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
+42
View File
@@ -402,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
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)
}
}