Skip to content

Commit 4931f87

Browse files
authored
Merge pull request #2433 from dgageot/fix6
fix: prevent symlink-based path traversal in ACP filesystem toolset
2 parents 659ec86 + d602d91 commit 4931f87

File tree

2 files changed

+125
-4
lines changed

2 files changed

+125
-4
lines changed

pkg/acp/filesystem.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"os"
78
"path/filepath"
89
"strings"
910

@@ -71,6 +72,8 @@ func (t *FilesystemToolset) Tools(ctx context.Context) ([]tools.Tool, error) {
7172

7273
// resolvePath resolves a user-supplied path relative to the working directory
7374
// and validates that the resulting path does not escape the working directory.
75+
// It follows symlinks to prevent a symlink inside the working directory from
76+
// pointing outside it.
7477
func (t *FilesystemToolset) resolvePath(userPath string) (string, error) {
7578
resolved := filepath.Clean(filepath.Join(t.workingDir, userPath))
7679
absWorkingDir, err := filepath.Abs(t.workingDir)
@@ -81,14 +84,52 @@ func (t *FilesystemToolset) resolvePath(userPath string) (string, error) {
8184
if err != nil {
8285
return "", fmt.Errorf("failed to resolve path: %w", err)
8386
}
87+
88+
// Resolve symlinks. For paths that don't exist yet (e.g. a new file
89+
// being created), walk up to the nearest existing ancestor, resolve
90+
// symlinks on that, then re-append the remaining components.
91+
realResolved, err := evalSymlinksAllowMissing(absResolved)
92+
if err != nil {
93+
return "", fmt.Errorf("failed to evaluate symlinks: %w", err)
94+
}
95+
realWorkingDir, err := filepath.EvalSymlinks(absWorkingDir)
96+
if err != nil {
97+
return "", fmt.Errorf("failed to evaluate symlinks for working directory: %w", err)
98+
}
99+
84100
// Normalize paths for comparison to prevent bypasses on case-insensitive
85101
// filesystems (macOS, Windows) where differing case could defeat the check.
86-
normResolved := normalizePathForComparison(absResolved)
87-
normWorkingDir := normalizePathForComparison(absWorkingDir)
102+
normResolved := normalizePathForComparison(realResolved)
103+
normWorkingDir := normalizePathForComparison(realWorkingDir)
88104
if !strings.HasPrefix(normResolved, normWorkingDir+string(filepath.Separator)) && normResolved != normWorkingDir {
89105
return "", fmt.Errorf("path %q escapes the working directory", userPath)
90106
}
91-
return absResolved, nil
107+
return realResolved, nil
108+
}
109+
110+
// evalSymlinksAllowMissing resolves symlinks for a path that may not fully
111+
// exist. It walks up from the given path until it finds an existing ancestor,
112+
// resolves symlinks on that ancestor, then re-appends the missing tail.
113+
func evalSymlinksAllowMissing(path string) (string, error) {
114+
resolved, err := filepath.EvalSymlinks(path)
115+
if err == nil {
116+
return resolved, nil
117+
}
118+
if !os.IsNotExist(err) {
119+
return "", err
120+
}
121+
122+
// Walk up to find the nearest existing ancestor.
123+
parent := filepath.Dir(path)
124+
if parent == path {
125+
// Reached filesystem root without finding an existing path.
126+
return path, nil
127+
}
128+
realParent, err := evalSymlinksAllowMissing(parent)
129+
if err != nil {
130+
return "", err
131+
}
132+
return filepath.Join(realParent, filepath.Base(path)), nil
92133
}
93134

94135
func (t *FilesystemToolset) handleReadFile(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) {

pkg/acp/filesystem_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package acp
22

33
import (
4+
"os"
45
"path/filepath"
6+
"runtime"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
@@ -17,7 +19,7 @@ func TestResolvePath(t *testing.T) {
1719
workingDir: workingDir,
1820
}
1921

20-
absWorkingDir, err := filepath.Abs(workingDir)
22+
absWorkingDir, err := filepath.EvalSymlinks(workingDir)
2123
require.NoError(t, err)
2224

2325
tests := []struct {
@@ -85,3 +87,81 @@ func TestNormalizePathForComparison(t *testing.T) {
8587
// The exact behavior depends on the platform.
8688
assert.NotEmpty(t, result)
8789
}
90+
91+
func TestResolvePath_SymlinkEscape(t *testing.T) {
92+
t.Parallel()
93+
94+
if runtime.GOOS == "windows" {
95+
t.Skip("symlink test not reliable on Windows")
96+
}
97+
98+
workingDir := t.TempDir()
99+
outsideDir := t.TempDir()
100+
101+
// Create a secret file outside the working directory.
102+
secretFile := filepath.Join(outsideDir, "secret.txt")
103+
require.NoError(t, os.WriteFile(secretFile, []byte("secret"), 0o644))
104+
105+
// Create a symlink inside the working directory pointing outside.
106+
symlink := filepath.Join(workingDir, "escape")
107+
require.NoError(t, os.Symlink(outsideDir, symlink))
108+
109+
ts := &FilesystemToolset{workingDir: workingDir}
110+
111+
// Accessing a file through the symlink should be blocked.
112+
_, err := ts.resolvePath("escape/secret.txt")
113+
require.Error(t, err)
114+
assert.Contains(t, err.Error(), "escapes the working directory")
115+
116+
// The symlink target itself should also be blocked.
117+
_, err = ts.resolvePath("escape")
118+
require.Error(t, err)
119+
assert.Contains(t, err.Error(), "escapes the working directory")
120+
}
121+
122+
func TestResolvePath_SymlinkWithinWorkingDir(t *testing.T) {
123+
t.Parallel()
124+
125+
if runtime.GOOS == "windows" {
126+
t.Skip("symlink test not reliable on Windows")
127+
}
128+
129+
workingDir := t.TempDir()
130+
131+
// Create a subdirectory and a symlink to it within the working dir.
132+
subdir := filepath.Join(workingDir, "real")
133+
require.NoError(t, os.Mkdir(subdir, 0o755))
134+
require.NoError(t, os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("ok"), 0o644))
135+
136+
link := filepath.Join(workingDir, "link")
137+
require.NoError(t, os.Symlink(subdir, link))
138+
139+
ts := &FilesystemToolset{workingDir: workingDir}
140+
141+
// Symlink within working dir should be allowed.
142+
resolved, err := ts.resolvePath("link/file.txt")
143+
require.NoError(t, err)
144+
assert.Contains(t, resolved, "real/file.txt")
145+
}
146+
147+
func TestResolvePath_NonExistentPathWithSymlinkAncestor(t *testing.T) {
148+
t.Parallel()
149+
150+
if runtime.GOOS == "windows" {
151+
t.Skip("symlink test not reliable on Windows")
152+
}
153+
154+
workingDir := t.TempDir()
155+
outsideDir := t.TempDir()
156+
157+
// Symlink inside working dir pointing outside.
158+
symlink := filepath.Join(workingDir, "escape")
159+
require.NoError(t, os.Symlink(outsideDir, symlink))
160+
161+
ts := &FilesystemToolset{workingDir: workingDir}
162+
163+
// Even for a non-existent file under the symlink, traversal should be blocked.
164+
_, err := ts.resolvePath("escape/nonexistent.txt")
165+
require.Error(t, err)
166+
assert.Contains(t, err.Error(), "escapes the working directory")
167+
}

0 commit comments

Comments
 (0)