Skip to content

fix: match RegExp matchers across realms - #655

Merged
SociableSteve merged 1 commit into
masterfrom
fix/cross-realm-regexp-matcher
Sep 3, 2026
Merged

fix: match RegExp matchers across realms#655
SociableSteve merged 1 commit into
masterfrom
fix/cross-realm-regexp-matcher

Conversation

@SociableSteve

@SociableSteve SociableSteve commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes #654

What

checkForUnsafeRegExp() and ensureStringClaimMatcher() in src/verifier.js gated their RegExp handling on r instanceof RegExp, which is a prototype-chain identity check rather than a type check. A RegExp created in a different JavaScript realm (for example vm.runInNewContext('/^admin$/g')) fails it despite being a fully functional RegExp with a live lastIndex.

Such a matcher fell through to the duck-typed typeof r.test === 'function' branch and was stored raw, so a cross-realm /g or /y pattern reintroduced the non-determinism that the lastIndex reset (CVE-2026-35040) fixed: the same valid token alternated between accepted and rejected across successive verify() calls. The same gap silently skipped the FAST_JWT_UNSAFE_REGEXP ReDoS warning for a cross-realm pattern.

The fix

Both call sites now share an isRegExpLike() helper:

function isRegExpLike(value) {
  return isRegExp(value) || value instanceof RegExp
}

isRegExp from node:util inspects the engine's internal slot rather than the prototype chain, so it is realm independent, and that is the fix. The instanceof arm is additive, kept so that objects presenting as RegExps without the internal slot (a Proxy over a RegExp in practice) 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 lastIndex reset 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 to test() rather than throwing. That also fixes frozen same-realm RegExps, which threw a raw TypeError on every call before this change.

The reset itself is deliberately left unconditional, as it was before. Guarding it on global/sticky was tried and reverted: a Proxy or a subclass can report flags that differ from the ones test() 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 master behaves differently, and no throw is introduced where master succeeded. Measured across 19 matcher shapes, 6 successive verifications each:

allowedAud shape master this PR
cross-realm /g, /y, and in an array alternates pass/reject passes
plain string, same-realm /g, duck-typed { test } passes passes
frozen cross-realm, no stateful flag passes passes
frozen same-realm, no stateful flag TypeError passes
frozen /g, either realm TypeError TypeError
Proxy with throwing flag getters passes passes
/g reporting global as false passes passes
non-global subclass whose exec advances lastIndex passes passes
Proxy over /g with a throwing set trap TypeError alternates pass/reject

The 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 Proxy over a cross-realm /g RegExp still validates non-deterministically. It satisfies neither isRegExp (it is a Proxy) nor instanceof RegExp (its target's prototype belongs to another realm), so isRegExpLike() returns false and it is treated as a custom matcher. master behaves identically, so this is not a regression, and closing it would mean applying lastIndex management 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 matchers block alongside the existing same-realm stateful RegExp flags block. They cover deterministic accept for cross-realm /g on allowedAud and allowedIss, deterministic reject for a non-matching cross-realm RegExp, the FAST_JWT_UNSAFE_REGEXP warning 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 and lastIndex-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.js alone:

  • remove the lastIndex reset entirely: 12 failures, including the pre-existing same-realm CVE-2026-35040 tests
  • remove the try around the reset: 3 failures
  • guard the reset on global/sticky: 4 failures

Docs

README.md documents the allowed* matcher contract next to the affected options. It marks an object merely exposing test() as runtime-only and unsupported rather than accepted, and records that fast-jwt resets a RegExp matcher's lastIndex before each test but performs no state management for a custom matcher. The typed contract in src/index.d.ts (VerifierAllowedBase = string | RegExp) is unchanged.

Verification

  • npm test: 379 tests, 379 pass, 0 fail (364 on master). TSTyche: 1 target, 20 assertions passed.
  • npm run lint: exits 0.
  • src/verifier.js coverage 99.72% line, the only uncovered lines being pre-existing crit handling.

Performance

Both isRegExpLike() call sites run in createVerifier(), not on the per-token verify() 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.

@SociableSteve
SociableSteve force-pushed the fix/cross-realm-regexp-matcher branch from b269a27 to 001c7f8 Compare September 3, 2026 15:46
@SociableSteve
SociableSteve requested review from lv10 and a lite review from Copilot September 3, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 (using node:util.types.isRegExp) and use it in both checkForUnsafeRegExp() and ensureStringClaimMatcher().
  • Wrap lastIndex reset in a try/catch to 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.

Comment thread test/verifier.spec.js
Comment thread README.md Outdated
Comment thread src/verifier.js
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
@SociableSteve
SociableSteve force-pushed the fix/cross-realm-regexp-matcher branch from 001c7f8 to 486fbee Compare September 3, 2026 16:18

@lv10 lv10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. src/verifier.js:53: cross-realm patterns now reach safe-regex2, which flags anything its parser can't handle. A safe cross-realm lookbehind now warns FAST_JWT_UNSAFE_REGEXP where master was silent. Mirrors the existing same-realm false positive, but isn't in the compatibility table.
  3. 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.
  4. test/verifier.spec.js:2274 / :2327: differ only in /g vs no /g but share an identical failure message, so they read as a duplicate.
  5. Description nit: the try/catch is on the per-token path, not confined to createVerifier(): only isRegExpLike() is. Measured at 300k verifications it's within noise, so wording only.

@SociableSteve
SociableSteve merged commit 8ab97d3 into master Sep 3, 2026
7 checks passed
@SociableSteve
SociableSteve deleted the fix/cross-realm-regexp-matcher branch September 3, 2026 16:25
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.

Cross-realm RegExp bypasses the stateful-matcher reset in allowed* options

3 participants