Skip to content

fix: allow safe relative subfolders in filename while still blocking traversal - #32

Merged
artemo-brd merged 2 commits into
mainfrom
fix/path-traversal-allow-safe-subfolders
Aug 2, 2026
Merged

fix: allow safe relative subfolders in filename while still blocking traversal#32
artemo-brd merged 2 commits into
mainfrom
fix/path-traversal-allow-safe-subfolders

Conversation

@artemo-brd

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to the path-traversal fix (CWE-22) already on main. That fix reduced anyfilename to its bare basename via path.basename() — safe, but more aggressive than necessary: it also stripped legitimate relative subfolders developers commonly use (e.g. filename: 'output/data.json'), even though those never posed a traversal risk. Since that basename-only behavior was never published to npm, this PR is what users will actually see as the shipped behavior.

What changed

FilenameSchema (src/schemas/shared.ts) — now allows relative subfolder segments (output/data.json, a/b/c/file.txt), and instead explicitly rejects, with a clear ValidationError (not a silent rewrite):

  • ".." segments anywhere in the path
  • absolute paths — POSIX (/etc/x), Windows (C:\x), UNC (\\host\share)

Backslashes are always treated as separators (not just on Windows), so a Windows-shaped traversal payload can't slip through on a POSIX host by switching separator style.

getAbsAndEnsureDir (src/utils/files.ts) — the single chokepoint all three write paths converge on (saveResults, SnapshotAPI.download, and BaseResult.save() — the last of which accepts a raw path that never passes through FilenameSchema at all) — now enforces containment itself rather than assuming callers already sanitized:

  1. Lexical containment: the resolved candidate must stay under the base directory (cwd by default).
  2. Real-path containment: after mkdir, re-resolve via fs.realpath and re-check, so a subfolder that is (or contains) a symlink pointing outside the base directory can't be used to escape it — lexical resolution alone is blind to symlinks.

Containment is only enforced for relative inputs. An absolute path is a deliberate choice by the calling code (same trust model as calling fs.writeFile directly) — BaseResult.save() has always supported writing to an arbitrary absolute destination (see the pre-existing /tmp/... test in tests/result.test.ts), and that's preserved unchanged.

User-facing behavior (vs. the currently-published 1.1.0)

Input to filename/filepathsaveResults / SnapshotAPI.downloadresult.save()
'output/data.json' (relative subfolder)✅ creates output/ and writes there✅ same as always
'../etc/passwd', 'output/../../x'ValidationError (clear, fails fast)FSError (containment check)
'/etc/passwd', 'C:\x', '\\\\host\\share'ValidationError✅ honored (explicit, trusted absolute path — unchanged)
Subfolder that's a symlink escaping cwdn/a (blocked earlier by schema for .., but a non-..-shaped symlinked folder is still caught)FSError (symlink escape detected via fs.realpath)

Tests

Rewrote tests/files.test.ts:

  • FilenameSchema suite for the new allow-subfolders/reject-traversal behavior.
  • New dedicated getAbsAndEnsureDir suite: nested subfolders, relative-traversal rejection, explicit-absolute-path pass-through, symlink-escape rejection.
  • Extended saveResults integration tests: nested subfolder write, symlink-escape rejection.
  • New BaseResult.save() coverage for the schema-bypass path (still enforces relative-traversal/symlink protection, still honors absolute paths).
  • Symlink-creating tests are skipped on Windows (fs.symlink() needs Developer Mode/admin there — an environment limitation, not a regression) via it.skipIf.

Verification

  • sanity (lint + typecheck): clean.
  • Full suite: 396/408 passing (12 skipped — real-API integration tests).
  • build + smoke-dist (ESM/CJS load from actual dist/): clean.

…traversal
The previous fix for path traversal (CWE-22) in FilenameSchema reduced any
filename to its bare basename via path.basename(), which is more aggressive
than necessary: it also strips legitimate relative subfolders developers
commonly use (e.g. filename: 'output/data.json'), even though those never
posed a traversal risk.
FilenameSchema now allows relative subfolder segments, and instead
explicitly rejects (with a clear ValidationError, not a silent rewrite):
- '..' segments anywhere in the path
- absolute paths (POSIX '/x', Windows 'C:\x', UNC '\\host\share')
Backslashes are always treated as separators (not just on Windows), so a
Windows-shaped traversal payload can't slip through on a POSIX host by
switching separator style.
getAbsAndEnsureDir (utils/files.ts) is the single chokepoint all three
write paths converge on (saveResults, SnapshotAPI.download, and
BaseResult.save() — the last of which accepts a raw path that never passes
through FilenameSchema at all), so it now enforces containment itself
rather than assuming callers already sanitized:
1. Lexical containment: resolved candidate must stay under baseDir.
2. Real-path containment: after mkdir, re-resolve via fs.realpath and
re-check, so a subfolder that is (or contains) a symlink pointing
outside baseDir can't be used to escape it — lexical resolution alone
is blind to symlinks.
Containment is only enforced for *relative* inputs: an absolute path is a
deliberate choice by the calling code (same trust model as calling
fs.writeFile directly) — BaseResult.save() has always supported writing to
an arbitrary absolute destination, and that's preserved.
Tests: rewrote tests/files.test.ts's FilenameSchema suite for the new
allow-subfolders/reject-traversal behavior, added a dedicated
getAbsAndEnsureDir suite (nested subfolders, relative-traversal rejection,
explicit-absolute-path pass-through, symlink-escape rejection), extended
saveResults integration tests (nested subfolder write, symlink-escape
rejection) and added BaseResult.save() coverage for the schema-bypass path.
…min)
fs.symlink() typically requires Developer Mode or admin rights on Windows,
unrelated to whether the containment fix itself works there. Skip the two
symlink-escape tests via it.skipIf(isWindows) instead of letting them fail
on an environment limitation rather than an actual regression.
@artemo-brd
artemo-brd merged commit 0fd51aa into mainAug 2, 2026
3 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@artemo-brd