mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
fix: tolerate padded auth base64 (#11694)
* fix: tolerate padded auth base64 * fix: avoid regex in auth padding normalization
This commit is contained in:
committed by
GitHub
parent
fcf95c7faa
commit
020ac45d3d
6
.changeset/clear-password-padding.md
Normal file
6
.changeset/clear-password-padding.md
Normal 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.
|
||||
@@ -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(['$', '%', '`', '"', "'"])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user