From 49cf7db02b692d66fab32479c616e9c7318277ff Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Tue, 3 Nov 2026 12:15:12 -0800 Subject: [PATCH 1/3] Align base64 implementations further --- src/base64.spec.ts | 100 +++++++++++++++++++++++++++++++++++---------- src/base64.ts | 10 +++-- src/index.ts | 7 ++-- src/parse.spec.ts | 25 ++++++++++++ 4 files changed, 114 insertions(+), 28 deletions(-) diff --git a/src/base64.spec.ts b/src/base64.spec.ts index 2abbfa9..43f812c 100644 --- a/src/base64.spec.ts +++ b/src/base64.spec.ts @@ -7,35 +7,93 @@ const importBase64 = async () => { return base64; }; -describe('base64', async () => { - let base64 = await importBase64(); +const ENCODE_CASES = [ + ['', ''], + ['f', 'Zg=='], + ['fo', 'Zm8='], + ['foo', 'Zm9v'], + ['foob', 'Zm9vYg=='], + ['fooba', 'Zm9vYmE='], + ['foobar', 'Zm9vYmFy'], + ['foo:bar', 'Zm9vOmJhcg=='], + [':', 'Og=='], + ['\ufeffa:', '77u/YTo='], + ['\0\x7f', 'AH8='], + ['ü:密碼', 'w7w65a+G56K8'], + ['😀:🔑', '8J+YgDrwn5SR'], + ['\ud800', '77+9'], + ['\udc00', '77+9'], +] as const; + +const DECODE_CASES = [ + ...ENCODE_CASES.map( + ([value, encoded]) => [encoded, value.toWellFormed()] as const, + ), + ['Zg', 'f'], + ['Zm8', 'fo'], + [' \tZm9v\r\nOmJhcg==\f', 'foo:bar'], + ['Zh==', 'f'], + ['Zm9=', 'fo'], + ['Y', ''], + ['!', ''], + ['/w==', '\ufffd'], + ['wyg=', '\ufffd('], + ['4oI=', '\ufffd'], + ['77+9', '\ufffd'], +] as const; + +describe.skipIf(typeof Buffer === 'undefined')('base64 (Buffer)', () => { + let base64: typeof import('./base64.js').base64; + + beforeAll(async () => { + base64 = await importBase64(); + }); afterAll(() => { - vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it.each(ENCODE_CASES)('encodes %j as %j', (value, encoded) => { + assert.strictEqual(base64.encode(value), encoded); + }); + + it.each(DECODE_CASES)('decodes %j as %j', (encoded, value) => { + assert.strictEqual(base64.decode(encoded), value); }); - describe.skipIf(!Buffer)('Buffer', async () => { - it('should encode base64', () => { - assert.strictEqual(base64.encode('foo:bar'), 'Zm9vOmJhcg=='); - }); + it.each(ENCODE_CASES)('round-trips %j', (value) => { + assert.strictEqual( + base64.decode(base64.encode(value)), + value.toWellFormed(), + ); + }); +}); + +describe.skipIf(!Uint8Array.prototype.toBase64)('base64 (Uint8Array)', () => { + let base64: typeof import('./base64.js').base64; - it('should decode base64', () => { - assert.strictEqual(base64.decode('Zm9vOmJhcg=='), 'foo:bar'); - }); + beforeAll(async () => { + vi.stubGlobal('Buffer', undefined); + base64 = await importBase64(); }); - describe.skipIf(!Uint8Array.prototype.toBase64)('Uint8Array', async () => { - beforeAll(async () => { - vi.stubGlobal('Buffer', undefined); - base64 = await importBase64(); - }); + afterAll(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it.each(ENCODE_CASES)('encodes %j as %j', (value, encoded) => { + assert.strictEqual(base64.encode(value), encoded); + }); - it('should encode base64', () => { - assert.strictEqual(base64.encode('foo:bar'), 'Zm9vOmJhcg=='); - }); + it.each(DECODE_CASES)('decodes %j as %j', (encoded, value) => { + assert.strictEqual(base64.decode(encoded), value); + }); - it('should decode base64', () => { - assert.strictEqual(base64.decode('Zm9vOmJhcg=='), 'foo:bar'); - }); + it.each(ENCODE_CASES)('round-trips %j', (value) => { + assert.strictEqual( + base64.decode(base64.encode(value)), + value.toWellFormed(), + ); }); }); diff --git a/src/base64.ts b/src/base64.ts index 30be663..0cd2a3f 100644 --- a/src/base64.ts +++ b/src/base64.ts @@ -7,7 +7,7 @@ export const base64 = (() => { } const textEncoder = new TextEncoder(); - const textDecoder = new TextDecoder(); + const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true }); return { encode: (str: string) => { @@ -15,8 +15,12 @@ export const base64 = (() => { return bytes.toBase64(); }, decode: (str: string) => { - const bytes = Uint8Array.fromBase64(str); - return textDecoder.decode(bytes); + try { + const bytes = Uint8Array.fromBase64(str); + return textDecoder.decode(bytes); + } catch { + return ''; + } }, }; })(); diff --git a/src/index.ts b/src/index.ts index 959b832..f17fb6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,14 +82,13 @@ export function format(credentials: Credentials): string { /** * RegExp for basic auth credentials * - * credentials = auth-scheme 1*SP token68 + * credentials = auth-scheme 1*SP base64 * auth-scheme = "Basic" ; case insensitive - * token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=" + * base64 = 1*( ALPHA / DIGIT / "+" / "/" ) *"=" * @private */ -const CREDENTIALS_REGEXP = - /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/; +const CREDENTIALS_REGEXP = /^ *Basic +([A-Za-z0-9+/]+=*) *$/; /** * RegExp for RFC 5234 CTL characters (US-ASCII 0-31 and 127). diff --git a/src/parse.spec.ts b/src/parse.spec.ts index 5bd5b79..8669935 100644 --- a/src/parse.spec.ts +++ b/src/parse.spec.ts @@ -26,6 +26,31 @@ describe('parse(string)', function () { }); }); + describe('with non-base64 characters', function () { + it.each(['.', '_', '~', '-', '!'])( + 'should reject embedded %j', + function (character) { + assert.strictEqual(parse(`Basic Zm9v${character}OmJhcg==`), undefined); + }, + ); + + it.each(['77-_Og==', '77-9Og==', '77+/Og==!'])( + 'should reject %j', + function (credentials) { + assert.strictEqual(parse(`Basic ${credentials}`), undefined); + }, + ); + }); + + describe('with standard base64 alphabet symbols', function () { + it('should accept plus and slash', function () { + assert.deepEqual(parse('Basic 77+/Og=='), { + name: '\uffff', + pass: '', + }); + }); + }); + describe('with valid credentials', function () { it('should return .name and .pass', function () { var creds = parse('basic Zm9vOmJhcg=='); From da2039f35a5d0018e8d803648708df85d004dd6d Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Tue, 3 Nov 2026 12:51:39 -0800 Subject: [PATCH 2/3] Align base64 implementations --- src/base64.spec.ts | 22 ++++------- src/base64.ts | 8 +--- src/format.spec.ts | 43 +++++++++------------- src/index.ts | 15 +++++++- src/parse.spec.ts | 92 ++++++++++++++++++++++++++++++---------------- 5 files changed, 100 insertions(+), 80 deletions(-) diff --git a/src/base64.spec.ts b/src/base64.spec.ts index 43f812c..0dda594 100644 --- a/src/base64.spec.ts +++ b/src/base64.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, assert, beforeAll, describe, it, vi } from 'vitest'; +import { afterAll, expect, beforeAll, describe, it, vi } from 'vitest'; const importBase64 = async () => { vi.resetModules(); @@ -34,8 +34,6 @@ const DECODE_CASES = [ [' \tZm9v\r\nOmJhcg==\f', 'foo:bar'], ['Zh==', 'f'], ['Zm9=', 'fo'], - ['Y', ''], - ['!', ''], ['/w==', '\ufffd'], ['wyg=', '\ufffd('], ['4oI=', '\ufffd'], @@ -54,18 +52,15 @@ describe.skipIf(typeof Buffer === 'undefined')('base64 (Buffer)', () => { }); it.each(ENCODE_CASES)('encodes %j as %j', (value, encoded) => { - assert.strictEqual(base64.encode(value), encoded); + expect(base64.encode(value)).toEqual(encoded); }); it.each(DECODE_CASES)('decodes %j as %j', (encoded, value) => { - assert.strictEqual(base64.decode(encoded), value); + expect(base64.decode(encoded)).toEqual(value); }); it.each(ENCODE_CASES)('round-trips %j', (value) => { - assert.strictEqual( - base64.decode(base64.encode(value)), - value.toWellFormed(), - ); + expect(base64.decode(base64.encode(value))).toEqual(value.toWellFormed()); }); }); @@ -83,17 +78,14 @@ describe.skipIf(!Uint8Array.prototype.toBase64)('base64 (Uint8Array)', () => { }); it.each(ENCODE_CASES)('encodes %j as %j', (value, encoded) => { - assert.strictEqual(base64.encode(value), encoded); + expect(base64.encode(value)).toEqual(encoded); }); it.each(DECODE_CASES)('decodes %j as %j', (encoded, value) => { - assert.strictEqual(base64.decode(encoded), value); + expect(base64.decode(encoded)).toEqual(value); }); it.each(ENCODE_CASES)('round-trips %j', (value) => { - assert.strictEqual( - base64.decode(base64.encode(value)), - value.toWellFormed(), - ); + expect(base64.decode(base64.encode(value))).toEqual(value.toWellFormed()); }); }); diff --git a/src/base64.ts b/src/base64.ts index 0cd2a3f..4d0aa11 100644 --- a/src/base64.ts +++ b/src/base64.ts @@ -15,12 +15,8 @@ export const base64 = (() => { return bytes.toBase64(); }, decode: (str: string) => { - try { - const bytes = Uint8Array.fromBase64(str); - return textDecoder.decode(bytes); - } catch { - return ''; - } + const bytes = Uint8Array.fromBase64(str); + return textDecoder.decode(bytes); }, }; })(); diff --git a/src/format.spec.ts b/src/format.spec.ts index e64c0a9..dd906b9 100644 --- a/src/format.spec.ts +++ b/src/format.spec.ts @@ -1,75 +1,68 @@ -import { describe, it, assert } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { format } from './index.js'; describe('format(credentials)', function () { describe('arguments', function () { describe('credentials', function () { it('should be required', function () { - assert.throws(() => (format as any)(), /Expected an object/); + expect(() => (format as any)()).toThrow(/Expected an object/); }); it('should accept credentials', function () { const header = format({ name: 'foo', pass: 'bar' }); - assert.strictEqual(header, 'Basic Zm9vOmJhcg=='); + expect(header).toEqual('Basic Zm9vOmJhcg=='); }); it('should reject null', function () { - assert.throws(format.bind(null, null as any), /Expected an object/); + expect(format.bind(null, null as any)).toThrow(/Expected an object/); }); it('should reject a number', function () { - assert.throws(format.bind(null, 42 as any), /Expected an object/); + expect(format.bind(null, 42 as any)).toThrow(/Expected an object/); }); it('should reject a string', function () { - assert.throws(format.bind(null, '' as any), /Expected an object/); + expect(format.bind(null, '' as any)).toThrow(/Expected an object/); }); it('should reject an object without name', function () { - assert.throws( - format.bind(null, { pass: 'bar' } as any), + expect(format.bind(null, { pass: 'bar' } as any)).toThrow( /Object must have string properties "name" and "pass"/, ); }); it('should reject an object without pass', function () { - assert.throws( - format.bind(null, { name: 'foo' } as any), + expect(format.bind(null, { name: 'foo' } as any)).toThrow( /Object must have string properties "name" and "pass"/, ); }); it('should reject an object with non-string name', function () { - assert.throws( - format.bind(null, { name: 42, pass: 'bar' } as any), + expect(format.bind(null, { name: 42, pass: 'bar' } as any)).toThrow( /Object must have string properties "name" and "pass"/, ); }); it('should reject an object with non-string pass', function () { - assert.throws( - format.bind(null, { name: 'foo', pass: 42 } as any), + expect(format.bind(null, { name: 'foo', pass: 42 } as any)).toThrow( /Object must have string properties "name" and "pass"/, ); }); it('should reject userid containing colon', function () { - assert.throws( - format.bind(null, { name: 'foo:bar', pass: 'baz' }), + expect(format.bind(null, { name: 'foo:bar', pass: 'baz' })).toThrow( /must not contain a colon/, ); }); it('should reject control chars in userid', function () { - assert.throws( + expect( format.bind(null, { name: 'foo\u0000bar', pass: 'baz' }), - /must not contain control characters/, - ); + ).toThrow(/must not contain control characters/); }); it('should reject control chars in password', function () { - assert.throws( - format.bind(null, { name: 'foo', pass: 'bar\u007f' }), + expect(format.bind(null, { name: 'foo', pass: 'bar\u007f' })).toThrow( /must not contain control characters/, ); }); @@ -79,28 +72,28 @@ describe('format(credentials)', function () { describe('with valid credentials', function () { it('should return header', function () { const header = format({ name: 'foo', pass: 'bar' }); - assert.strictEqual(header, 'Basic Zm9vOmJhcg=='); + expect(header).toEqual('Basic Zm9vOmJhcg=='); }); }); describe('with empty password', function () { it('should return header', function () { const header = format({ name: 'foo', pass: '' }); - assert.strictEqual(header, 'Basic Zm9vOg=='); + expect(header).toEqual('Basic Zm9vOg=='); }); }); describe('with empty userid', function () { it('should return header', function () { const header = format({ name: '', pass: 'pass' }); - assert.strictEqual(header, 'Basic OnBhc3M='); + expect(header).toEqual('Basic OnBhc3M='); }); }); describe('with empty userid and pass', function () { it('should return header', function () { const header = format({ name: '', pass: '' }); - assert.strictEqual(header, 'Basic Og=='); + expect(header).toEqual('Basic Og=='); }); }); }); diff --git a/src/index.ts b/src/index.ts index f17fb6c..8b259bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,8 +33,19 @@ export function parse(string: string): Credentials | undefined { const match = CREDENTIALS_REGEXP.exec(string); if (!match) return undefined; + // Allow unpadded base64, but reject partial groups and malformed padding. + const encoded = match[1]; + const length = encoded.length; + if ( + length % 4 === 1 || + (encoded.charCodeAt(length - 1) === 61 && + (length % 4 !== 0 || encoded.charCodeAt(length - 3) === 61)) + ) { + return undefined; + } + // decode user pass - const userPass = base64.decode(match[1]); + const userPass = base64.decode(encoded); const colonIndex = userPass.indexOf(':'); if (colonIndex === -1) return undefined; @@ -88,7 +99,7 @@ export function format(credentials: Credentials): string { * @private */ -const CREDENTIALS_REGEXP = /^ *Basic +([A-Za-z0-9+/]+=*) *$/; +const CREDENTIALS_REGEXP = /^ *Basic +([a-z0-9+/]+=*) *$/i; /** * RegExp for RFC 5234 CTL characters (US-ASCII 0-31 and 127). diff --git a/src/parse.spec.ts b/src/parse.spec.ts index 8669935..5a4c3ad 100644 --- a/src/parse.spec.ts +++ b/src/parse.spec.ts @@ -1,28 +1,29 @@ -import { describe, it, assert } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { base64 } from './base64.js'; import { parse } from './index.js'; describe('parse(string)', function () { describe('with non string', function () { it('should throw', function () { - assert.throws(() => parse(undefined as any), /Expected a string/); + expect(() => parse(undefined as any)).toThrow(/Expected a string/); }); }); describe('with malformed string', function () { it('should return undefined', function () { - assert.strictEqual(parse('Something'), undefined); + expect(parse('Something')).toEqual(undefined); }); }); describe('with malformed scheme', function () { it('should return undefined', function () { - assert.strictEqual(parse('basic_Zm9vOmJhcg=='), undefined); + expect(parse('basic_Zm9vOmJhcg==')).toEqual(undefined); }); }); describe('with malformed credentials', function () { it('should return undefined', function () { - assert.strictEqual(parse('basic Zm9vcgo='), undefined); + expect(parse('basic Zm9vcgo=')).toEqual(undefined); }); }); @@ -30,21 +31,21 @@ describe('parse(string)', function () { it.each(['.', '_', '~', '-', '!'])( 'should reject embedded %j', function (character) { - assert.strictEqual(parse(`Basic Zm9v${character}OmJhcg==`), undefined); + expect(parse(`Basic Zm9v${character}OmJhcg==`)).toEqual(undefined); }, ); it.each(['77-_Og==', '77-9Og==', '77+/Og==!'])( 'should reject %j', function (credentials) { - assert.strictEqual(parse(`Basic ${credentials}`), undefined); + expect(parse(`Basic ${credentials}`)).toEqual(undefined); }, ); }); describe('with standard base64 alphabet symbols', function () { it('should accept plus and slash', function () { - assert.deepEqual(parse('Basic 77+/Og=='), { + expect(parse('Basic 77+/Og==')).toEqual({ name: '\uffff', pass: '', }); @@ -53,65 +54,92 @@ describe('parse(string)', function () { describe('with valid credentials', function () { it('should return .name and .pass', function () { - var creds = parse('basic Zm9vOmJhcg=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, 'bar'); + expect(parse('basic Zm9vOmJhcg==')).toEqual({ name: 'foo', pass: 'bar' }); + }); + }); + + describe('with unpadded credentials', () => { + it.each([ + ['Zm9vOg', 'foo', ''], + ['Zm9vOmI', 'foo', 'b'], + ['Zm9vOmJh', 'foo', 'ba'], + ])('accepts %j', (encoded, name, pass) => { + expect(parse(`Basic ${encoded}`)).toEqual({ name, pass }); }); }); describe('with empty password', function () { it('should return .name and .pass', function () { - var creds = parse('basic Zm9vOg=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, ''); + expect(parse('basic Zm9vOg==')).toEqual({ name: 'foo', pass: '' }); }); }); describe('with empty userid', function () { it('should return .name and .pass', function () { - var creds = parse('basic OnBhc3M='); - assert.strictEqual(creds?.name, ''); - assert.strictEqual(creds?.pass, 'pass'); + expect(parse('basic OnBhc3M=')).toEqual({ name: '', pass: 'pass' }); }); }); describe('with empty userid and pass', function () { it('should return .name and .pass', function () { - var creds = parse('basic Og=='); - assert.strictEqual(creds?.name, ''); - assert.strictEqual(creds?.pass, ''); + expect(parse('basic Og==')).toEqual({ name: '', pass: '' }); }); }); describe('with colon in pass', function () { it('should return .name and .pass', function () { - var creds = parse('basic Zm9vOnBhc3M6d29yZA=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, 'pass:word'); + expect(parse('basic Zm9vOnBhc3M6d29yZA==')).toEqual({ + name: 'foo', + pass: 'pass:word', + }); }); }); describe('with scheme "Basic"', function () { it('should return .name and .pass', function () { - var creds = parse('Basic Zm9vOmJhcg=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, 'bar'); + expect(parse('Basic Zm9vOmJhcg==')).toEqual({ name: 'foo', pass: 'bar' }); }); }); describe('with scheme "BASIC"', function () { it('should return .name and .pass', function () { - var creds = parse('BASIC Zm9vOmJhcg=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, 'bar'); + expect(parse('BASIC Zm9vOmJhcg==')).toEqual({ name: 'foo', pass: 'bar' }); }); }); describe('with scheme "BaSiC"', function () { it('should return .name and .pass', function () { - var creds = parse('BaSiC Zm9vOmJhcg=='); - assert.strictEqual(creds?.name, 'foo'); - assert.strictEqual(creds?.pass, 'bar'); + expect(parse('BaSiC Zm9vOmJhcg==')).toEqual({ name: 'foo', pass: 'bar' }); + }); + }); +}); + +describe('base64 structure', () => { + beforeEach(() => { + vi.spyOn(base64, 'decode').mockImplementation(() => { + throw new Error('Malformed base64 must be rejected before decoding'); }); }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + 'Y', + 'Zm9vOmJhcgYYY', + 'Zm9vOg=', + 'Zm9vOg===', + 'Zm9vOg====', + 'Zm9vOg======', + 'Zm9vOg========', + 'Zm9vOmJh========', + 'Zm9vOmI==', + 'Zm9vOmJh=', + 'Zm9vOmJh====', + 'Zm9v=OmJhcg==', + ])('rejects malformed base64 %j', (encoded) => { + expect(parse(`Basic ${encoded}`)).toEqual(undefined); + expect(base64.decode).not.toHaveBeenCalled(); + }); }); From e58e956ea78617665859e3a253918a12f02fc174 Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Wed, 4 Nov 2026 09:19:12 -0800 Subject: [PATCH 3/3] Move into util --- src/index.ts | 28 +++------------------------- src/utils.spec.ts | 35 +++++++++++++++++++++++++++++++++++ src/utils.ts | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 src/utils.spec.ts create mode 100644 src/utils.ts diff --git a/src/index.ts b/src/index.ts index 8b259bb..acce804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ */ import { base64 } from './base64.js'; +import { parseBasic } from './utils.js'; /** * Object to represent user credentials. @@ -29,20 +30,8 @@ export function parse(string: string): Credentials | undefined { throw new TypeError('Expected a string'); } - // parse header - const match = CREDENTIALS_REGEXP.exec(string); - if (!match) return undefined; - - // Allow unpadded base64, but reject partial groups and malformed padding. - const encoded = match[1]; - const length = encoded.length; - if ( - length % 4 === 1 || - (encoded.charCodeAt(length - 1) === 61 && - (length % 4 !== 0 || encoded.charCodeAt(length - 3) === 61)) - ) { - return undefined; - } + const encoded = parseBasic(string); + if (encoded === undefined) return undefined; // decode user pass const userPass = base64.decode(encoded); @@ -90,17 +79,6 @@ export function format(credentials: Credentials): string { return 'Basic ' + base64.encode(str); } -/** - * RegExp for basic auth credentials - * - * credentials = auth-scheme 1*SP base64 - * auth-scheme = "Basic" ; case insensitive - * base64 = 1*( ALPHA / DIGIT / "+" / "/" ) *"=" - * @private - */ - -const CREDENTIALS_REGEXP = /^ *Basic +([a-z0-9+/]+=*) *$/i; - /** * RegExp for RFC 5234 CTL characters (US-ASCII 0-31 and 127). * @private diff --git a/src/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 0000000..eebd554 --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { parseBasic } from './utils.js'; + +describe('parseBasic', () => { + it.each([ + ['Basic Og==', 'Og=='], + [' bAsIc Zm9vOmI= ', 'Zm9vOmI='], + ['Basic Zm9vOg', 'Zm9vOg'], + ['Basic Zm9vOmI', 'Zm9vOmI'], + ['Basic Zm9vOmJh', 'Zm9vOmJh'], + ['Basic 77+/Og==', '77+/Og=='], + ])('extracts %j', (header, encoded) => { + expect(parseBasic(header)).toBe(encoded); + }); + + it.each([ + '', + 'Basic ', + 'Basic ==== ', + 'Basic Y', + 'Basic Zm9vOg=', + 'Basic Zm9vOmI==', + 'Basic Zm9vOmJh====', + 'Basic Zm9vOg==' + '='.repeat(100_000), + 'Basic\tOg==', + '\tBasic Og==', + 'Basic Og==\n', + 'Basic Og==\r\n', + 'Basic Og==\t', + 'Basic Og== extra', + 'Bearer Og==', + ])('rejects invalid header %#', (header) => { + expect(parseBasic(header)).toBeUndefined(); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..29a4338 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,21 @@ +/** + * Extract a standard base64 token from a Basic authorization header. + */ +export function parseBasic(value: string): string | undefined { + const match = BASIC_REGEXP.exec(value); + if (!match) return undefined; + + const encoded = match[1]; + const length = encoded.length; + if ( + length % 4 === 1 || + (encoded.charCodeAt(length - 1) === 61 && length % 4 !== 0) + ) { + return undefined; + } + + return encoded; +} + +// Bound padding to avoid scanning arbitrarily long runs of '='. +const BASIC_REGEXP = /^ *Basic +([a-z0-9+/]+={0,2}) *$/i;