fix: canonicalize root in safeJoin to handle symlinked storage roots

When the configured storage root (or a system temp dir on macOS where
/var → /private/var) is reached through a symlink, filepath.EvalSymlinks
on a child path resolves to the canonical form, but within() was comparing
against the lexical root — causing valid paths to be rejected with
"files: path escapes its area".

Fix: resolve the root once with EvalSymlinks before the symlink guard
loop, and compare resolved paths against the canonical root. The initial
lexical containment check (line 108) still uses the original root so
that the returned path keeps the caller's expected prefix.

Adds two regression tests:
  - TestSafeJoinSymlinkedRoot: valid file under a symlinked root is accepted
  - TestSafeJoinChildSymlinkEscapeStillBlocked: escaping child symlink is still rejected

Fixes #62
This commit is contained in:
Kyle Paul Zengo 2026-06-29 01:31:55 +00:00
parent f2bcb7e063
commit e345845c41
2 changed files with 60 additions and 1 deletions

View file

@ -108,12 +108,19 @@ func safeJoin(root, rel string) (string, error) {
if !within(root, full) {
return "", errEscape
}
// Canonicalize the root so that a symlinked storage root (e.g. /var →
// /private/var on macOS, or an operator-configured symlink) doesn't cause
// false-positive escapes when EvalSymlinks resolves the full path.
canonRoot := root
if r, err := filepath.EvalSymlinks(root); err == nil {
canonRoot = r
}
// Symlink guard: resolve the longest existing prefix and re-check. This
// catches a symlink (created out-of-band) that points outside the area.
probe := full
for {
if resolvedPath, err := filepath.EvalSymlinks(probe); err == nil {
if !within(root, resolvedPath) {
if !within(canonRoot, resolvedPath) {
return "", errEscape
}
break