From a32ddb7ffef4fbdfa455f515f46bcdd752e265af Mon Sep 17 00:00:00 2001 From: yfwmaniish Date: Tue, 1 Sep 2026 14:49:34 +0530 Subject: [PATCH] fix(verify): bound RegExp "audience" matching against an oversized aud 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 #1031. --- README.md | 2 ++ test/claim-aud.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++ test/verify.tests.js | 18 +++++++++++ verify.js | 17 +++++++++- 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e20dd9c..705467e4 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ As mentioned in [this comment](https://github.com/auth0/node-jsonwebtoken/issues > * default - ['RS256', 'RS384', 'RS512'] * `audience`: if you want to check audience (`aud`), provide a value here. The audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. > Eg: `"urn:foo"`, `/urn:f[o]{2}/`, `[/urn:f[o]{2}/, "urn:bar"]` + > When `audience` includes a regular expression, it is matched against the token's `aud` claim, which is attacker-controlled. A poorly-written pattern (e.g. one with nested quantifiers) can be forced into catastrophic backtracking by a crafted `aud` value (ReDoS). `maxAudienceLength` (below) bounds the input length that gets tested, but the real fix is to write `aud` patterns that can't backtrack catastrophically in the first place - anchor them and avoid nested/ambiguous quantifiers. +* `maxAudienceLength`: maximum length, in characters, that a token's `aud` claim may be before being tested against a regular-expression `audience` (default: `256`). A claim longer than this is treated as a non-match rather than being passed to the regex, protecting against ReDoS via an oversized `aud` value. Has no effect on string `audience` checks, which use plain equality. * `complete`: return an object with the decoded `{ payload, header, signature }` instead of only the usual content of the payload. * `issuer` (optional): string or array of strings of valid values for the `iss` field. * `jwtid` (optional): if you want to check JWT ID (`jti`), provide a string value here. diff --git a/test/claim-aud.test.js b/test/claim-aud.test.js index 3a27fd89..0a638c53 100644 --- a/test/claim-aud.test.js +++ b/test/claim-aud.test.js @@ -433,4 +433,74 @@ describe('audience', function() { }); }); }); + + // See: https://github.com/auth0/node-jsonwebtoken/issues/1031 + // A RegExp "audience" is tested (RegExp#test) against the "aud" claim, which + // comes straight from the token payload. An oversized, attacker-crafted "aud" + // can force a catastrophically-backtracking pattern into a multi-second (or + // longer) hang. maxAudienceLength bounds this by rejecting an overlong "aud" + // before it ever reaches the regex. + describe('ReDoS protection for a RegExp "audience" option', function () { + // Catastrophic backtracking: verify() with the pre-fix code takes ~2s for + // 24 "a"s and grows exponentially from there - never actually run this + // against an unbounded-length "aud" in a test. + const catastrophicRegex = /(a+)+$/; + let longAudToken; + + beforeEach(function (done) { + // 300 "a"s: past the default 256-char cap, so the fix never touches + // the regex engine at all - safe to include in the normal test run. + signWithAudience(undefined, {aud: 'a'.repeat(300) + '!'}, (err, t) => { + longAudToken = t; + done(err); + }); + }); + + it('should quickly reject an "aud" claim longer than the default maxAudienceLength instead of matching it against the regex', function (done) { + const start = Date.now(); + verifyWithAudience(longAudToken, catastrophicRegex, (err) => { + testUtils.asyncCheck(done, () => { + expect(Date.now() - start).to.be.below(500); + expect(err).to.be.instanceOf(jwt.JsonWebTokenError); + expect(err).to.have.property('message', `jwt audience invalid. expected: ${String(catastrophicRegex)}`); + }); + }); + }); + + it('should still match a legitimate "aud" claim under the length cap against a RegExp "audience"', function (done) { + testUtils.signJWTHelper({aud: 'urn:foo'}, 'secret', {algorithm: 'HS256'}, (signErr, token) => { + if (signErr) return done(signErr); + verifyWithAudience(token, /^urn:f[o]{2}$/, (err, decoded) => { + testUtils.asyncCheck(done, () => { + expect(err).to.be.null; + expect(decoded).to.have.property('aud', 'urn:foo'); + }); + }); + }); + }); + + it('should respect a custom "maxAudienceLength" option, rejecting an "aud" the default cap would allow', function (done) { + testUtils.signJWTHelper({aud: 'urn:foo'}, 'secret', {algorithm: 'HS256'}, (signErr, token) => { + if (signErr) return done(signErr); + testUtils.verifyJWTHelper(token, 'secret', {audience: /^urn:f[o]{2}$/, maxAudienceLength: 3}, (err) => { + testUtils.asyncCheck(done, () => { + expect(err).to.be.instanceOf(jwt.JsonWebTokenError); + expect(err).to.have.property('message', 'jwt audience invalid. expected: /^urn:f[o]{2}$/'); + }); + }); + }); + }); + + it('should not apply maxAudienceLength to a string "audience" check', function (done) { + testUtils.signJWTHelper({aud: 'a'.repeat(300)}, 'secret', {algorithm: 'HS256'}, (signErr, token) => { + if (signErr) return done(signErr); + verifyWithAudience(token, 'a'.repeat(300), (err, decoded) => { + testUtils.asyncCheck(done, () => { + expect(err).to.be.null; + expect(decoded).to.have.property('aud', 'a'.repeat(300)); + }); + }); + }); + }); + }); }); diff --git a/test/verify.tests.js b/test/verify.tests.js index 88500756..26a25683 100644 --- a/test/verify.tests.js +++ b/test/verify.tests.js @@ -248,6 +248,24 @@ describe('verify', function() { }); }); + describe('option: maxAudienceLength', function () { + [ + 'notANumber', + 0, + -1, + NaN, + ].forEach((maxAudienceLength) => { + it(`should error with value ${String(maxAudienceLength)}`, function (done) { + jwt.verify(token, key, {maxAudienceLength}, function (err, p) { + assert.equal(err.name, 'JsonWebTokenError'); + assert.equal(err.message, 'maxAudienceLength must be a positive number'); + assert.isUndefined(p); + done(); + }); + }); + }); + }); + describe('option: maxAge and clockTimestamp', function () { // { foo: 'bar', iat: 1437018582, exp: 1437018800 } exp = iat + 218s const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpYXQiOjE0MzcwMTg1ODIsImV4cCI6MTQzNzAxODgwMH0.AVOsNC7TiT-XVSpCpkwB1240izzCIJ33Lp07gjnXVpA'; diff --git a/verify.js b/verify.js index cdbfdc45..521c19cb 100644 --- a/verify.js +++ b/verify.js @@ -54,6 +54,10 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { return done(new JsonWebTokenError('allowInvalidAsymmetricKeyTypes must be a boolean')); } + if (options.maxAudienceLength !== undefined && (typeof options.maxAudienceLength !== 'number' || !(options.maxAudienceLength > 0))) { + return done(new JsonWebTokenError('maxAudienceLength must be a positive number')); + } + const clockTimestamp = options.clockTimestamp || Math.floor(Date.now() / 1000); if (!jwtString){ @@ -195,9 +199,20 @@ module.exports = function (jwtString, secretOrPublicKey, options, callback) { const audiences = Array.isArray(options.audience) ? options.audience : [options.audience]; const target = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + // A RegExp audience is tested against the aud claim, which comes straight + // from the token payload - an attacker who controls that claim can pick + // an input crafted to catastrophically backtrack against the + // application's regex (ReDoS). Capping the length of what gets tested + // bounds a single verify() call regardless of how that regex is written. + const maxAudienceLength = options.maxAudienceLength || 256; + const match = target.some(function (targetAudience) { return audiences.some(function (audience) { - return audience instanceof RegExp ? audience.test(targetAudience) : audience === targetAudience; + if (audience instanceof RegExp) { + const targetAudienceString = String(targetAudience); + return targetAudienceString.length <= maxAudienceLength && audience.test(targetAudienceString); + } + return audience === targetAudience; }); });