scripts/assert-packed-manifest.sh rejects a dependency specifier that is a bare
relative path (../core, ./sib, ~/home, /x), and trims leading whitespace
so " ../core" cannot slip past the anchor. A specifier prefixed with a literal
tab or newline still bypasses both checks.
Cause
The rows are fed to the scan loop through jq @tsv:
rows=$(printf '%s' "$manifest" | jq -r '
["dependencies","optionalDependencies","peerDependencies"][] as $f
| (.[$f] // {}) | to_entries[]
| [$f, .key, (.value | tostring)] | @tsv')
@tsv escapes real control characters into their two-character forms — a tab
becomes \ + t, a newline becomes \ + n — so the field-splitting stays
correct. That is the right call for the loop, and it is load-bearing: it is why a
crafted specifier cannot desynchronize read -r field name spec or forge an
extra row.
But it runs before the whitespace trim. By the time the trim sees the value,
the first character is a literal backslash, not whitespace. The trim is a no-op,
no protocol matches, the .*|/*|\~/* local-path arm does not match a leading
backslash, and the specifier falls through to continue — default-allow.
Evidence
A packed manifest with these three dependencies (real control characters, not
escape sequences):
{
"@probe/core": "\t../core",
"@probe/nl": "\n../core2",
"@probe/sp": " ../core3"
}
yields exactly one violation — the space-prefixed one. The tab and newline
forms pass silently.
They are live local dependencies, not malformed strings. Against the
npm-package-arg bundled with npm 12.0.2:
npa.resolve("x", "\t../core") => type: directory, registry: undefined
So this is the same class as the bug already fixed: a specifier that resolves
against the publisher's filesystem, packs green, and installs for nobody.
Severity
Low, and narrower than it looks. It requires a literal control character at the
start of a package.json dependency value — not something a package manager
or a human editor produces incidentally. It was equally open before the guard
existed, so this is a gap in new coverage rather than a regression.
Fix options
- Trim after unescaping. Convert
\t / \n back before the whitespace
trim, or trim against the escaped forms as well. Smallest change; keeps
@tsv's desynchronization safety intact.
- Move the row encoding off
@tsv. Emit NUL-delimited or base64 rows so the
scan loop sees the true bytes. Cleaner semantically, but @tsv's escaping is
currently what makes the loop injection-proof — replacing it means
re-establishing that property another way.
Option 1 is likely correct. Whichever is chosen, add fixtures under
tests/fixtures/assert-packed-manifest/ for the tab and newline forms, and prove
they fail against the current logic before fixing.
Note the inline-sync invariant: scripts/assert-packed-manifest.sh is embedded
verbatim in .github/workflows/publish-npm.yml, so a script change requires a
regenerated inline copy or ./scripts/check-inline-sync.sh fails CI.
Found by the final whole-branch review of #139 and parked there as a known
follow-up rather than fixed in that PR.
Refs #139.
scripts/assert-packed-manifest.shrejects a dependency specifier that is a barerelative path (
../core,./sib,~/home,/x), and trims leading whitespaceso
" ../core"cannot slip past the anchor. A specifier prefixed with a literaltab or newline still bypasses both checks.
Cause
The rows are fed to the scan loop through
jq @tsv:@tsvescapes real control characters into their two-character forms — a tabbecomes
\+t, a newline becomes\+n— so the field-splitting stayscorrect. That is the right call for the loop, and it is load-bearing: it is why a
crafted specifier cannot desynchronize
read -r field name specor forge anextra row.
But it runs before the whitespace trim. By the time the trim sees the value,
the first character is a literal backslash, not whitespace. The trim is a no-op,
no protocol matches, the
.*|/*|\~/*local-path arm does not match a leadingbackslash, and the specifier falls through to
continue— default-allow.Evidence
A packed manifest with these three dependencies (real control characters, not
escape sequences):
{ "@probe/core": "\t../core", "@probe/nl": "\n../core2", "@probe/sp": " ../core3" }yields exactly one violation — the space-prefixed one. The tab and newline
forms pass silently.
They are live local dependencies, not malformed strings. Against the
npm-package-argbundled with npm 12.0.2:So this is the same class as the bug already fixed: a specifier that resolves
against the publisher's filesystem, packs green, and installs for nobody.
Severity
Low, and narrower than it looks. It requires a literal control character at the
start of a
package.jsondependency value — not something a package manageror a human editor produces incidentally. It was equally open before the guard
existed, so this is a gap in new coverage rather than a regression.
Fix options
\t/\nback before the whitespacetrim, or trim against the escaped forms as well. Smallest change; keeps
@tsv's desynchronization safety intact.@tsv. Emit NUL-delimited or base64 rows so thescan loop sees the true bytes. Cleaner semantically, but
@tsv's escaping iscurrently what makes the loop injection-proof — replacing it means
re-establishing that property another way.
Option 1 is likely correct. Whichever is chosen, add fixtures under
tests/fixtures/assert-packed-manifest/for the tab and newline forms, and provethey fail against the current logic before fixing.
Note the inline-sync invariant:
scripts/assert-packed-manifest.shis embeddedverbatim in
.github/workflows/publish-npm.yml, so a script change requires aregenerated inline copy or
./scripts/check-inline-sync.shfails CI.Found by the final whole-branch review of #139 and parked there as a known
follow-up rather than fixed in that PR.
Refs #139.