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) + } +}