fix(#150): return resolved path from validateDocmapPath to close TOCTOU gap
Finding 4 [MINOR] from self-review: Previously, validateDocmapPath validated *docmapFlag then returned error only, leaving the caller to re-open the original (unresolved) path via ParseDocMapConfig. In theory, the path could change between validation and use (check-then-use race). Change validateDocmapPath to return (string, error): on success it returns the filepath.EvalSymlinks-resolved absolute path. The caller now passes resolvedDocmap to ParseDocMapConfig instead of the original *docmapFlag string, eliminating any check-then-use window. Also update the test for TestValidateDocmapPath_DirSymlinkBypass to use the new two-value return: _ for the resolved path, err for the error. Low-risk in ephemeral CI but correct by construction.
This commit is contained in:
@@ -31,11 +31,11 @@ const maxDocmapBytes int64 = 10 * 1024 * 1024 // 10 MB
|
|||||||
//
|
//
|
||||||
// resolvedRoot must already be an absolute, symlink-free path (obtained from
|
// resolvedRoot must already be an absolute, symlink-free path (obtained from
|
||||||
// filepath.Abs + filepath.EvalSymlinks).
|
// filepath.Abs + filepath.EvalSymlinks).
|
||||||
func validateDocmapPath(localPath, resolvedRoot string) error {
|
func validateDocmapPath(localPath, resolvedRoot string) (string, error) {
|
||||||
// Resolve the docmap path to an absolute path.
|
// Resolve the docmap path to an absolute path.
|
||||||
absPath, err := filepath.Abs(localPath)
|
absPath, err := filepath.Abs(localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot resolve path: %w", err)
|
return "", fmt.Errorf("cannot resolve path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve ALL symlink components, not just the final one.
|
// Resolve ALL symlink components, not just the final one.
|
||||||
@@ -47,29 +47,29 @@ func validateDocmapPath(localPath, resolvedRoot string) error {
|
|||||||
// path is inside the root while the actual destination is not.
|
// path is inside the root while the actual destination is not.
|
||||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot resolve path (symlink): %w", err)
|
return "", fmt.Errorf("cannot resolve path (symlink): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lstat the resolved path — EvalSymlinks guarantees resolvedPath is
|
// Lstat the resolved path — EvalSymlinks guarantees resolvedPath is
|
||||||
// symlink-free, so ModeSymlink can never be set here; this is unreachable.
|
// symlink-free, so ModeSymlink can never be set here; this is unreachable.
|
||||||
fi, err := os.Lstat(resolvedPath)
|
fi, err := os.Lstat(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot stat file: %w", err)
|
return "", fmt.Errorf("cannot stat file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confine to resolvedRoot: use the fully-resolved path so that a directory
|
// Confine to resolvedRoot: use the fully-resolved path so that a directory
|
||||||
// symlink inside the repo cannot carry the path outside the root.
|
// symlink inside the repo cannot carry the path outside the root.
|
||||||
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
|
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
return fmt.Errorf("path must be within --repo-root")
|
return "", fmt.Errorf("path must be within --repo-root")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce size cap before reading to prevent memory exhaustion.
|
// Enforce size cap before reading to prevent memory exhaustion.
|
||||||
if fi.Size() > maxDocmapBytes {
|
if fi.Size() > maxDocmapBytes {
|
||||||
return fmt.Errorf("file size %d bytes exceeds %d-byte limit", fi.Size(), maxDocmapBytes)
|
return "", fmt.Errorf("file size %d bytes exceeds %d-byte limit", fi.Size(), maxDocmapBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return resolvedPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runValidateDocmap implements the `review-bot validate-docmap` subcommand.
|
// runValidateDocmap implements the `review-bot validate-docmap` subcommand.
|
||||||
@@ -137,15 +137,19 @@ func runValidateDocmap(args []string) int {
|
|||||||
// if they resolve to a path inside the root).
|
// if they resolve to a path inside the root).
|
||||||
// 3. Does not exceed maxDocmapBytes (prevent memory exhaustion from an
|
// 3. Does not exceed maxDocmapBytes (prevent memory exhaustion from an
|
||||||
// oversized committed file).
|
// oversized committed file).
|
||||||
if err := validateDocmapPath(*docmapFlag, resolvedRoot); err != nil {
|
// validateDocmapPath returns the resolved path; use it directly to
|
||||||
|
// eliminate any TOCTOU race between validation and use.
|
||||||
|
resolvedDocmap, err := validateDocmapPath(*docmapFlag, resolvedRoot)
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintf(errWriter, "Error: --docmap %q is invalid: %v\n", *docmapFlag, err)
|
fmt.Fprintf(errWriter, "Error: --docmap %q is invalid: %v\n", *docmapFlag, err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse docmap YAML.
|
// Parse docmap YAML using the resolved path — eliminates any TOCTOU race
|
||||||
cfg, err := review.ParseDocMapConfig(*docmapFlag)
|
// between validation and use.
|
||||||
|
cfg, err := review.ParseDocMapConfig(resolvedDocmap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errWriter, "Error: failed to parse docmap %q: %v\n", *docmapFlag, err)
|
fmt.Fprintf(errWriter, "Error: failed to parse docmap %q: %v\n", resolvedDocmap, err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
|
|||||||
t.Fatalf("EvalSymlinks(repoDir): %v", err)
|
t.Fatalf("EvalSymlinks(repoDir): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
|
if _, err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
|
||||||
t.Error("expected rejection of dir-symlink bypass, got nil error")
|
t.Error("expected rejection of dir-symlink bypass, got nil error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user