fix(#141): address security-review-bot REQUEST_CHANGES findings
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 25s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 41s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 48s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 54s

Finding #1 [MAJOR]: replace os.Stat with os.Lstat in checkStaleDocs to
prevent symlink traversal. Symlinks under repoRoot could probe arbitrary
host file existence; Lstat never follows them. Symlinked docs are now
treated as stale.

Finding #2 [MINOR]: resolve --repo-root with filepath.Abs +
filepath.EvalSymlinks before passing to checkStaleDocs, so a symlinked
repo-root cannot bypass the filepath.Rel escape guard.

Finding #3 [NIT]: reject backslashes in ValidateDocPath to prevent
Windows platform edge cases where a path separator may be normalised
differently by the host OS or VCS backend.

Tests added:
- TestCheckStaleDocs_SymlinkOutside: symlink inside repo → outside
- TestCheckStaleDocs_SymlinkInsideRepo: intra-repo symlink also rejected
- TestRunValidateDocmap_SymlinkRepoRoot: symlinked --repo-root resolves OK
- TestValidateDocPath_Backslash: backslash paths rejected
- Backslash cases added to TestValidateDocPath invalid slice

All go test ./... pass, go vet ./... clean.
This commit is contained in:
Rodin
2026-05-14 23:50:12 -07:00
parent 3f8da76b42
commit b22de19aa1
4 changed files with 161 additions and 11 deletions
+28 -8
View File
@@ -83,10 +83,22 @@ func runValidateDocmap(args []string) int {
}
// --- Check 2: Stale docs ---
// Resolve repoRoot to an absolute, symlink-free path before any Rel checks
// so that a symlinked --repo-root cannot be used to bypass the escape
// guard in checkStaleDocs.
absRoot, err := filepath.Abs(*repoRootFlag)
if err != nil {
fmt.Fprintf(errWriter, "Error: failed to resolve --repo-root %q: %v\n", *repoRootFlag, err)
return 2
}
resolvedRoot, err := filepath.EvalSymlinks(absRoot)
if err != nil {
fmt.Fprintf(errWriter, "Error: failed to resolve --repo-root %q: %v\n", *repoRootFlag, err)
return 2
}
// checkStaleDocs validates each path before touching the filesystem; see
// its documentation for the path-traversal hardening applied.
repoRoot := filepath.Clean(*repoRootFlag)
staleDocs := checkStaleDocs(cfg, repoRoot)
staleDocs := checkStaleDocs(cfg, resolvedRoot)
if len(staleDocs) > 0 {
failed = true
fmt.Fprintln(errWriter, "ERROR: stale docmap docs: entries (paths do not exist):")
@@ -108,9 +120,11 @@ func runValidateDocmap(args []string) int {
//
// Path-traversal hardening: each docPath is validated with
// review.ValidateDocPath (rejects absolute paths and ".." segments) and then
// confined to repoRoot via filepath.Clean + filepath.Rel before os.Stat is
// called. Paths that fail either check are treated as invalid (reported as
// stale) without touching the host filesystem.
// confined to repoRoot via filepath.Clean + filepath.Rel before os.Lstat is
// called. Symlinks are treated as stale — a CI tool running against
// PR-controlled content must not follow symlinks that could probe arbitrary
// host paths. Paths that fail any check are treated as invalid (reported as
// stale) without following any symlinks.
func checkStaleDocs(cfg *review.DocMapConfig, repoRoot string) []string {
seen := make(map[string]struct{})
var stale []string
@@ -142,9 +156,15 @@ func checkStaleDocs(cfg *review.DocMapConfig, repoRoot string) []string {
continue
}
// Safe to stat: path is relative, contains no "..", and is
// confined within repoRoot.
if _, err := os.Stat(fullPath); err != nil {
// Use Lstat (not Stat) so symlinks are never followed. A symlink
// under repoRoot could point anywhere on the host, allowing a
// malicious PR to probe file existence. Treat symlinks as stale.
fi, err := os.Lstat(fullPath)
if err != nil {
stale = append(stale, docPath)
continue
}
if fi.Mode()&os.ModeSymlink != 0 {
stale = append(stale, docPath)
}
}