Skip to content

Commit 001c7f8

Browse files
committed
fix: recognise RegExp matchers created in another realm
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
1 parent 20728b7 commit 001c7f8

3 files changed

Lines changed: 252 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ Create a verifier function by calling `createVerifier` and providing one or more
164164

165165
- `allowedNonce`: A string, a regular expression, an array of strings or an array of regular expressions containing allowed values for the nonce claim (`nonce`). By default, all values are accepted.
166166

167+
For the five `allowed*` options above, any object exposing a `test(value)` method happens to work at runtime in plain JavaScript, but it is not part of the TypeScript types and is not a supported configuration. fast-jwt resets a `RegExp` matcher's `lastIndex` before each test, so a stateful pattern validates deterministically, but it performs no state management on a custom matcher's behalf, so a stateful custom matcher is the caller's responsibility.
168+
167169
- `requiredClaims`: An array of strings containing which claims should exist in the token. By default, no claim is marked as required.
168170

169171
- `ignoreExpiration`: Do not validate the expiration of the token. Default is `false`.

src/verifier.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const { createPublicKey, createSecretKey } = require('node:crypto')
4+
const { isRegExp } = require('node:util').types
45
const Cache = require('mnemonist/lru-cache')
56

67
const safeRegex = require('safe-regex2')
@@ -42,10 +43,14 @@ function prepareKeyOrSecret(key, isSecret) {
4243
return isSecret ? createSecretKey(key) : createPublicKey(key)
4344
}
4445

