fix: match RegExp matchers across realms - #655
Conversation
b269a27 to
001c7f8
Compare
There was a problem hiding this comment.
🟡 Changes recommended
A couple of small but user-facing/documentation and test-maintainability issues should be addressed (README wording about reset guarantees and a brittle TypeError assertion tied to safe-regex2 internals).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes verifier handling of RegExp matchers created in other JavaScript realms so they’re consistently recognized as real regular expressions, ensuring lastIndex reset and unsafe-RegExp warning behavior apply across realms (closing #654).
Changes:
- Add a shared
isRegExpLike()helper (usingnode:util.types.isRegExp) and use it in bothcheckForUnsafeRegExp()andensureStringClaimMatcher(). - Wrap
lastIndexreset in atry/catchto avoid throwing when the matcher can’t be reset. - Add a comprehensive test block covering cross-realm, duck-typed, Proxy-wrapped, frozen, and subclass matcher shapes; update README matcher-contract wording.
File summaries
| File | Description |
|---|---|
src/verifier.js |
Uses realm-independent RegExp detection and makes lastIndex reset best-effort to avoid crashes. |
test/verifier.spec.js |
Adds regression coverage for cross-realm and edge-case matcher shapes to prevent non-deterministic verification. |
README.md |
Documents the matcher contract and clarifies RegExp vs custom test() matcher behavior. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The allowed* verifier options gated their RegExp handling on `r instanceof RegExp`, which is a prototype-chain identity check rather than a type check. A RegExp built in a different JavaScript realm, for example via `vm.runInNewContext`, fails it despite being a fully functional RegExp with a live lastIndex. Such a matcher fell through to the duck-typed `test()` branch and was stored raw, so a cross-realm /g or /y pattern reintroduced the non-determinism the lastIndex reset (CVE-2026-35040) fixed: a valid token alternated between accepted and rejected across successive calls. The same gap silently skipped the FAST_JWT_UNSAFE_REGEXP ReDoS warning for a cross-realm pattern. Both call sites now share an `isRegExpLike()` helper. `isRegExp` from node:util inspects the engine's internal slot, so it is realm independent; the `instanceof` arm is kept so objects presenting as RegExps without the internal slot keep the handling they had before. `Object.prototype.toString.call()` was avoided because its brand can be forged through Symbol.toStringTag. Widening the predicate brings real cross-realm RegExps into the reset path for the first time, where an unconditional `lastIndex` write throws for a frozen matcher. The write is now wrapped so an unwritable matcher falls through to test() instead, which also fixes frozen same-realm RegExps that threw on every call before. A Proxy over a cross-realm /g RegExp remains non-deterministic: it satisfies neither isRegExp nor instanceof RegExp, so it is treated as a custom matcher. That is pre-existing behaviour and out of scope here. Closes #654
001c7f8 to
486fbee
Compare
There was a problem hiding this comment.
Approving. Correct and tightly scoped; reading the internal slot instead of the prototype chain takes the cross-realm /g, /y, subclass and mixed-array shapes from alternating pass/reject to stable pass. I compared this branch against master across ~25 matcher shapes and found nothing that regresses. It also can't loosen verification: the predicate only widens, so no unsafe pattern stops being warned about, and the catch only skips the reset, so r.test(v) still decides every match.
All non-blocking:
src/verifier.js:75: a third option beyond the two you reverted: on reset failure, fall back to a fast-jwt-owned stateless clone (new RegExp(r.source, r.flags.replace(/[gy]/g, '')), built once, itself guarded) instead of falling through to a matcher you know you couldn't reset. That's what produces the last row of your table. Design preference, not a defect — nothing can depend on that shape.src/verifier.js:53: cross-realm patterns now reachsafe-regex2, which flags anything its parser can't handle. A safe cross-realm lookbehind now warnsFAST_JWT_UNSAFE_REGEXPwheremasterwas silent. Mirrors the existing same-realm false positive, but isn't in the compatibility table.test/verifier.spec.js:2247: no assertion on the success path, so it passes vacuously; since that shape alternates, it asserts nothing about half the time.test/verifier.spec.js:2274/:2327: differ only in/gvs no/gbut share an identical failure message, so they read as a duplicate.- Description nit: the
try/catchis on the per-token path, not confined tocreateVerifier(): onlyisRegExpLike()is. Measured at 300k verifications it's within noise, so wording only.
Closes #654
What
checkForUnsafeRegExp()andensureStringClaimMatcher()insrc/verifier.jsgated their RegExp handling onr instanceof RegExp, which is a prototype-chain identity check rather than a type check. A RegExp created in a different JavaScript realm (for examplevm.runInNewContext('/^admin$/g')) fails it despite being a fully functional RegExp with a livelastIndex.Such a matcher fell through to the duck-typed
typeof r.test === 'function'branch and was stored raw, so a cross-realm/gor/ypattern reintroduced the non-determinism that thelastIndexreset (CVE-2026-35040) fixed: the same valid token alternated between accepted and rejected across successiveverify()calls. The same gap silently skipped theFAST_JWT_UNSAFE_REGEXPReDoS warning for a cross-realm pattern.The fix
Both call sites now share an
isRegExpLike()helper:isRegExpfromnode:utilinspects the engine's internal slot rather than the prototype chain, so it is realm independent, and that is the fix. Theinstanceofarm is additive, kept so that objects presenting as RegExps without the internal slot (aProxyover a RegExp in practice) keep the handling they had before.Object.prototype.toString.call()was avoided because its brand can be forged throughSymbol.toStringTag.Widening the predicate brings real cross-realm RegExps into the
lastIndexreset path for the first time, where an unconditional write throws for a frozen matcher. The write is now wrapped so an unwritable matcher falls through totest()rather than throwing. That also fixes frozen same-realm RegExps, which threw a rawTypeErroron every call before this change.The reset itself is deliberately left unconditional, as it was before. Guarding it on
global/stickywas tried and reverted: aProxyor a subclass can report flags that differ from the onestest()actually uses, and reading those flags can itself throw, so inferring statefulness from them broke shapes that worked previously.Compatibility
No shape that returns a result on
masterbehaves differently, and no throw is introduced wheremastersucceeded. Measured across 19 matcher shapes, 6 successive verifications each:allowedAudshapemaster/g,/y, and in an array/g, duck-typed{ test }TypeError/g, either realmTypeErrorTypeErrorProxywith throwing flag getters/greportingglobalas falseexecadvanceslastIndexProxyover/gwith a throwingsettrapTypeErrorThe last row is the only behaviour change, on a shape that already throws on
master, so no working installation can depend on it.Known limitation
A
Proxyover a cross-realm/gRegExp still validates non-deterministically. It satisfies neitherisRegExp(it is a Proxy) norinstanceof RegExp(its target's prototype belongs to another realm), soisRegExpLike()returns false and it is treated as a custom matcher.masterbehaves identically, so this is not a regression, and closing it would mean applyinglastIndexmanagement to arbitrary caller-supplied matchers, which contradicts the documented contract that fast-jwt does not manage their state.Tests
15 tests added, in a
cross-realm and duck-typed RegExp matchersblock alongside the existing same-realmstateful RegExp flagsblock. They cover deterministic accept for cross-realm/gonallowedAudandallowedIss, deterministic reject for a non-matching cross-realm RegExp, theFAST_JWT_UNSAFE_REGEXPwarning for a cross-realm unsafe pattern, an array mixing a cross-realm RegExp with a string, the duck-typed{ test }contract, and the frozen, subclass, flag-getter andlastIndex-write shapes in the table above.Every one was verified to fail without the change that makes it pass. Three mutations against the final code, in
test/verifier.spec.jsalone:lastIndexreset entirely: 12 failures, including the pre-existing same-realm CVE-2026-35040 teststryaround the reset: 3 failuresglobal/sticky: 4 failuresDocs
README.mddocuments theallowed*matcher contract next to the affected options. It marks an object merely exposingtest()as runtime-only and unsupported rather than accepted, and records that fast-jwt resets a RegExp matcher'slastIndexbefore each test but performs no state management for a custom matcher. The typed contract insrc/index.d.ts(VerifierAllowedBase = string | RegExp) is unchanged.Verification
npm test: 379 tests, 379 pass, 0 fail (364 onmaster). TSTyche: 1 target, 20 assertions passed.npm run lint: exits 0.src/verifier.jscoverage 99.72% line, the only uncovered lines being pre-existingcrithandling.Performance
Both
isRegExpLike()call sites run increateVerifier(), not on the per-tokenverify()path, so verification throughput is unaffected.Notes
This is a robustness fix, not a security release. The privately reported advisory (GHSA-2fxx-jx5v-mhj7) was closed as not-a-vulnerability: the trigger is a valid token from a legitimate user, the realm boundary is a fixed property of the deployment that an attacker cannot influence, and the failure mode is non-deterministic rejection of a claim match rather than any bypass of signature or key validation.