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..00cff90a7 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5532,6 +5532,19 @@ 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 + // 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: [ [], @@ -5542,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',