46+
function isRegExpLike(value) {
47+
return isRegExp(value) || value instanceof RegExp
48+
}
49+
4550
function checkForUnsafeRegExp(raw, optionName) {
4651
const patterns = Array.isArray(raw) ? raw : [raw]
4752
for (const r of patterns) {
48-
if (r instanceof RegExp && !safeRegex(r)) {
53+
if (isRegExpLike(r) && !safeRegex(r)) {
4954
process.emitWarning(
5055
`The ${optionName} option contains an unsafe RegExp ${r} that may cause a ReDoS attack. Please review it. ` +
5156
'See https://github.com/nearform/fast-jwt/security/advisories/GHSA-cjw9-ghj4-fwxf for details.',
@@ -63,10 +68,14 @@ function ensureStringClaimMatcher(raw) {
6368
return raw
6469
.filter(r => r)
6570
.map(r => {
66-
if (r instanceof RegExp) {
71+
if (isRegExpLike(r)) {
6772
return {
6873
test: v => {
69-
r.lastIndex = 0
74+
try {
75+
r.lastIndex = 0
76+
} catch {
77+
// A frozen matcher cannot be reset, so let test() decide rather than throwing.
78+
}
7079
return r.test(v)
7180
}
7281
}

test/verifier.spec.js

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { createHash, createHmac } = require('node:crypto')
44
const { readFileSync } = require('node:fs')
55
const { resolve } = require('node:path')
66
const { describe, test } = require('node:test')
7+
const vm = require('node:vm')
78

89
const { createSigner, createVerifier, TokenError } = require('../src')
910
const { hashToken } = require('../src/utils')
@@ -2120,6 +2121,243 @@ describe('createVerifier', () => {
21202121
})
21212122
})
21222123

2124+
describe('cross-realm and duck-typed RegExp matchers', () => {
2125+
test('cross-realm /g flag must not cause non-deterministic claim validation - allowedAud', t => {
2126+
t.mock.timers.enable({ now: 100000 })
2127+
const sign = createSigner({ key: 'secret' })
2128+
const encodedToken = sign({ aud: 'admin' })
2129+
const crossRealmRegExp = vm.runInNewContext('/^admin$/g')
2130+
const verifier = createVerifier({ key: 'secret', allowedAud: crossRealmRegExp })
2131+
2132+
// All 8 successive calls with the same valid token must succeed
2133+
for (let attempt = 0; attempt < 8; attempt++) {
2134+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass with a cross-realm /g RegExp`)
2135+
}
2136+
})
2137+
2138+
test('cross-realm /g flag must not cause non-deterministic claim validation - allowedIss', t => {
2139+
t.mock.timers.enable({ now: 100000 })
2140+
const sign = createSigner({ key: 'secret' })
2141+
const encodedToken = sign({ iss: 'issuer' })
2142+
const crossRealmRegExp = vm.runInNewContext('/^issuer$/g')
2143+
const verifier = createVerifier({ key: 'secret', allowedIss: crossRealmRegExp })
2144+
2145+
for (let attempt = 0; attempt < 8; attempt++) {
2146+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass with a cross-realm /g RegExp`)
2147+
}
2148+
})
2149+
2150+
test('a cross-realm RegExp that does not match the claim keeps rejecting across successive calls', t => {
2151+
t.mock.timers.enable({ now: 100000 })
2152+
const sign = createSigner({ key: 'secret' })
2153+
const encodedToken = sign({ aud: 'guest' })
2154+
const crossRealmRegExp = vm.runInNewContext('/^admin$/g')
2155+
const verifier = createVerifier({ key: 'secret', allowedAud: crossRealmRegExp })
2156+
2157+
for (let attempt = 0; attempt < 8; attempt++) {
2158+
t.assert.throws(() => verifier(encodedToken), TokenError, `call ${attempt} should reject a non-matching aud`)
2159+
}
2160+
})
2161+
2162+
test('emits FAST_JWT_UNSAFE_REGEXP warning for an unsafe cross-realm RegExp in allowedAud', async t => {
2163+
const crossRealmRegExp = vm.runInNewContext('/^(a+)+X$/')
2164+
const w = await expectWarning('FAST_JWT_UNSAFE_REGEXP', () =>
2165+
createVerifier({ key: 'secret', allowedAud: crossRealmRegExp })
2166+
)
2167+
t.assert.equal(w.code, 'FAST_JWT_UNSAFE_REGEXP')
2168+
t.assert.ok(w.message.includes('allowedAud'))
2169+
})
2170+
2171+
test('a plain duck-typed matcher object is used as-is', t => {
2172+
t.mock.timers.enable({ now: 100000 })
2173+
const sign = createSigner({ key: 'secret' })
2174+
const encodedToken = sign({ aud: 'admin' })
2175+
const customMatcher = {
2176+
test(value) {
2177+
return value === 'admin'
2178+
}
2179+
}
2180+
const verifier = createVerifier({ key: 'secret', allowedAud: customMatcher })
2181+
2182+
t.assert.doesNotThrow(() => verifier(encodedToken))
2183+
2184+
const rejectingToken = sign({ aud: 'guest' })
2185+
t.assert.throws(() => verifier(rejectingToken), TokenError)
2186+
})
2187+
2188+
test('an array mixing a cross-realm /g RegExp with a plain string behaves deterministically', t => {
2189+
t.mock.timers.enable({ now: 100000 })
2190+
const sign = createSigner({ key: 'secret' })
2191+
const encodedToken = sign({ aud: 'admin' })
2192+
const crossRealmRegExp = vm.runInNewContext('/^admin$/g')
2193+
const verifier = createVerifier({ key: 'secret', allowedAud: [crossRealmRegExp, 'other-audience'] })
2194+
2195+
for (let attempt = 0; attempt < 8; attempt++) {
2196+
t.assert.doesNotThrow(
2197+
() => verifier(encodedToken),
2198+
`call ${attempt} should pass with a cross-realm /g RegExp in an array`
2199+
)
2200+
}
2201+
})
2202+
2203+
test('a method-binding Proxy over a /g RegExp validates deterministically', t => {
2204+
t.mock.timers.enable({ now: 100000 })
2205+
const sign = createSigner({ key: 'secret' })
2206+
const encodedToken = sign({ aud: 'admin' })
2207+
const bindingHandler = {
2208+
get(target, property) {
2209+
const value = Reflect.get(target, property, target)
2210+
return typeof value === 'function' ? value.bind(target) : value
2211+
}
2212+
}
2213+
const proxiedRegExp = new Proxy(/^admin$/g, bindingHandler)
2214+
const verifier = createVerifier({ key: 'secret', allowedAud: proxiedRegExp })
2215+
2216+
for (let attempt = 0; attempt < 8; attempt++) {
2217+
t.assert.doesNotThrow(
2218+
() => verifier(encodedToken),
2219+
`call ${attempt} should pass with a method-binding Proxy over a /g RegExp`
2220+
)
2221+
}
2222+
})
2223+
2224+
// The throw here comes from safe-regex2 stringifying the matcher, not from a check fast-jwt makes,
2225+
// so a safe-regex2 change could move the failure without fast-jwt changing.
2226+
test('a Proxy-wrapped RegExp matcher fails at createVerifier() time, not on each verify() call', t => {
2227+
const proxiedRegExp = new Proxy(/^admin$/, {})
2228+
2229+
t.assert.throws(() => createVerifier({ key: 'secret', allowedAud: proxiedRegExp }), TypeError)
2230+
})
2231+
2232+
test('a frozen cross-realm RegExp without a stateful flag validates without throwing', t => {
2233+
t.mock.timers.enable({ now: 100000 })
2234+
const sign = createSigner({ key: 'secret' })
2235+
const encodedToken = sign({ aud: 'admin' })
2236+
const frozenCrossRealmRegExp = Object.freeze(vm.runInNewContext('/^admin$/'))
2237+
const verifier = createVerifier({ key: 'secret', allowedAud: frozenCrossRealmRegExp })
2238+
2239+
for (let attempt = 0; attempt < 4; attempt++) {
2240+
t.assert.doesNotThrow(
2241+
() => verifier(encodedToken),
2242+
`call ${attempt} should pass with a frozen cross-realm RegExp`
2243+
)
2244+
}
2245+
})
2246+
2247+
test('a matcher whose lastIndex cannot be written never throws a TypeError', t => {
2248+
t.mock.timers.enable({ now: 100000 })
2249+
const sign = createSigner({ key: 'secret' })
2250+
const encodedToken = sign({ aud: 'admin' })
2251+
const refusingHandler = {
2252+
get(target, property) {
2253+
const value = Reflect.get(target, property, target)
2254+
return typeof value === 'function' ? value.bind(target) : value
2255+
},
2256+
set() {
2257+
throw new TypeError('lastIndex write refused')
2258+
}
2259+
}
2260+
const proxiedRegExp = new Proxy(/^admin$/g, refusingHandler)
2261+
const verifier = createVerifier({ key: 'secret', allowedAud: proxiedRegExp })
2262+
2263+
// The claim may or may not match, since a lastIndex we cannot reset stays advanced, but the
2264+
// failure must always be a TokenError rather than the raw TypeError earlier versions threw.
2265+
for (let attempt = 0; attempt < 4; attempt++) {
2266+
try {
2267+
verifier(encodedToken)
2268+
} catch (error) {
2269+
t.assert.ok(error instanceof TokenError, `call ${attempt} should not throw a TypeError`)
2270+
}
2271+
}
2272+
})
2273+
2274+
test('a stateful matcher whose flag getters throw still validates deterministically', t => {
2275+
t.mock.timers.enable({ now: 100000 })
2276+
const sign = createSigner({ key: 'secret' })
2277+
const encodedToken = sign({ aud: 'admin' })
2278+
const throwingFlagHandler = {
2279+
get(target, property) {
2280+
if (property === 'global' || property === 'sticky') {
2281+
throw new TypeError('flag access refused')
2282+
}
2283+
const value = Reflect.get(target, property, target)
2284+
return typeof value === 'function' ? value.bind(target) : value
2285+
}
2286+
}
2287+
const proxiedRegExp = new Proxy(/^admin$/g, throwingFlagHandler)
2288+
const verifier = createVerifier({ key: 'secret', allowedAud: proxiedRegExp })
2289+
2290+
for (let attempt = 0; attempt < 8; attempt++) {
2291+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass when flag getters throw`)
2292+
}
2293+
})
2294+
2295+
test('a /g RegExp that reports global as false is still reset between calls', t => {
2296+
t.mock.timers.enable({ now: 100000 })
2297+
const sign = createSigner({ key: 'secret' })
2298+
const encodedToken = sign({ aud: 'admin' })
2299+
const lyingRegExp = /^admin$/g
2300+
Object.defineProperty(lyingRegExp, 'global', { value: false })
2301+
const verifier = createVerifier({ key: 'secret', allowedAud: lyingRegExp })
2302+
2303+
for (let attempt = 0; attempt < 8; attempt++) {
2304+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass despite a false global flag`)
2305+
}
2306+
})
2307+
2308+
test('a non-global RegExp subclass whose exec advances lastIndex is still reset between calls', t => {
2309+
t.mock.timers.enable({ now: 100000 })
2310+
const sign = createSigner({ key: 'secret' })
2311+
const encodedToken = sign({ aud: 'admin' })
2312+
// exec() both advances and depends on lastIndex, so it only matches when lastIndex was reset.
2313+
class AdvancingRegExp extends RegExp {
2314+
exec(input) {
2315+
this.lastIndex += 1
2316+
return this.lastIndex === 1 ? [input] : null
2317+
}
2318+
}
2319+
const advancingRegExp = new AdvancingRegExp('^admin$')
2320+
const verifier = createVerifier({ key: 'secret', allowedAud: advancingRegExp })
2321+
2322+
for (let attempt = 0; attempt < 8; attempt++) {
2323+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass with an advancing subclass`)
2324+
}
2325+
})
2326+
2327+
test('a matcher whose flag getters throw still validates instead of throwing', t => {
2328+
t.mock.timers.enable({ now: 100000 })
2329+
const sign = createSigner({ key: 'secret' })
2330+
const encodedToken = sign({ aud: 'admin' })
2331+
const throwingFlagHandler = {
2332+
get(target, property) {
2333+
if (property === 'global' || property === 'sticky') {
2334+
throw new TypeError('flag access refused')
2335+
}
2336+
const value = Reflect.get(target, property, target)
2337+
return typeof value === 'function' ? value.bind(target) : value
2338+
}
2339+
}
2340+
const proxiedRegExp = new Proxy(/^admin$/, throwingFlagHandler)
2341+
const verifier = createVerifier({ key: 'secret', allowedAud: proxiedRegExp })
2342+
2343+
for (let attempt = 0; attempt < 4; attempt++) {
2344+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass when flag getters throw`)
2345+
}
2346+
})
2347+
2348+
test('a frozen same-realm RegExp without a stateful flag validates without throwing', t => {
2349+
t.mock.timers.enable({ now: 100000 })
2350+
const sign = createSigner({ key: 'secret' })
2351+
const encodedToken = sign({ aud: 'admin' })
2352+
const frozenRegExp = Object.freeze(/^admin$/)
2353+
const verifier = createVerifier({ key: 'secret', allowedAud: frozenRegExp })
2354+
2355+
for (let attempt = 0; attempt < 4; attempt++) {
2356+
t.assert.doesNotThrow(() => verifier(encodedToken), `call ${attempt} should pass with a frozen RegExp`)
2357+
}
2358+
})
2359+
})
2360+
21232361
describe('crit header validation (RFC 7515 §4.1.11)', () => {
21242362
test('rejects token with unknown critical extension (secure-by-default, no allowedCritHeaders)', t => {
21252363
const signer = createSigner({

0 commit comments

Comments
 (0)