Skip to content

assert-packed-manifest: tab/newline-prefixed local paths bypass the guard #140

Description

@j7an

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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions