Skip to content

fix(verify): bound RegExp "audience" matching against an oversized aud claim (ReDoS) - #1044

Open
yfwmaniish wants to merge 1 commit into
auth0:masterfrom
yfwmaniish:fix/audience-regex-redos
Open

fix(verify): bound RegExp "audience" matching against an oversized aud claim (ReDoS)#1044
yfwmaniish wants to merge 1 commit into
auth0:masterfrom
yfwmaniish:fix/audience-regex-redos

Conversation

@yfwmaniish

Copy link
Copy Markdown

Fixes #1031.

What

A RegExp audience is tested via RegExp#test(targetAudience) in verify.js, where targetAudience comes straight from the token's aud claim - fully attacker-controlled:

return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience;

If the application's audience regex has catastrophic-backtracking potential (nested quantifiers, ambiguous alternation - a documented, common shape for multi-tenant/wildcard-subdomain audience matching), a single crafted token can hang verify() for seconds to minutes. Reproduced directly against current master:

$ node -e "
const jwt = require('./index.js');
const audRegex = /(a+)+\$/;
const token = jwt.sign({ aud: 'a'.repeat(24) + '!' }, 'secret');
const t0 = Date.now();
try { jwt.verify(token, 'secret', { audience: audRegex }); } catch (_) {}
console.log('verify took', Date.now() - t0, 'ms');
"
verify took 1993 ms

Time grows exponentially with input length from there.

Fix

Added a maxAudienceLength option (default 256). An aud claim longer than this is treated as a non-match rather than being passed to the regex at all, so a single verify() call is bounded regardless of how the application's regex is written. Only applies to RegExp audiences - string audience checks (plain equality) are O(n) and unaffected.

Being upfront about the limits of this fix

I want to be precise here rather than overclaiming. This is a defense-in-depth bound, not a complete fix for catastrophic-backtracking regexes:

  • It closes the unbounded-length attack surface - previously an attacker could submit an arbitrarily long aud (kilobytes) and drive the hang arbitrarily high. Now the worst case is bounded to whatever maxAudienceLength characters cost against the application's specific regex.
  • But the /(a+)+$/ example above is already catastrophic at ~20-25 characters - well under the 256-char default. No length cap can be both tight enough to block a 24-character attack and loose enough to let realistic audience strings (tenant subdomains, URLs - often 30-60+ characters) through. I checked: a cap tight enough to stop this specific example would reject essentially every realistic RegExp-audience use case, so I didn't set the default that low.
  • The actual, complete fix for a genuinely catastrophic pattern is for the application's own audience regex to not have catastrophic-backtracking potential in the first place (anchor it, avoid nested/ambiguous quantifiers). I've documented this directly in the README next to the new option rather than implying the length cap alone solves it.
  • maxAudienceLength is configurable, so an application that knows its audience format is short (e.g. always under 32 chars) can tighten it meaningfully.

I think this is still worth merging as a real, tested, backward-compatible improvement, but wanted to be explicit rather than have this read as "fixes ReDoS" when it's better described as "removes the unbounded-length case and gives applications a knob."

Testing

  • Added maxAudienceLength validation tests (mirrors the existing clockTimestamp/allowInvalidAsymmetricKeyTypes option-validation style) - including a test for NaN, which caught a real bug in my first attempt at the validation guard (NaN <= 0 is false, so it silently passed through; fixed by inverting to !(value > 0)).
  • Added a ReDoS regression test using the same /(a+)+$/ pattern with a 300-char aud (past the default cap, so it's fast either way in CI - never runs the actual unbounded-length case in the test suite), asserting the call completes in well under 500ms and returns the normal "audience invalid" error.
  • Added tests confirming: a legitimate under-cap audience still matches correctly, a custom maxAudienceLength is respected, and string audience checks are unaffected by the cap.
  • Negative control: reverted just verify.js, kept the tests - the 4 new validation tests and the "custom maxAudienceLength" test all fail as expected (the ReDoS-timing test itself was excluded from this run via --grep --invert, since without the fix it would hit genuine catastrophic backtracking against the 300-char input and hang rather than fail cleanly - confirmed separately, at a bounded scale, via the reproduction above). Restored.
  • Full suite: mocha - 519/519 passing (1 pre-existing pending, unrelated).
  • eslint clean on all three changed source/test files.

@yfwmaniish
yfwmaniish requested a review from a team as a code owner September 1, 2026 09:17
…d claim (ReDoS)

A RegExp `audience` is tested via `RegExp#test(targetAudience)`, where
targetAudience comes straight from the token's aud claim - fully
attacker-controlled. An application whose audience regex has
catastrophic-backtracking potential (nested quantifiers, ambiguous
alternation - a documented-common shape for multi-tenant/wildcard
audience matching) can be driven into a multi-second-or-worse hang by
a single crafted token; reproduced directly against current master
(2s for a 24-char crafted aud against /(a+)+$/, growing exponentially
from there).

Adds a `maxAudienceLength` option (default 256): an aud claim longer
than this is treated as a non-match rather than being handed to the
regex at all, so a single verify() call is bounded regardless of how
the application's regex is written. Only applies to RegExp audiences;
string audience checks (plain equality) are unaffected.

Being upfront about what this does and doesn't do: this is a
defense-in-depth bound, not a complete fix. It closes the
unbounded-length attack surface, but a regex that's already
catastrophic at ~20-25 characters (like the /(a+)+$/ example above)
isn't stopped by a 256-char default, since the malicious input is
shorter than the cap - no cap can be both tight enough to block that
and loose enough to allow realistic audience strings through. The
actual fix for that case is for the application's own audience regex
to not have catastrophic-backtracking potential in the first place;
documented as such in the README alongside the new option.

Fixes auth0#1031.
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.

ReDoS in verify() when audience option is a RegExp: attacker-controlled aud claim → catastrophic backtracking

1 participant