fix: tolerate padded auth base64 (#11694)

* fix: tolerate padded auth base64

* fix: avoid regex in auth padding normalization
This commit is contained in:
Sean Kenneth Doherty
2026-05-17 06:24:24 -05:00
committed by GitHub
parent fcf95c7faa
commit 020ac45d3d
4 changed files with 88 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": patch
"pnpm": patch
---
Allow redundant trailing base64 padding in `.npmrc` auth values and report invalid auth base64 with a pnpm error.

View File

@@ -59,7 +59,7 @@ function parseBasicAuth ({
authPassword,
}: Pick<RawCreds, 'authPairBase64' | 'authUsername' | 'authPassword'>): BasicAuth | undefined {
if (authPairBase64) {
const pair = atob(authPairBase64)
const pair = decodeBase64Credential(authPairBase64, '_auth')
const colonIndex = pair.indexOf(':')
if (colonIndex < 0) {
throw new AuthMissingSeparatorError()
@@ -70,12 +70,44 @@ function parseBasicAuth ({
}
if (authUsername && authPassword) {
return { username: authUsername, password: atob(authPassword) }
return { username: authUsername, password: decodeBase64Credential(authPassword, '_password') }
}
return undefined
}
function decodeBase64Credential (value: string, key: '_auth' | '_password'): string {
try {
return atob(value)
} catch {
const normalizedValue = normalizeBase64Padding(value)
if (normalizedValue !== value) {
try {
return atob(normalizedValue)
} catch {}
}
throw new AuthBase64DecodeError(key)
}
}
function normalizeBase64Padding (value: string): string {
let paddingStart = value.length
while (paddingStart > 0 && value[paddingStart - 1] === '=') {
paddingStart--
}
const valueWithoutPadding = value.slice(0, paddingStart)
if (!valueWithoutPadding) return value
const remainder = valueWithoutPadding.length % 4
if (remainder === 1) return value
return valueWithoutPadding.padEnd(
valueWithoutPadding.length + (4 - remainder) % 4,
'='
)
}
export class AuthMissingSeparatorError extends PnpmError {
constructor () {
super('AUTH_MISSING_SEPARATOR', 'No separator found in the decoded form of _auth', {
@@ -84,6 +116,14 @@ export class AuthMissingSeparatorError extends PnpmError {
}
}
export class AuthBase64DecodeError extends PnpmError {
constructor (key: '_auth' | '_password') {
super('AUTH_INVALID_BASE64', `Failed to decode ${key} as base64`, {
hint: `${key} must contain a base64-encoded ${key === '_auth' ? '<username>:<password>' : 'password'} value`,
})
}
}
/** Characters reserved for more advanced features in the future. */
const RESERVED_CHARACTERS = new Set(['$', '%', '`', '"', "'"])

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from '@jest/globals'
import {
AuthBase64DecodeError,
AuthMissingSeparatorError,
type Creds,
parseCreds,
@@ -52,6 +53,23 @@ describe('parseCreds', () => {
})).toThrow(new AuthMissingSeparatorError())
})
test('authPairBase64 allows redundant trailing padding', () => {
expect(parseCreds({
authPairBase64: `${btoa('foo:bar')}=`,
})).toStrictEqual({
basicAuth: {
username: 'foo',
password: 'bar',
},
} as Creds)
})
test('authPairBase64 must be base64', () => {
expect(() => parseCreds({
authPairBase64: 'foo*bar',
})).toThrow(new AuthBase64DecodeError('_auth'))
})
test('authUsername and authPassword', () => {
expect(parseCreds({
authUsername: 'foo',
@@ -72,6 +90,25 @@ describe('parseCreds', () => {
})).toBeUndefined()
})
test('authPassword allows redundant trailing padding', () => {
expect(parseCreds({
authUsername: 'foo',
authPassword: `${btoa('bar')}=`,
})).toStrictEqual({
basicAuth: {
username: 'foo',
password: 'bar',
},
} as Creds)
})
test('authPassword must be base64', () => {
expect(() => parseCreds({
authUsername: 'foo',
authPassword: 'bar*baz',
})).toThrow(new AuthBase64DecodeError('_password'))
})
test('tokenHelper', () => {
expect(parseCreds({
tokenHelper: 'example-token-helper --foo --bar baz',

View File

@@ -349,6 +349,9 @@ fn base64_decode_covers_every_alphabet_branch() {
assert_eq!(base64_decode("fn5+").as_deref(), Some("~~~"));
// `=` padding short-circuits the loop on a 2-byte input.
assert_eq!(base64_decode("aGk=").as_deref(), Some("hi"));
// Redundant trailing padding is ignored, matching pnpm's tolerant
// credential decoder.
assert_eq!(base64_decode("aGk===").as_deref(), Some("hi"));
// Invalid byte returns None so the parser keeps the raw
// value verbatim. `*` is not in the alphabet.
assert_eq!(base64_decode("not*base64"), None);