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; }); });