@@ -2,6 +2,8 @@ package cli
22
33import (
44 "bytes"
5+ "os"
6+ "path/filepath"
57 "testing"
68
79 "github.com/spf13/cobra"
@@ -59,6 +61,174 @@ func TestLLMEndpointValidation(t *testing.T) {
5961 }
6062}
6163
64+ // TestHasWritableAncestor exercises the TOCTOU guard that gates the best-effort
65+ // chmod of the global config dir in PersistentPreRunE. The chmod is skipped when
66+ // any ancestor — not just the immediate parent — is group/world-writable, since a
67+ // writable ancestor anywhere up the path lets an attacker substitute a path
68+ // component and win the Lstat->Chmod race.
69+ //
70+ // hasWritableAncestor walks the filesystem all the way to root, so a clean
71+ // ("proceeds") case needs a base whose entire ancestry is non-writable. t.TempDir()
72+ // lives under a world-writable /tmp (1777), which would always trip the guard, so
73+ // the tree is built under the user's home dir instead and the proceeds assertion is
74+ // skipped on environments whose HOME itself sits under a writable ancestor.
75+ //
76+ // The helper walks BOTH the lexical ancestor chain of the original path AND the
77+ // chain of the symlink-resolved real path; either chain being writable is unsafe.
78+ // EvalSymlinks requires the path to exist, so cfgDir leaves are created (not just
79+ // their parents) in these cases. It also treats relative and unresolvable paths as
80+ // unsafe.
81+ func TestHasWritableAncestor (t * testing.T ) {
82+ home , err := os .UserHomeDir ()
83+ require .NoError (t , err )
84+
85+ // base is a freshly created, 0755 dir under HOME; only its ancestry (HOME and
86+ // above) plus whatever we loosen below can be writable.
87+ base , err := os .MkdirTemp (home , "hwa-test-" )
88+ require .NoError (t , err )
89+ t .Cleanup (func () { _ = os .RemoveAll (base ) })
90+ require .NoError (t , os .Chmod (base , 0o755 ))
91+
92+ t .Run ("relative path => unsafe" , func (t * testing.T ) {
93+ // A relative config dir can't be reasoned about (real ancestry depends on
94+ // cwd), so it must be treated as unsafe regardless of the filesystem.
95+ assert .True (t , hasWritableAncestor (filepath .Join ("foo" , "basecamp" )))
96+ })
97+
98+ t .Run ("non-writable ancestors => chmod proceeds" , func (t * testing.T ) {
99+ if hasWritableAncestor (base ) {
100+ t .Skip ("HOME has a writable ancestor; cannot demonstrate the proceeds case here" )
101+ }
102+ grandparent := filepath .Join (base , "ok-gp" )
103+ parent := filepath .Join (grandparent , "parent" )
104+ cfgDir := filepath .Join (parent , "basecamp" )
105+ require .NoError (t , os .MkdirAll (cfgDir , 0o755 ))
106+
107+ assert .False (t , hasWritableAncestor (cfgDir ))
108+ })
109+
110+ t .Run ("writable immediate parent => chmod skipped" , func (t * testing.T ) {
111+ parent := filepath .Join (base , "wp-parent" )
112+ cfgDir := filepath .Join (parent , "basecamp" )
113+ require .NoError (t , os .MkdirAll (cfgDir , 0o755 ))
114+ require .NoError (t , os .Chmod (parent , 0o777 ))
115+
116+ assert .True (t , hasWritableAncestor (cfgDir ))
117+ })
118+
119+ t .Run ("non-writable parent but writable grandparent => chmod skipped" , func (t * testing.T ) {
120+ grandparent := filepath .Join (base , "wgp-gp" )
121+ parent := filepath .Join (grandparent , "parent" )
122+ cfgDir := filepath .Join (parent , "basecamp" )
123+ require .NoError (t , os .MkdirAll (cfgDir , 0o755 ))
124+ // Loosen the grandparent only; the immediate parent stays 0755.
125+ require .NoError (t , os .Chmod (grandparent , 0o777 ))
126+
127+ assert .True (t , hasWritableAncestor (cfgDir ))
128+ })
129+
130+ t .Run ("symlink into writable real ancestor => chmod skipped" , func (t * testing.T ) {
131+ if hasWritableAncestor (base ) {
132+ t .Skip ("HOME has a writable ancestor; cannot isolate the symlinked-ancestor case here" )
133+ }
134+ // Real target lives under a world-writable ancestor (writable/real-cfg),
135+ // but the lexical path to the symlink (safe/link) has only non-writable
136+ // ancestors. Lexical walking would miss the writable ancestor; symlink
137+ // resolution must catch it.
138+ writable := filepath .Join (base , "writable" )
139+ realCfg := filepath .Join (writable , "real-cfg" )
140+ require .NoError (t , os .MkdirAll (realCfg , 0o755 ))
141+ require .NoError (t , os .Chmod (writable , 0o777 ))
142+
143+ safe := filepath .Join (base , "safe" )
144+ require .NoError (t , os .MkdirAll (safe , 0o755 ))
145+ link := filepath .Join (safe , "link" )
146+ if err := os .Symlink (realCfg , link ); err != nil {
147+ t .Skipf ("symlinks unavailable in this environment: %v" , err )
148+ }
149+
150+ assert .True (t , hasWritableAncestor (link ))
151+ })
152+
153+ t .Run ("symlink whose lexical parent is writable but target tree is private => chmod skipped" , func (t * testing.T ) {
154+ if hasWritableAncestor (base ) {
155+ t .Skip ("HOME has a writable ancestor; cannot isolate the writable-symlink-parent case here" )
156+ }
157+ // The dual to the prior case: here the symlink's TARGET tree is entirely
158+ // private (0755) but the world-writable dir holding the symlink is in the
159+ // LEXICAL chain only — EvalSymlinks jumps to the target and skips it, so a
160+ // resolved-only walk returns "safe". A local user with write access to the
161+ // writable dir can swap the symlink between our Lstat and Chmod, so the
162+ // lexical chain must catch it.
163+ realTree := filepath .Join (base , "private-real" , "sub" )
164+ require .NoError (t , os .MkdirAll (realTree , 0o755 ))
165+
166+ writable := filepath .Join (base , "world-writable" )
167+ require .NoError (t , os .MkdirAll (writable , 0o755 ))
168+ require .NoError (t , os .Chmod (writable , 0o777 ))
169+ link := filepath .Join (writable , "link" )
170+ if err := os .Symlink (realTree , link ); err != nil {
171+ t .Skipf ("symlinks unavailable in this environment: %v" , err )
172+ }
173+
174+ // link/leaf resolves into the private tree, but its lexical parent chain
175+ // runs through the world-writable dir.
176+ assert .True (t , hasWritableAncestor (filepath .Join (link , "leaf" )))
177+ })
178+ }
179+
180+ // TestIsForeignOwnerWritable exercises the foreign-owner half of the TOCTOU guard
181+ // directly. The helper only flags ancestors owned by a DIFFERENT non-root user
182+ // that still carry the owner-write bit; root-owned and self-owned dirs are trusted
183+ // regardless of their owner-write bit. Most cases run as any user; the genuine
184+ // "true" case requires chown'ing to a foreign uid, which only root can do, so it is
185+ // skipped otherwise.
186+ func TestIsForeignOwnerWritable (t * testing.T ) {
187+ home , err := os .UserHomeDir ()
188+ require .NoError (t , err )
189+ base , err := os .MkdirTemp (home , "ifow-test-" )
190+ require .NoError (t , err )
191+ t .Cleanup (func () { _ = os .RemoveAll (base ) })
192+
193+ t .Run ("self-owned 0700 => false" , func (t * testing.T ) {
194+ dir := filepath .Join (base , "self-0700" )
195+ require .NoError (t , os .Mkdir (dir , 0o700 ))
196+ fi , err := os .Stat (dir )
197+ require .NoError (t , err )
198+ assert .False (t , isForeignOwnerWritable (fi ))
199+ })
200+
201+ t .Run ("self-owned 0755 => false (uid==self; group/world bits handled elsewhere)" , func (t * testing.T ) {
202+ dir := filepath .Join (base , "self-0755" )
203+ require .NoError (t , os .Mkdir (dir , 0o755 ))
204+ require .NoError (t , os .Chmod (dir , 0o755 ))
205+ fi , err := os .Stat (dir )
206+ require .NoError (t , err )
207+ // Owned by us, so the foreign-owner check is false even with the
208+ // owner-write bit set; group/world-writability is a separate concern
209+ // handled by hasWritableChain, not this helper.
210+ assert .False (t , isForeignOwnerWritable (fi ))
211+ })
212+
213+ t .Run ("foreign non-root owner with owner-write => true" , func (t * testing.T ) {
214+ // A genuine foreign-owned, owner-writable dir can only be produced by
215+ // chown'ing to another uid, which requires root. Skip otherwise.
216+ if os .Geteuid () != 0 {
217+ t .Skip ("need root to chown a dir to a foreign uid" )
218+ }
219+ dir := filepath .Join (base , "foreign-0755" )
220+ require .NoError (t , os .Mkdir (dir , 0o755 ))
221+ // Chown to a non-root, non-self uid (nobody-ish). Skip if it fails.
222+ const foreignUID = 65534 // conventionally "nobody"
223+ if err := os .Chown (dir , foreignUID , foreignUID ); err != nil {
224+ t .Skipf ("cannot chown to foreign uid %d: %v" , foreignUID , err )
225+ }
226+ fi , err := os .Stat (dir )
227+ require .NoError (t , err )
228+ assert .True (t , isForeignOwnerWritable (fi ))
229+ })
230+ }
231+
62232func TestResolvePreferences (t * testing.T ) {
63233 boolPtr := func (b bool ) * bool { return & b }
64234 intPtr := func (i int ) * int { return & i }
0 commit comments