From 657328d22b7d8574c4daecf3855b5bb0e9285308 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 16:14:46 +0530 Subject: [PATCH 01/11] fix(isJWT): validate Base64url-decoded header and payload are JSON objects (fixes #2511) --- src/lib/isJWT.js | 29 ++++++++++++++++++++++++----- test/validators.test.js | 9 +++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 1d0ade5ee..20c7a1841 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -1,15 +1,34 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; +function tryDecodeJSON(segment) { + if (!isBase64(segment, { urlSafe: true })) return false; + try { + // Normalize base64url alphabet to base64, then restore stripped padding + let b64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + const decoded = Buffer.from(b64, 'base64').toString('utf8'); + 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); const dotSplit = str.split('.'); - const len = dotSplit.length; - if (len !== 3) { - return false; - } + if (dotSplit.length !== 3) return false; + + const header = dotSplit[0]; + const payload = dotSplit[1]; + const signature = dotSplit[2]; + + if (!tryDecodeJSON(header)) return false; + if (!tryDecodeJSON(payload)) return false; + if (!isBase64(signature, { urlSafe: true })) return false; - return dotSplit.reduce((acc, currElem) => acc && isBase64(currElem, { urlSafe: true }), true); + return true; } diff --git a/test/validators.test.js b/test/validators.test.js index 010d4fa5a..285ecabf1 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5549,6 +5549,15 @@ describe('Validators', () => { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTYxNjY1Mzg3Mn0.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiaWF0IjoxNjE2NjUzODcyLCJleHAiOjE2MTY2NTM4ODJ9.a1jLRQkO5TV5y5ERcaPAiM9Xm2gBdRjKrrCpHkGr_8M', '$Zs.ewu.su84', 'ks64$S/9.dy$§kz.3sd73b', + 'foo.bar.', + '..', + '.t.', + 'foo.bar.baz', + 'Zm9v.YmFy.', + 'eyJmb28iOiJiYXIifQ.YmFy.', + 'Zm9v.eyJiYXIiOiJiYXoifQ.', + 'W10=.eyJiYXIiOiJiYXoifQ.', + 'eyJmb28iOiJiYXIifQ.W10=.', ], error: [ [], From 3233fb73c0a6e96516b9d4cde7f50a3196d99dd7 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 16:21:37 +0530 Subject: [PATCH 02/11] test(isJWT) --- test/validators.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index 285ecabf1..fad3b284f 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5558,6 +5558,8 @@ describe('Validators', () => { 'Zm9v.eyJiYXIiOiJiYXoifQ.', 'W10=.eyJiYXIiOiJiYXoifQ.', 'eyJmb28iOiJiYXIifQ.W10=.', + 'bnVsbA.eyJiYXIiOiJiYXoifQ.', + 'WzFd.eyJiYXIiOiJiYXoifQ.', ], error: [ [], From d141406a0f2df048e5bef4249c06989a34a61796 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 16:26:03 +0530 Subject: [PATCH 03/11] fix(isJWT): --- src/lib/isJWT.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 20c7a1841..3951aae4a 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -9,7 +9,10 @@ function tryDecodeJSON(segment) { while (b64.length % 4) b64 += '='; const decoded = Buffer.from(b64, 'base64').toString('utf8'); const parsed = JSON.parse(decoded); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + if (typeof parsed !== 'object') return false; + if (parsed === null) return false; + if (Array.isArray(parsed)) return false; + return true; } catch (e) { return false; } From f44c09ed99e6bc7887d31d8b0b42f6d9021b26b1 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 16:33:07 +0530 Subject: [PATCH 04/11] test(isJWT): add cases to cover line 12 and line 34 branches --- test/validators.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/validators.test.js b/test/validators.test.js index fad3b284f..179093061 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5560,6 +5560,8 @@ describe('Validators', () => { 'eyJmb28iOiJiYXIifQ.W10=.', 'bnVsbA.eyJiYXIiOiJiYXoifQ.', 'WzFd.eyJiYXIiOiJiYXoifQ.', + 'ImhlbGxvIg.eyJiYXIiOiJiYXoifQ.', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.invalid$sig', ], error: [ [], From 018dfb41444b9edb81804587a5fd105e68ed0688 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 18:56:36 +0530 Subject: [PATCH 05/11] fix(isJWT): replace Buffer.from with cross-env decode helper; add unsecured JWT valid test --- src/lib/isJWT.js | 29 ++++++++++++++++++++++++++++- test/validators.test.js | 1 + 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 3951aae4a..214667e35 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -1,13 +1,40 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; +function decodeBase64Url(b64) { + if (typeof Buffer !== 'undefined') { + if (typeof Buffer.from === 'function') { + return Buffer.from(b64, 'base64').toString('utf8'); + } + // eslint-disable-next-line no-buffer-constructor + return new Buffer(b64, 'base64').toString('utf8'); + } + if (typeof atob === 'function') { + const binary = atob(b64); + if (typeof TextDecoder !== 'undefined') { + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder('utf-8').decode(bytes); + } + let encoded = ''; + for (let i = 0; i < binary.length; i += 1) { + const code = binary.charCodeAt(i).toString(16).padStart(2, '0'); + encoded += `%${code}`; + } + return decodeURIComponent(encoded); + } + return b64; +} + function tryDecodeJSON(segment) { if (!isBase64(segment, { urlSafe: true })) return false; try { // Normalize base64url alphabet to base64, then restore stripped padding let b64 = segment.replace(/-/g, '+').replace(/_/g, '/'); while (b64.length % 4) b64 += '='; - const decoded = Buffer.from(b64, 'base64').toString('utf8'); + const decoded = decodeBase64Url(b64); const parsed = JSON.parse(decoded); if (typeof parsed !== 'object') return false; if (parsed === null) return false; diff --git a/test/validators.test.js b/test/validators.test.js index 179093061..a152cb261 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5542,6 +5542,7 @@ describe('Validators', () => { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb3JlbSI6Imlwc3VtIn0.ymiJSsMJXR6tMSr8G9usjQ15_8hKPDv_CArLhxw28MI', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkb2xvciI6InNpdCIsImFtZXQiOlsibG9yZW0iLCJpcHN1bSJdfQ.rRpe04zbWbbJjwM43VnHzAboDzszJtGrNsUxaqQ-GQ8', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqb2huIjp7ImFnZSI6MjUsImhlaWdodCI6MTg1fSwiamFrZSI6eyJhZ2UiOjMwLCJoZWlnaHQiOjI3MH19.YRLPARDmhGMC3BBk_OhtwwK21PIkVCqQe8ncIRPKo-E', + 'eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.', ], invalid: [ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', From 32c48e33a0f948d0852e130a930d508b7c1c8304 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 19:01:15 +0530 Subject: [PATCH 06/11] chore(isJWT): ignore cross-env fallback from coverage --- src/lib/isJWT.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 214667e35..f40881949 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -1,6 +1,7 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; +/* istanbul ignore next */ function decodeBase64Url(b64) { if (typeof Buffer !== 'undefined') { if (typeof Buffer.from === 'function') { From 6853ed287be03b6ea81ff61c2d29e218e5097d55 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 19:09:30 +0530 Subject: [PATCH 07/11] fix(isJWT): replace padStart, scope istanbul ignore, fix test padding --- src/lib/isJWT.js | 6 ++++-- test/validators.test.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index f40881949..20bd045d3 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -1,7 +1,6 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; -/* istanbul ignore next */ function decodeBase64Url(b64) { if (typeof Buffer !== 'undefined') { if (typeof Buffer.from === 'function') { @@ -10,6 +9,7 @@ function decodeBase64Url(b64) { // eslint-disable-next-line no-buffer-constructor return new Buffer(b64, 'base64').toString('utf8'); } + /* istanbul ignore next */ if (typeof atob === 'function') { const binary = atob(b64); if (typeof TextDecoder !== 'undefined') { @@ -21,11 +21,13 @@ function decodeBase64Url(b64) { } let encoded = ''; for (let i = 0; i < binary.length; i += 1) { - const code = binary.charCodeAt(i).toString(16).padStart(2, '0'); + const hex = binary.charCodeAt(i).toString(16); + const code = hex.length === 1 ? `0${hex}` : hex; encoded += `%${code}`; } return decodeURIComponent(encoded); } + /* istanbul ignore next */ return b64; } diff --git a/test/validators.test.js b/test/validators.test.js index a152cb261..2b50c7a1d 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5557,8 +5557,8 @@ describe('Validators', () => { 'Zm9v.YmFy.', 'eyJmb28iOiJiYXIifQ.YmFy.', 'Zm9v.eyJiYXIiOiJiYXoifQ.', - 'W10=.eyJiYXIiOiJiYXoifQ.', - 'eyJmb28iOiJiYXIifQ.W10=.', + 'W10.eyJiYXIiOiJiYXoifQ.', + 'eyJmb28iOiJiYXIifQ.W10.', 'bnVsbA.eyJiYXIiOiJiYXoifQ.', 'WzFd.eyJiYXIiOiJiYXoifQ.', 'ImhlbGxvIg.eyJiYXIiOiJiYXoifQ.', From eaeaeb0118741df2e6656a7d966d95fc149c8b26 Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 19:20:38 +0530 Subject: [PATCH 08/11] chore(isJWT): add istanbul ignore for old Node Buffer fallback --- src/lib/isJWT.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 20bd045d3..5ae5ff513 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -3,9 +3,11 @@ import isBase64 from './isBase64'; function decodeBase64Url(b64) { if (typeof Buffer !== 'undefined') { + /* istanbul ignore else */ if (typeof Buffer.from === 'function') { return Buffer.from(b64, 'base64').toString('utf8'); } + /* istanbul ignore next */ // eslint-disable-next-line no-buffer-constructor return new Buffer(b64, 'base64').toString('utf8'); } From 81ac03978c3dc31b1bfbb9e2210b7a66a1f9b2fe Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 19:24:30 +0530 Subject: [PATCH 09/11] chore(isJWT): ignore Buffer undefined else branch --- src/lib/isJWT.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 5ae5ff513..05d9f07a4 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -2,6 +2,7 @@ import assertString from './util/assertString'; import isBase64 from './isBase64'; function decodeBase64Url(b64) { + /* istanbul ignore else */ if (typeof Buffer !== 'undefined') { /* istanbul ignore else */ if (typeof Buffer.from === 'function') { From cdea4ec7c49ab0a1aa4bcaaa72c9ea767670b81c Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 19:32:53 +0530 Subject: [PATCH 10/11] fix(isJWT): require non-empty signature unless alg is none (RFC 7519 Section 6) --- src/lib/isJWT.js | 8 ++++++-- test/validators.test.js | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 05d9f07a4..6dbff4fba 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -45,7 +45,7 @@ function tryDecodeJSON(segment) { if (typeof parsed !== 'object') return false; if (parsed === null) return false; if (Array.isArray(parsed)) return false; - return true; + return parsed; } catch (e) { return false; } @@ -62,9 +62,13 @@ export default function isJWT(str) { const payload = dotSplit[1]; const signature = dotSplit[2]; - if (!tryDecodeJSON(header)) return false; + const decodedHeader = tryDecodeJSON(header); + if (!decodedHeader) return false; if (!tryDecodeJSON(payload)) return false; if (!isBase64(signature, { urlSafe: true })) return false; + // Empty signature only allowed for unsecured JWTs (alg: none) + if (signature === '' && decodedHeader.alg !== 'none') return false; + return true; } diff --git a/test/validators.test.js b/test/validators.test.js index 2b50c7a1d..6405b1b76 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5563,6 +5563,7 @@ describe('Validators', () => { 'WzFd.eyJiYXIiOiJiYXoifQ.', 'ImhlbGxvIg.eyJiYXIiOiJiYXoifQ.', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.invalid$sig', + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.', ], error: [ [], From 6aae9bc5fd6093c638170e102cee9bcaec15c50c Mon Sep 17 00:00:00 2001 From: Kartikeya Date: Sat, 7 Mar 2026 23:02:06 +0530 Subject: [PATCH 11/11] revert(isJWT): remove alg check for empty signature, simplify header validation --- src/lib/isJWT.js | 6 +----- test/validators.test.js | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/lib/isJWT.js b/src/lib/isJWT.js index 6dbff4fba..9bb422a86 100644 --- a/src/lib/isJWT.js +++ b/src/lib/isJWT.js @@ -62,13 +62,9 @@ export default function isJWT(str) { const payload = dotSplit[1]; const signature = dotSplit[2]; - const decodedHeader = tryDecodeJSON(header); - if (!decodedHeader) return false; + if (!tryDecodeJSON(header)) return false; if (!tryDecodeJSON(payload)) return false; if (!isBase64(signature, { urlSafe: true })) return false; - // Empty signature only allowed for unsecured JWTs (alg: none) - if (signature === '' && decodedHeader.alg !== 'none') return false; - return true; } diff --git a/test/validators.test.js b/test/validators.test.js index 6405b1b76..2b50c7a1d 100644 --- a/test/validators.test.js +++ b/test/validators.test.js @@ -5563,7 +5563,6 @@ describe('Validators', () => { 'WzFd.eyJiYXIiOiJiYXoifQ.', 'ImhlbGxvIg.eyJiYXIiOiJiYXoifQ.', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.invalid$sig', - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.', ], error: [ [],