Commit ab4ee7d
committed
fix(GHSA-c48m-32m9-vx93): reject '..' traversal in allowlisted subpaths
`LegacyResolver.customResolve` decided whether to consult a custom
`require.resolve` by testing the bare specifier against `externalCache`
regexes built with no anchors, so `require: { external: ['left-pad'] }`
matched `evil-left-pad` / `left-pad-evil` / `xleft-padx` by substring
containment. The resolver then located the colliding host package, its
resolved path was appended to `this.externals`, and under the default
`context: 'host'` its top-level code ran with full host authority from a
sandbox configured with `builtin: []`.
Anchoring alone was insufficient: the matcher must permit a subpath tail
(`left-pad/utils`), and that tail accepts `..` segments, so
`left-pad/../evil-package` and `left-pad/sub/../../evil-package` walked
back out of the package boundary to the same effect. A regex lookahead
cannot close this — it only inspects the segment after the first
separator.
Two composed layers in lib/resolver-compat.js:
- The `externalCache` matcher is anchored `^(?:<pattern>)(?:[\\/].*)?$`,
so a specifier must equal the allowlisted name or be a subpath under
it. Wildcard segment semantics are preserved (`@scope/*` still matches
`@scope/pkg` and `@scope/pkg/sub`, not `x@scope/pkg`). The separate
filename-side `this.externals` matcher is untouched.
- On the bare-specifier branch only, the specifier is split on `[\\/]`
and rejected if any segment is exactly `..`, before the custom
resolver is consulted. Rejection returns undefined, so the standard
loader runs, the path never enters `this.externals`, and the sandbox
observes an ordinary module-not-found.
Ordering: the `..` check runs on the raw, un-canonicalized specifier and
therefore before any realpath(). This is the only correct placement --
canonicalization removes `..` segments by definition, so the same
lexical check after realpath() would be a no-op. It composes with rather
than duplicates `CustomResolver.isPathAllowed`'s realpath dereferencing
(GHSA-cp6g-6699-wx9c), which is a filename-space check against
`require.root` symlinks; this one is a specifier-space check against
lexical escape of the package name boundary. Neither subsumes the other
and no new path into `isPathAllowed` is introduced.
Restores the external-package allowlist boundary asserted by
docs/ATTACKS.md Defense Invariant 13's sibling for external modules.
Tests: test/ghsa/GHSA-c48m-32m9-vx93/repro.js -- substring collisions,
`..` traversal at three depths plus bare `left-pad/..`, wildcard segment
matching, and the legitimate name/subpath cases, using resolver
consultation as the oracle.
docs/ATTACKS.md: new Category 45 (module-resolution/path-traversal; no
existing category covered the external-package allowlist -- Category 21
is the builtin allowlist and Category 24 is the `require.root` filename
boundary, both cross-referenced), new Compound Attack Pattern 28, and a
"How The Bridge Defends" row. No renumbering was required.
package.json version unchanged.1 parent d6ef73b commit ab4ee7d
4 files changed
Lines changed: 186 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
0 commit comments