os.Lstat(absPath) follows the dir symlink, finds a regular file outside repo — no symlink bit on final component ✅
filepath.Rel(resolvedRoot, absPath) passes because path is textually inside repo ✅
File gets read → bypass
This was verified with a reproducible Go test.
Fix
In validateDocmapPath, after filepath.Abs, call filepath.EvalSymlinks and use the resolved path for all subsequent checks:
absPath,err:=filepath.Abs(localPath)iferr!=nil{returnfmt.Errorf("cannot resolve path: %w",err)}// Resolve symlinks in ALL components (not just the final one).// This catches directory symlinks that could bypass the confinement check.resolvedPath,err:=filepath.EvalSymlinks(absPath)iferr!=nil{returnfmt.Errorf("cannot resolve path (symlink): %w",err)}// Use resolvedPath from here on for Rel check and Lstatfi,err:=os.Lstat(resolvedPath)// ...rel,err:=filepath.Rel(resolvedRoot,resolvedPath)
Note: after EvalSymlinks the path is already symlink-free, so the ModeSymlink check can be removed or kept as defense-in-depth (it will never fire, but the comment is still educational).
Also add test
funcTestValidateDocmapPath_DirSymlinkBypass(t*testing.T){repoDir:=t.TempDir()outsideDir:=t.TempDir()// Secret file outside repo os.WriteFile(filepath.Join(outsideDir,"secret.yml"),[]byte("mappings: []"),0o644)// Directory symlink inside repo pointing outsideos.Symlink(outsideDir,filepath.Join(repoDir,".review-bot"))// Attempt to read outside file via directory symlinkattackPath:=filepath.Join(repoDir,".review-bot","secret.yml")err:=validateDocmapPath(attackPath,repoDir)iferr==nil{t.Error("expected rejection of dir-symlink bypass, got nil")}}
Severity
MINOR — requires the PR author to commit .review-bot/ as a directory symlink (aggressive modification) and relies on the CI-configured --docmap path landing on a parseable YAML outside the repo. Not easily exploitable in typical usage.
Discovered during self-review of PR #142 (post-merge).
## Summary
`validateDocmapPath` in `cmd/review-bot/validatedocmap.go` has a directory-symlink bypass that contradicts its stated security invariant.
## Background
PR #142 added path-confinement hardening to prevent reading arbitrary host files via `--docmap`. The hardening correctly:
- Calls `filepath.EvalSymlinks` on `--repo-root` to resolve it
- Uses `os.Lstat` to reject file-level symlinks
- Uses `filepath.Rel` for confinement check
However, `os.Lstat(absPath)` only avoids following the **final** component of the path. It **does** follow intermediate directory symlinks.
## Bypass
If a PR commits `.review-bot/` as a directory symlink pointing outside the repo, and CI is configured with `--docmap .review-bot/doc-map.yml`:
1. `filepath.Abs("/.review-bot/doc-map.yml")` → textually inside repo ✅
2. `os.Lstat(absPath)` follows the dir symlink, finds a regular file outside repo — no symlink bit on final component ✅
3. `filepath.Rel(resolvedRoot, absPath)` passes because path is textually inside repo ✅
4. File gets read → **bypass**
This was verified with a reproducible Go test.
## Fix
In `validateDocmapPath`, after `filepath.Abs`, call `filepath.EvalSymlinks` and use the resolved path for all subsequent checks:
```go
absPath, err := filepath.Abs(localPath)
if err != nil {
return fmt.Errorf("cannot resolve path: %w", err)
}
// Resolve symlinks in ALL components (not just the final one).
// This catches directory symlinks that could bypass the confinement check.
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return fmt.Errorf("cannot resolve path (symlink): %w", err)
}
// Use resolvedPath from here on for Rel check and Lstat
fi, err := os.Lstat(resolvedPath)
// ...
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
```
Note: after `EvalSymlinks` the path is already symlink-free, so the `ModeSymlink` check can be removed or kept as defense-in-depth (it will never fire, but the comment is still educational).
## Also add test
```go
func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
repoDir := t.TempDir()
outsideDir := t.TempDir()
// Secret file outside repo
os.WriteFile(filepath.Join(outsideDir, "secret.yml"), []byte("mappings: []"), 0o644)
// Directory symlink inside repo pointing outside
os.Symlink(outsideDir, filepath.Join(repoDir, ".review-bot"))
// Attempt to read outside file via directory symlink
attackPath := filepath.Join(repoDir, ".review-bot", "secret.yml")
err := validateDocmapPath(attackPath, repoDir)
if err == nil {
t.Error("expected rejection of dir-symlink bypass, got nil")
}
}
```
## Severity
MINOR — requires the PR author to commit `.review-bot/` as a directory symlink (aggressive modification) and relies on the CI-configured `--docmap` path landing on a parseable YAML outside the repo. Not easily exploitable in typical usage.
Discovered during self-review of PR #142 (post-merge).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
validateDocmapPathincmd/review-bot/validatedocmap.gohas a directory-symlink bypass that contradicts its stated security invariant.Background
PR #142 added path-confinement hardening to prevent reading arbitrary host files via
--docmap. The hardening correctly:filepath.EvalSymlinkson--repo-rootto resolve itos.Lstatto reject file-level symlinksfilepath.Relfor confinement checkHowever,
os.Lstat(absPath)only avoids following the final component of the path. It does follow intermediate directory symlinks.Bypass
If a PR commits
.review-bot/as a directory symlink pointing outside the repo, and CI is configured with--docmap .review-bot/doc-map.yml:filepath.Abs("/.review-bot/doc-map.yml")→ textually inside repo ✅os.Lstat(absPath)follows the dir symlink, finds a regular file outside repo — no symlink bit on final component ✅filepath.Rel(resolvedRoot, absPath)passes because path is textually inside repo ✅This was verified with a reproducible Go test.
Fix
In
validateDocmapPath, afterfilepath.Abs, callfilepath.EvalSymlinksand use the resolved path for all subsequent checks:Note: after
EvalSymlinksthe path is already symlink-free, so theModeSymlinkcheck can be removed or kept as defense-in-depth (it will never fire, but the comment is still educational).Also add test
Severity
MINOR — requires the PR author to commit
.review-bot/as a directory symlink (aggressive modification) and relies on the CI-configured--docmappath landing on a parseable YAML outside the repo. Not easily exploitable in typical usage.Discovered during self-review of PR #142 (post-merge).