Skip to content

Commit b7bb06b

Browse files
committed
fix(sandbox): bound directory-symlink traversal in SSH key discovery
1 parent 2dc8dcb commit b7bb06b

2 files changed

Lines changed: 120 additions & 6 deletions

File tree

‎internal/sandbox/ssh_discovery_limits_test.go‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,86 @@ func TestLinuxAbsentSSHKeyRefusesCommandBeforeCreation(t *testing.T) {
165165
})
166166
}
167167
}
168+
169+
func TestSSHDiscoverySymlinkDirectoryBounds(t *testing.T) {
170+
t.Run("symlink directory limit exceeded", func(t *testing.T) {
171+
sshDir := filepath.Join(t.TempDir(), ".ssh")
172+
mustWriteFile(t, filepath.Join(sshDir, "known_hosts"), "example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...")
173+
external := t.TempDir()
174+
curr := external
175+
for i := 0; i <= sshSymlinkMaxDirs; i++ {
176+
curr = filepath.Join(curr, fmt.Sprintf("dir-%03d", i))
177+
if err := os.Mkdir(curr, 0700); err != nil {
178+
t.Fatal(err)
179+
}
180+
}
181+
if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil {
182+
t.Fatal(err)
183+
}
184+
scanner := &sshDiscovery{}
185+
_ = scanner.walkPrivateKeyFiles(sshDir)
186+
if len(scanner.errors) == 0 {
187+
t.Fatal("expected discovery error for symlink directory limit exceeded, got none")
188+
}
189+
var matched bool
190+
for _, e := range scanner.errors {
191+
if strings.Contains(e, "symlink directory limit exceeded") {
192+
matched = true
193+
break
194+
}
195+
}
196+
if !matched {
197+
t.Fatalf("expected symlink directory limit exceeded, got %v", scanner.errors)
198+
}
199+
})
200+
201+
t.Run("symlink entry limit exceeded", func(t *testing.T) {
202+
sshDir := filepath.Join(t.TempDir(), ".ssh")
203+
if err := os.MkdirAll(sshDir, 0700); err != nil {
204+
t.Fatal(err)
205+
}
206+
external := t.TempDir()
207+
for i := 0; i <= sshSymlinkMaxEntries; i++ {
208+
mustWriteFile(t, filepath.Join(external, fmt.Sprintf("file-%04d", i)), "data")
209+
}
210+
if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil {
211+
t.Fatal(err)
212+
}
213+
scanner := &sshDiscovery{}
214+
_ = scanner.walkPrivateKeyFiles(sshDir)
215+
if len(scanner.errors) == 0 {
216+
t.Fatal("expected discovery error for symlink entry limit exceeded, got none")
217+
}
218+
var matched bool
219+
for _, e := range scanner.errors {
220+
if strings.Contains(e, "symlink entry limit exceeded") {
221+
matched = true
222+
break
223+
}
224+
}
225+
if !matched {
226+
t.Fatalf("expected symlink entry limit exceeded, got %v", scanner.errors)
227+
}
228+
})
229+
230+
t.Run("symlink within budget succeeds", func(t *testing.T) {
231+
sshDir := filepath.Join(t.TempDir(), ".ssh")
232+
if err := os.MkdirAll(sshDir, 0700); err != nil {
233+
t.Fatal(err)
234+
}
235+
external := t.TempDir()
236+
keyFile := filepath.Join(external, "custom_key")
237+
mustWriteFile(t, keyFile, sshPrivateKeyFixture())
238+
if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil {
239+
t.Fatal(err)
240+
}
241+
scanner := &sshDiscovery{}
242+
keys := scanner.walkPrivateKeyFiles(sshDir)
243+
if len(scanner.errors) != 0 {
244+
t.Fatalf("unexpected discovery errors: %v", scanner.errors)
245+
}
246+
if len(keys) == 0 {
247+
t.Fatal("expected discovered key in symlinked directory, got none")
248+
}
249+
})
250+
}

‎internal/sandbox/ssh_key_deny.go‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const sshPrivateKeyWalkPageSize = 256
2222

2323
const sshPrivateKeySniffBytes = 128
2424

25+
// sshSymlinkMaxDirs and sshSymlinkMaxEntries bound traversal of directory
26+
// trees reached through symlinks inside ~/.ssh. Real ~/.ssh directory trees
27+
// remain unbounded.
28+
const sshSymlinkMaxDirs = 64
29+
30+
const sshSymlinkMaxEntries = 1024
31+
2532
// sshWellKnownPrivateKeyNames are the OpenSSH default private-key basenames.
2633
// They are emitted even when ~/.ssh is absent so pathname-policy backends can
2734
// reserve them; mount-based Linux must refuse unprotected future key paths.
@@ -85,10 +92,19 @@ func (s *sshDiscovery) privateKeyDenyCandidates(home string) []string {
8592
var testSSHWalkChildHook func(dir string)
8693

8794
func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
95+
type pendingDir struct {
96+
path string
97+
viaSymlink bool
98+
}
99+
88100
var out []string
89101
visitedDirs := make(map[string]bool)
90-
pending := []string{sshDir}
91-
walk := func(dir string) {
102+
pending := []pendingDir{{path: sshDir, viaSymlink: false}}
103+
var symlinkDirsVisited int
104+
var symlinkEntriesSeen int
105+
106+
walk := func(item pendingDir) {
107+
dir := item.path
92108
realDir := dir
93109
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
94110
realDir = resolved
@@ -98,6 +114,14 @@ func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
98114
}
99115
visitedDirs[realDir] = true
100116

117+
if item.viaSymlink {
118+
symlinkDirsVisited++
119+
if symlinkDirsVisited > sshSymlinkMaxDirs {
120+
s.fail(dir, "symlink directory limit exceeded")
121+
return
122+
}
123+
}
124+
101125
root, err := os.OpenRoot(dir)
102126
if err != nil {
103127
if !os.IsNotExist(err) {
@@ -123,6 +147,13 @@ func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
123147
s.fail(dir, err.Error())
124148
return
125149
}
150+
if item.viaSymlink {
151+
symlinkEntriesSeen += len(entries)
152+
if symlinkEntriesSeen > sshSymlinkMaxEntries {
153+
s.fail(dir, "symlink entry limit exceeded")
154+
return
155+
}
156+
}
126157
if testSSHWalkChildHook != nil {
127158
testSSHWalkChildHook(dir)
128159
}
@@ -156,7 +187,7 @@ func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
156187
}
157188
targetStat, err := os.Stat(targetPath)
158189
if err == nil && targetStat.IsDir() {
159-
pending = append(pending, targetPath)
190+
pending = append(pending, pendingDir{path: targetPath, viaSymlink: true})
160191
continue
161192
}
162193
// Inspect leaf symlinks (bounded, specials rejected) so a
@@ -170,7 +201,7 @@ func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
170201
continue
171202
}
172203
if info.IsDir() {
173-
pending = append(pending, path)
204+
pending = append(pending, pendingDir{path: path, viaSymlink: item.viaSymlink})
174205
continue
175206
}
176207
if !mode.IsRegular() {
@@ -188,9 +219,9 @@ func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string {
188219
// Iteration keeps open directory handles and call-stack depth constant even
189220
// for deeply nested layouts. The physical-path set still breaks link cycles.
190221
for len(pending) > 0 {
191-
dir := pending[len(pending)-1]
222+
item := pending[len(pending)-1]
192223
pending = pending[:len(pending)-1]
193-
walk(dir)
224+
walk(item)
194225
}
195226
return out
196227
}

0 commit comments

Comments
 (0)