From bc8f0c95f3798627d1167693171dfbd879e3d12b Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Mon, 16 Mar 2026 04:23:45 -0500 Subject: [PATCH 1/2] fix(isJWT): validate that header and payload decode to valid JSON objects (#2511) The isJWT validator now properly validates that the header (first part) and payload (second part) of a JWT decode to valid JSON objects, not just valid Base64 strings. Per RFC 7519, a JWT consists of three Base64URL-encoded parts: - Header: MUST be a JSON object containing at least "alg" - Payload: MUST be a JSON object (the claims) - Signature: Binary data (not required to be JSON) Previously, strings like "foo.bar.baz" or ".babelrc.cjs" would incorrectly return true because they matched the Base64 pattern, even though decoding them does not produce valid JSON. Fixes #2511 Co-Authored-By: Claude Opus 4.6 --- src/lib/isJWT.js | 41 ++++++++++++++++++++++++++++++++++++++++- test/validators.test.js | 5 +++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 1d0ade5ee..c8131337e 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -1,6 +1,33 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; +function isValidJSONObject(str) { + // Base64 URL decode + // Replace URL-safe chars and add padding if needed + let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) { + base64 += '='; + } + + try { + // Use atob for browser or Buffer for Node.js + let decoded; + if (typeof atob === 'function') { + decoded = atob(base64); + } else if (typeof Buffer !== 'undefined') { + decoded = Buffer.from(base64, 'base64').toString('utf8'); + } else { + return false; + } + + // Parse as JSON and verify it's an object (not array, string, number, etc.) + const parsed = JSON.parse(decoded); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + } catch (e) { + return false; + } +} + export default function isJWT(str) { assertString(str); @@ -11,5 +38,17 @@ export default function isJWT(str) { return false; } - return dotSplit.reduce((acc, currElem) => acc && isBase64(currElem, { urlSafe: true }), true); + // All three parts must be valid Base64 URL + const allBase64 = dotSplit.reduce( + (acc, currElem) => acc && isBase64(currElem, { urlSafe: true }), + true + ); + + if (!allBase64) { + return false; + } + + // Header (first part) and Payload (second part) must be valid JSON objects + // Signature (third part) does not need to be valid JSON + return isValidJSONObject(dotSplit[0]) && isValidJSONObject(dotSplit[1]); } diff --git a/test/validators.test.js b/test/validators.test.js index 60ffa9c81..2b4546e78 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5532,6 +5532,11 @@ describe('Validators', () => { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTYxNjY1Mzg3Mn0.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiaWF0IjoxNjE2NjUzODcyLCJleHAiOjE2MTY2NTM4ODJ9.a1jLRQkO5TV5y5ERcaPAiM9Xm2gBdRjKrrCpHkGr_8M', '$Zs.ewu.su84', 'ks64$S/9.dy$§kz.3sd73b', + // Issue #2511: Base64-valid but not valid JSON when decoded + 'foo.bar.baz', // "foo" decodes to invalid JSON + '.babelrc.cjs', // empty string and non-JSON + '..', // empty parts + '.t.', // empty header and signature ], error: [ [], From 1602dfbfe94803f6c60c22060557a35aad70e8b0 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Mon, 16 Mar 2026 04:53:23 -0500 Subject: [PATCH 2/2] test(isJWT): add test coverage for JSON validation edge cases Adds tests for 100% coverage of the new isValidJSONObject function: - Edge cases for JSON types that aren't objects (arrays, null, primitives) - Buffer.from() fallback path (when atob is unavailable) - Fallback return false path (when neither atob nor Buffer available) Fixes #2511 coverage requirements. Co-Authored-By: Claude Opus 4.6 --- test/validators.test.js | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index 2b4546e78..00cff90a7 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5537,6 +5537,14 @@ describe('Validators', () => { '.babelrc.cjs', // empty string and non-JSON '..', // empty parts '.t.', // empty header and signature + // Issue #2511: Valid JSON but not an object (array, null, primitive types) + 'WyJhIiwiYiJd.eyJzdWIiOiIxMjM0In0.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // array as header + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.WyJhIiwiYiJd.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // array as payload + 'bnVsbA.eyJzdWIiOiIxMjM0In0.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // null as header + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bnVsbA.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // null as payload + 'ImhlbGxvIg.eyJzdWIiOiIxMjM0In0.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // string as header + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MTIz.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // number as payload + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.dHJ1ZQ.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', // boolean as payload ], error: [ [], @@ -5547,6 +5555,47 @@ describe('Validators', () => { }); }); + it('should validate JWT tokens using Buffer fallback (when atob is unavailable)', () => { + // Test the Buffer.from() fallback path in isValidJSONObject by temporarily removing atob + const originalAtob = global.atob; + global.atob = undefined; + + try { + test({ + validator: 'isJWT', + valid: [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHI', + ], + invalid: [ + 'foo.bar.baz', // Invalid JSON when decoded + ], + }); + } finally { + global.atob = originalAtob; + } + }); + + it('should reject JWT tokens when no decoder is available (neither atob nor Buffer)', () => { + // Test the fallback return false path when both atob and Buffer are unavailable + const originalAtob = global.atob; + const originalBuffer = global.Buffer; + global.atob = undefined; + global.Buffer = undefined; + + try { + test({ + validator: 'isJWT', + invalid: [ + // Valid Base64 JWT structure but should fail since no decoder available + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHI', + ], + }); + } finally { + global.atob = originalAtob; + global.Buffer = originalBuffer; + } + }); + it('should validate null strings', () => { test({ validator: 'isEmpty',