Skip to content

fix(decompress): harden toFile against symlink path-escape - #140

Open
ranxianglei wants to merge 3 commits into
masterfrom
2026-08-13_decompress-symlink-hardening
Open

fix(decompress): harden toFile against symlink path-escape#140
ranxianglei wants to merge 3 commits into
masterfrom
2026-08-13_decompress-symlink-hardening

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What

resolveToFilePath checked path containment against the literal resolved path. A symlink inside an allowed root (e.g. /tmp) that points outside the allowed roots could pass the check and let decompress --toFile write to an arbitrary filesystem location.

Fix

  • Resolve symlinks in the longest existing ancestor of the target before the containment check, so a symlinked component that escapes is resolved to its real target.
  • Compare against realpath'd roots too — tmpdir() itself often sits behind a symlink (/var -> /private/var on macOS), so the string forms would otherwise diverge and reject legitimate writes.
  • Null-guard args.blockId before trim() for robustness.

Test

New regression test decompress toFile rejects paths that escape an allowed root via a symlink: creates a symlink inside an allowed jail pointing at /etc, asserts the write is rejected. Fails on the old code (literal-path check), passes with the fix.

✔ decompress toFile rejects paths that escape an allowed root via a symlink

Validation

  • typecheck ✓
  • full suite: 276 pass / 0 fail
  • build ✓

Scope

Minimal, isolated, no cross-repo dependency. Extracted from #119 (thanks @21307369 for the original implementation). This is the high-priority security item from the #119 review split — the rest of #119 (the /compact pipeline, clean-output, i18n) is being split into separate PRs.

awork and others added 2 commits August 13, 2026 23:35
resolveToFilePath checked containment against the literal resolved path,
so a symlink inside an allowed root (e.g. /tmp) pointing elsewhere could
pass the check and write outside the allowed roots.

Resolve symlinks in the longest existing ancestor of the target before
the containment check, and compare against realpath'd roots (tmpdir()
itself often sits behind a symlink, e.g. /var -> /private/var on macOS).

Also null-guard args.blockId before trim() for robustness.

Extracted from #119 (lsmir2).
…k (Windows)

existsSync follows symlinks, so a symlink whose target does not (yet)
exist reads as non-existent and was skipped by the ancestor walk — the
unresolved link component then passed the containment check even though
writing through it would land outside the allowed roots.

Resolve each suffix component via lstatSync/readlinkSync so dangling
symlinks (e.g. to a non-existent /etc on Windows) are caught. Adds a
dedicated dangling-symlink test.
Sign up for free to 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