mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-11 04:47:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user