From 8d940022b70f138cec446d5f6bbb9c2eebfafb47 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 21 Jun 2026 01:59:53 +0100 Subject: [PATCH] fix(search): drop matches outside the workspace root (symlink escape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/search/search.go | 19 +++++++++++++++ internal/search/search_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/internal/search/search.go b/internal/search/search.go index 74d20c4..fd35a92 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -258,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error { 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. func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) { results := &SearchResults{ @@ -288,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes 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 if maxResults > 0 && len(results.Results) >= maxResults { results.Truncated = true diff --git a/internal/search/search_test.go b/internal/search/search_test.go index faacf2c..6f66d6a 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -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) + } +}