Skip to content

fix(bundle,lockfile): require every flat file key to be canonical - #185

Merged
ChALkeR merged 3 commits into
mainfrom
claude/reject-absolute-file-keys
Oct 1, 2026
Merged

ChALkeR merged 3 commits into
mainfrom
claude/reject-absolute-file-keys

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Bundle.parse and Lockfile.parse checked a bucket dir and a file name each for escaping the project root. They never checked the flat key the two join into, and never required a canonical spelling of it. That leaves two problems.

  • An empty dir makes keys absolute. sources: { '': { files: { 'etc/passwd': … } } } parsed as the file /etc/passwd. With a root listing, it parsed as the key ''.
  • Aliases get past the duplicate-key check. One file can carry two payloads under different spellings, in bundles and lockfiles alike. Any of these parsed:
    • buckets src and src/.
    • . holding src/x.js next to src holding ./x.js
    • one bucket holding both src/x.js and src/a/../x.js

serialize() could also write artifacts that the matching parse then rejects.

Change

One shared helper in artifact-util.js, flatFileKeys(modules, what, onDuplicate), joins each file's dir and name into its flat key. It then:

  • rejects any key with an empty, . or .. segment, which covers the empty dir, absolute keys and every alias above
  • reports keys found in two buckets through onDuplicate, so each caller keeps its existing message

Bundle.parse, Lockfile.parse, mergeModuleMaps and both serialize() methods use it in place of their own flatten-and-dedup loops. So whatever stasis writes, it can parse again.

It also replaces the per-file escape checks it subsumes. The lockfile passes the resulting key set to parseExecutable instead of recomputing it. Errors name the key, JSON-quoted (bundle: non-canonical file key "src/./x.js"), and a non-string bucket dir gets its own message.

Names made of dots stay ordinary names: ..foo, ..., .pnpm/… and node_modules/.bin/… are accepted. stasis builds keys from path.relative, so it never writes a non-canonical one; the full suite, including many runs that build and write bundles and lockfiles, never hits the new check. A hand-edited artifact with such a key now fails to parse.

Tests

  • Rejected by both parsers:
    • the empty dir, including a root listing
    • src/, src/., ./src, src//lib and a/../src as dirs
    • ./x.js, a/../x.js, x//y.js, /x.js and x/ as file names
    • the cross-bucket ./x.js alias, and a v0 ./src/x.js
  • Still accepted: the dot-names above.
  • Write paths: serialize() refuses a non-canonical key and a cross-bucket duplicate, and merge() refuses a bad bucket on the other side, for both Bundle and Lockfile.
  • Regression check: both new tests fail on main.
  • Full suite: 2034 passed, 0 failed, 3 skipped on Node 24.14; lint is clean.

Relation to #184

#184 (the streaming bundle reader) has its own narrower per-file check in stasis-core/src/bundle.js. Whichever PR merges second needs a small conflict resolved there. After this merges, #184's streaming pre-check can use the same rule.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u

Bundle.parse and Lockfile.parse check a bucket dir and each file name for
escaping the root, but not the flat key they join into. An empty dir joins
with any name into an absolute key: `sources: { '': { files: { 'etc/passwd':
... } } }` parsed as the file `/etc/passwd`. Both parsers now also check the
joined key (stasis only ever writes the root bucket as '.').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…ng joined keys

Review of the joined-key check: it missed '' with a '' root listing (key
''), and the cause is wider than absolute keys -- bucket dirs were never
required to be canonical, so 'src' and 'src/.' (or 'src/', './src') alias
one directory with two payloads past the duplicate-key check, in bundles and
lockfiles alike.

A bucket dir must now be '.' or a relative path without empty, '.' or '..'
segments (isCanonicalDir), checked once per bucket in Bundle.parse and
Lockfile.parse, and on the write paths -- mergeModuleMaps and both
serialize()s -- so stasis never writes an artifact its own parse rejects.
It replaces the per-file joined-key check: with a canonical dir, a file name
that doesn't escape can't make the key escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
@exo-nikita exo-nikita changed the title fix(bundle,lockfile): reject a file key that joins into an absolute path fix(bundle,lockfile): require canonical bucket dirs Sep 30, 2026
Second review: making only bucket dirs canonical left file-name aliases
open -- '.' holding 'src/x.js' next to 'src' holding './x.js' (or
'src/a/../x.js' in one bucket) still gave one file two payloads -- and
serialize() still wrote artifacts its own parse rejects.

One shared artifact-util helper, flatFileKeys, now flattens each file's key
(dir and name joined), rejects any key with an empty, '.' or '..' segment
(which covers the empty dir, absolute keys and every alias), and reports
duplicates. Bundle.parse, Lockfile.parse, mergeModuleMaps and both
serialize()s use it instead of their own loops, so what stasis writes always
parses. It replaces isCanonicalDir and the per-name escape checks it
subsumes; the Lockfile passes its keys to parseExecutable instead of
recomputing them. Errors name the key (JSON-quoted); a non-string bucket
dir gets a proper message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
@exo-nikita exo-nikita changed the title fix(bundle,lockfile): require canonical bucket dirs fix(bundle,lockfile): require every flat file key to be canonical Sep 30, 2026
@ChALkeR
ChALkeR merged commit 8f418a0 into main Oct 1, 2026
5 checks passed
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.

3 participants