From 0051d85ec7b4043760f2865540c1fd1129b53247 Mon Sep 17 00:00:00 2001 From: maximilliangrand <214999687+maximilliangrand@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:08:08 +0200 Subject: [PATCH] fix: reject interior padding in rfc4648 decode The padded codecs (base64pad, base64urlpad, base32pad, base32hexpad and the upper variants) accepted a '=' in the middle of a string and decoded it to bogus bytes instead of rejecting it. createAlphabetIdx indexed the trailing '=' of the padded alphabets, giving it a symbol value, and decode strips only trailing '=', so an interior '=' was folded into the output as data. Skip '=' when building the alphabet index. Trailing padding is handled by the strip loop in decode and the pad flag in encode, so any non-trailing '=' is now rejected as a non-base character. Valid input round-trips unchanged. --- src/bases/base.ts | 8 ++++++++ test/test-multibase.spec.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/bases/base.ts b/src/bases/base.ts index 54cea234..53bb8bda 100644 --- a/src/bases/base.ts +++ b/src/bases/base.ts @@ -218,6 +218,14 @@ function createAlphabetIdx (alphabet: string, caseInsensitive: boolean): Record< // Build the character lookup table: const alphabetIdx: Record = {} for (let i = 0; i < alphabet.length; ++i) { + // The trailing '=' of a padded alphabet is a padding marker, not a data + // symbol. Skip it so an interior '=' has no index and is rejected as a + // non-base character in decode, instead of being folded into the output as + // bogus data. Trailing padding is stripped by decode before the lookup, so + // valid input round-trips unchanged. + if (alphabet[i] === '=') { + continue + } alphabetIdx[alphabet[i]] = i // For case-insensitive codecs, map the opposite case to the same index so // differently cased input decodes without errors (multibase spec). diff --git a/test/test-multibase.spec.ts b/test/test-multibase.spec.ts index de1bc596..5931c1a2 100644 --- a/test/test-multibase.spec.ts +++ b/test/test-multibase.spec.ts @@ -199,6 +199,20 @@ describe('multibase', () => { assert.throws(() => base64.decode(b64.substring(0, b64.length - 1)), 'Unexpected end of data') }) + it('rejects interior padding (RFC 4648 section 3.2)', () => { + const padded = { ...b32, ...b64 } + for (const base of [padded.base64pad, padded.base64urlpad, padded.base32pad, padded.base32hexpad]) { + const value = Uint8Array.from([0x41, 0x42]) + // A valid encoding, with legitimate trailing padding, still round-trips. + const encoded = base.encode(value) + assert.deepStrictEqual(base.decode(encoded), value) + // A '=' spliced into the data run (padding may only be a trailing run) is + // invalid and must be rejected, not silently decoded to bogus bytes. + const tampered = encoded.slice(0, 2) + '=' + encoded.slice(2) + assert.throws(() => base.decode(tampered), `Non-${base.name} character`) + } + }) + it('infers prefix and name correctly', () => { const name = base32.name