fix(search): drop matches outside the workspace root (symlink escape)

file_search runs ripgrep with --follow on by default, so a symlink inside the
workspace pointing outside it (e.g. link -> /etc) caused rg to traverse out and
return matches from external files — leaking their content to the caller despite
the workspace-scoping contract. parseOutput emitted match paths verbatim with no
re-validation.

Filter every match through IsPathAllowed (resolving rg's workspace-relative path
against the root first); IsPathAllowed's EvalSymlinks rejects the escape while
still permitting legitimate worktree siblings. Defense in depth that also covers
--no-ignore and any other traversal that reaches outside the root.

Adds a security regression test (proven to fail without the filter).
This commit is contained in:
2026-06-21 01:59:53 +01:00
parent 99de8a93b9
commit 8d940022b7
2 changed files with 61 additions and 0 deletions
+19
View File
@@ -258,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
WithRemediation("Provide paths within the workspace root") WithRemediation("Provide paths within the workspace root")
} }
// resultPathAllowed reports whether a ripgrep result path is within the
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
// to it, so a relative path is joined to the root before the check; IsPathAllowed
// then resolves symlinks, rejecting any path that --follow led outside the root.
func (s *Searcher) resultPathAllowed(p string) bool {
if !filepath.IsAbs(p) {
p = filepath.Join(s.cfg.WorkspaceRoot, p)
}
return s.cfg.IsPathAllowed(p)
}
// parseOutput parses ripgrep JSON output. // parseOutput parses ripgrep JSON output.
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) { func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
results := &SearchResults{ results := &SearchResults{
@@ -288,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
continue continue
} }
// Defense in depth: never surface a file outside the workspace root.
// ripgrep with --follow (default on) can traverse a symlink whose
// target lives outside the root; dropping those matches keeps search
// from leaking external file content.
if !s.resultPathAllowed(match.Path.Text) {
continue
}
// Check max results // Check max results
if maxResults > 0 && len(results.Results) >= maxResults { if maxResults > 0 && len(results.Results) >= maxResults {
results.Truncated = true results.Truncated = true
+42
View File
@@ -402,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
t.Error("expected error for empty pattern") t.Error("expected error for empty pattern")
} }
} }
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
// is outside the workspace root. Such matches must never be returned — search
// must not leak the content of files outside the workspace.
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
const secret = "SUPERSECRETLEAKCANARY"
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
// A regular in-workspace file that does NOT contain the secret.
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
t.Fatal(err)
}
// A symlink inside the workspace pointing at the outside directory.
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
cfg := config.Default()
cfg.WorkspaceRoot = workspace
if !cfg.FollowSymlinks {
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
s, err := New(cfg, logger)
if err != nil {
t.Skip("ripgrep not installed")
}
results, err := s.Search(context.Background(), &Request{Pattern: secret})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, r := range results.Results {
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
}
}