Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions test/claim-aud.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
});
});
});
18 changes: 18 additions & 0 deletions test/verify.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 16 additions & 1 deletion verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down Expand Up @@ -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;
});
});

Expand Down