fix: block untrusted request destination env expansion (#12291)

Fixes CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r by making environment expansion trust-aware for registry/auth config and request destinations.

- Stops project `.npmrc` from expanding `${...}` placeholders in registry/proxy request destinations, URL-scoped keys, and registry credential values.
- Stops repository-controlled `pnpm-workspace.yaml` from expanding `${...}` placeholders in request destinations: `registry`, `registries`, `namedRegistries`, and `pnprServer`.
- Preserves env expansion for trusted user/global/auth.ini/CLI/global config/env config so existing token, registry, and pnpr server setup flows continue to work.
- Ports the same trust boundary to pacquet for dependency-management commands.
This commit is contained in:
Zoltan Kochan
2026-06-09 22:29:15 +02:00
committed by GitHub
parent 230df57aa5
commit 1017c36776
15 changed files with 1018 additions and 145 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/config.reader": minor
"pnpm": minor
---
Stopped expanding environment variables in repository-controlled registry/proxy request destinations and registry credential values from `.npmrc`, and in workspace registry URLs from `pnpm-workspace.yaml`. Move dynamic registry URL and token configuration to trusted user, global, CLI, or environment config.

View File

@@ -26,16 +26,36 @@ export type OptionsFromRootManifest = {
requiredScripts?: string[]
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'pnprServer' | 'updateConfig'>
export function getOptionsFromPnpmSettings (manifestDir: string | undefined, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
const settings: OptionsFromRootManifest = replaceEnvInSettings(pnpmSettings)
interface GetOptionsFromPnpmSettingsOptions {
manifest?: ProjectManifest
expandRequestDestinationEnv?: boolean
}
interface ReplaceEnvInSettingsOptions {
expandRequestDestinationEnv: boolean
}
const REQUEST_DESTINATION_SCALAR_KEYS = new Set(['pnprServer', 'registry'])
export function getOptionsFromPnpmSettings (
manifestDir: string | undefined,
pnpmSettings: PnpmSettings,
manifestOrOpts?: ProjectManifest | GetOptionsFromPnpmSettingsOptions
): OptionsFromRootManifest {
const opts = isGetOptionsFromPnpmSettingsOptions(manifestOrOpts)
? manifestOrOpts
: manifestOrOpts == null ? {} : { manifest: manifestOrOpts }
const settings: OptionsFromRootManifest = replaceEnvInSettings(pnpmSettings, {
expandRequestDestinationEnv: opts.expandRequestDestinationEnv ?? false,
})
if (settings.overrides) {
assertValidOverrides(settings.overrides)
if (Object.keys(settings.overrides).length === 0) {
delete settings.overrides
} else {
warnAboutDeprecatedVersionReferences(settings.overrides)
if (manifest) {
settings.overrides = mapValues(createVersionReferencesReplacer(manifest), settings.overrides)
if (opts.manifest) {
settings.overrides = mapValues(createVersionReferencesReplacer(opts.manifest), settings.overrides)
}
}
}
@@ -50,6 +70,12 @@ export function getOptionsFromPnpmSettings (manifestDir: string | undefined, pnp
return settings
}
function isGetOptionsFromPnpmSettingsOptions (
value: ProjectManifest | GetOptionsFromPnpmSettingsOptions | undefined
): value is GetOptionsFromPnpmSettingsOptions {
return value != null && ('expandRequestDestinationEnv' in value || 'manifest' in value)
}
function assertValidOverrides (overrides: unknown): asserts overrides is Record<string, string> {
if (overrides == null || typeof overrides !== 'object' || Array.isArray(overrides)) {
throw new PnpmError('INVALID_OVERRIDES', `The overrides field should be an object, but got ${renderReceivedType(overrides)}`)
@@ -67,19 +93,21 @@ function renderReceivedType (value: unknown): string {
return typeof value
}
function replaceEnvInSettings (settings: PnpmSettings): PnpmSettings {
function replaceEnvInSettings (
settings: PnpmSettings,
opts: ReplaceEnvInSettingsOptions
): PnpmSettings {
const newSettings: PnpmSettings = {}
for (const [key, value] of Object.entries(settings)) {
const newKey = envReplace(key, process.env)
if (typeof value === 'string') {
if (REQUEST_DESTINATION_SCALAR_KEYS.has(newKey) && !opts.expandRequestDestinationEnv && hasEnvPlaceholder(value)) continue
// @ts-expect-error
newSettings[newKey as keyof PnpmSettings] = envReplace(value, process.env)
} else if (newKey === 'registries' || newKey === 'namedRegistries') {
// Registry URL maps in workspace yaml must support `${VAR}` substitution
// in their values so users can reuse the same env-var pattern they use
// in `.npmrc`. Only these keys are treated this way to avoid surprising
// behavior on unrelated object-valued settings.
newSettings[newKey as keyof PnpmSettings] = replaceEnvInStringValues(value) as never
newSettings[newKey as keyof PnpmSettings] = (opts.expandRequestDestinationEnv
? replaceEnvInStringValues(value)
: copyStringValuesWithoutEnvPlaceholders(value)) as never
} else {
newSettings[newKey as keyof PnpmSettings] = value
}
@@ -96,6 +124,20 @@ function replaceEnvInStringValues (value: unknown): unknown {
return out
}
function copyStringValuesWithoutEnvPlaceholders (value: unknown): unknown {
if (value == null || typeof value !== 'object' || Array.isArray(value)) return value
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof v === 'string' && hasEnvPlaceholder(v)) continue
out[k] = v
}
return out
}
function hasEnvPlaceholder (value: string): boolean {
return /\$\{[^}]+\}/.test(value)
}
function warnAboutDeprecatedVersionReferences (overrides: Record<string, string>): void {
const selectors = Object.keys(overrides).filter((selector) => overrides[selector][0] === '$')
if (selectors.length === 0) return

View File

@@ -311,6 +311,7 @@ export async function getConfig (opts: {
}
addSettingsFromWorkspaceManifestToConfig(pnpmConfig, {
configFromCliOpts,
expandRequestDestinationEnv: true,
projectManifest: undefined,
workspaceDir: undefined,
workspaceManifest: globalYamlConfig,
@@ -322,6 +323,9 @@ export async function getConfig (opts: {
...networkConfigs.registries,
}
pnpmConfig.registries = { ...registriesFromNpmrc }
if (explicitlySetKeys.has('registry') && typeof pnpmConfig.registry === 'string') {
pnpmConfig.registries.default = normalizeRegistryUrl(pnpmConfig.registry)
}
pnpmConfig.configByUri = { ...networkConfigs.configByUri }
// tokenHelper must only come from user-level config (~/.npmrc or global auth.ini),
@@ -874,16 +878,18 @@ function getNodeVersionFromEnginesRuntime (manifest: ProjectManifest): string |
function addSettingsFromWorkspaceManifestToConfig (pnpmConfig: Config & ConfigContext, {
configFromCliOpts,
expandRequestDestinationEnv,
projectManifest,
workspaceManifest,
workspaceDir,
}: {
configFromCliOpts: Record<string, unknown>
expandRequestDestinationEnv?: boolean
projectManifest: ProjectManifest | undefined
workspaceDir: string | undefined
workspaceManifest: WorkspaceManifest
}): void {
const newSettings = Object.assign(getOptionsFromPnpmSettings(workspaceDir, workspaceManifest, projectManifest), configFromCliOpts)
const newSettings = Object.assign(getOptionsFromPnpmSettings(workspaceDir, workspaceManifest, { manifest: projectManifest, expandRequestDestinationEnv }), configFromCliOpts)
for (const [key, value] of Object.entries(newSettings)) {
if (!isCamelCase(key)) continue

View File

@@ -44,6 +44,11 @@ export interface LoadNpmrcConfigOpts {
env?: Record<string, string | undefined>
}
interface ReadAndFilterNpmrcOptions {
expandAuthValueEnv?: boolean
expandRequestDestinationEnv?: boolean
}
export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
const warnings: string[] = []
const env = opts.env ?? process.env as Record<string, string | undefined>
@@ -59,7 +64,8 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
const workspaceNpmrc = readAndFilterNpmrc(
path.resolve(workspaceNpmrcDir, '.npmrc'),
warnings,
env
env,
{ expandAuthValueEnv: false, expandRequestDestinationEnv: false }
)
// Read user .npmrc (from npmrcAuthFile setting or ~/.npmrc)
@@ -161,7 +167,8 @@ const UNSCOPED_RESCOPABLE_KEYS = [
function readAndFilterNpmrc (
filePath: string,
warnings: string[],
env: Record<string, string | undefined>
env: Record<string, string | undefined>,
opts: ReadAndFilterNpmrcOptions = {}
): Record<string, unknown> {
let raw: Record<string, unknown>
try {
@@ -176,12 +183,38 @@ function readAndFilterNpmrc (
const npmrcDir = path.dirname(filePath)
const result: Record<string, unknown> = {}
const expandAuthValueEnv = opts.expandAuthValueEnv ?? true
const expandRequestDestinationEnv = opts.expandRequestDestinationEnv ?? true
for (const [rawKey, rawValue] of Object.entries(raw)) {
// Apply ${VAR} substitution to both keys and values
if (!expandRequestDestinationEnv && hasEnvPlaceholder(rawKey) && isRequestDestinationKey(rawKey)) {
warnIgnoredRequestDestinationEnv(filePath, rawKey, warnings)
continue
}
if (!expandAuthValueEnv && hasEnvPlaceholder(rawKey) && isAuthValueKey(rawKey)) {
warnIgnoredAuthValueEnv(filePath, rawKey, warnings)
continue
}
const key = substituteEnv(rawKey, env, warnings)
let value: unknown = typeof rawValue === 'string'
? substituteEnv(rawValue, env, warnings)
: rawValue
if (!expandRequestDestinationEnv && hasEnvPlaceholder(rawKey) && isRequestDestinationKey(key)) {
warnIgnoredRequestDestinationEnv(filePath, rawKey, warnings)
continue
}
if (!expandAuthValueEnv && hasEnvPlaceholder(rawKey) && isAuthValueKey(key)) {
warnIgnoredAuthValueEnv(filePath, rawKey, warnings)
continue
}
let value: unknown = rawValue
if (typeof rawValue === 'string') {
if (!expandRequestDestinationEnv && hasEnvPlaceholder(rawValue) && isRequestDestinationValueKey(key)) {
warnIgnoredRequestDestinationEnv(filePath, key, warnings)
continue
}
if (!expandAuthValueEnv && hasEnvPlaceholder(rawValue) && isAuthValueKey(key)) {
warnIgnoredAuthValueEnv(filePath, key, warnings)
continue
}
value = substituteEnv(rawValue, env, warnings)
}
// Only keep auth/registry related keys
if (isNpmrcReadableKey(key)) {
@@ -197,6 +230,37 @@ function readAndFilterNpmrc (
return rescopeUnscopedCreds(result, filePath, warnings)
}
function isRequestDestinationKey (key: string): boolean {
return isRegistryKey(key) || key.startsWith('//')
}
function isRequestDestinationValueKey (key: string): boolean {
return isRegistryKey(key) || key === 'https-proxy' || key === 'http-proxy' || key === 'proxy'
}
function isRegistryKey (key: string): boolean {
return key === 'registry' || (key.startsWith('@') && key.endsWith(':registry'))
}
const AUTH_VALUE_KEYS = ['_authToken', '_auth', '_password', 'username', 'tokenHelper', 'cert', 'key'] as const
const AUTH_VALUE_KEY_SUFFIXES = AUTH_VALUE_KEYS.map(key => `:${key}`)
function isAuthValueKey (key: string): boolean {
return (AUTH_VALUE_KEYS as readonly string[]).includes(key) || AUTH_VALUE_KEY_SUFFIXES.some(suffix => key.endsWith(suffix))
}
function hasEnvPlaceholder (value: string): boolean {
return /\$\{[^}]+\}/.test(value)
}
function warnIgnoredRequestDestinationEnv (filePath: string, key: string, warnings: string[]): void {
warnings.push(`Ignored project-level request destination "${key}" in "${filePath}": environment variables are not expanded in repository-controlled registry or proxy URLs.`)
}
function warnIgnoredAuthValueEnv (filePath: string, key: string, warnings: string[]): void {
warnings.push(`Ignored project-level auth setting "${key}" in "${filePath}": environment variables are not expanded in repository-controlled registry credentials.`)
}
// Rewrite any unscoped per-registry keys in `source` to their URL-scoped
// equivalents (`//host[:port]/path/:<key>=...`) using `source.registry` —
// or the builtin default registry if the source doesn't declare its own.

View File

@@ -17,7 +17,7 @@ test('getOptionsFromPnpmSettings() replaces env variables in settings', () => {
expect(options.foo).toBe('bar')
})
test('getOptionsFromPnpmSettings() expands env variables inside registries values', () => {
test('getOptionsFromPnpmSettings() ignores env variables inside registries values', () => {
process.env.PNPM_TEST_TOKEN = 'secret'
const options = getOptionsFromPnpmSettings(process.cwd(), {
registries: {
@@ -25,17 +25,57 @@ test('getOptionsFromPnpmSettings() expands env variables inside registries value
'@scope': 'https://registry.example.com/${PNPM_TEST_TOKEN}/',
},
}) as any // eslint-disable-line
expect(options.registries['@scope']).toBe('https://registry.example.com/secret/')
expect(options.registries).toStrictEqual({
default: 'https://registry.npmjs.org/',
})
})
test('getOptionsFromPnpmSettings() expands env variables inside namedRegistries values', () => {
test('getOptionsFromPnpmSettings() ignores env variables inside namedRegistries values', () => {
process.env.PNPM_TEST_HOST = 'work.example.com'
const options = getOptionsFromPnpmSettings(process.cwd(), {
namedRegistries: {
work: 'https://${PNPM_TEST_HOST}/npm/',
},
} as any) as any // eslint-disable-line
expect(options.namedRegistries.work).toBe('https://work.example.com/npm/')
expect(options.namedRegistries).toStrictEqual({})
})
test('getOptionsFromPnpmSettings() ignores env variables inside registry setting', () => {
process.env.PNPM_TEST_HOST = 'registry.example.com'
const options = getOptionsFromPnpmSettings(process.cwd(), {
registry: 'https://${PNPM_TEST_HOST}/npm/',
} as any) as any // eslint-disable-line
expect(options.registry).toBeUndefined()
})
test('getOptionsFromPnpmSettings() ignores env variables inside pnprServer setting', () => {
process.env.PNPM_TEST_HOST = 'registry.example.com'
const options = getOptionsFromPnpmSettings(process.cwd(), {
pnprServer: 'https://${PNPM_TEST_HOST}/pnpr/',
} as any) as any // eslint-disable-line
expect(options.pnprServer).toBeUndefined()
})
test('getOptionsFromPnpmSettings() may expand env variables inside trusted request destinations', () => {
process.env.PNPM_TEST_HOST = 'registry.example.com'
const options = getOptionsFromPnpmSettings(process.cwd(), {
pnprServer: 'https://${PNPM_TEST_HOST}/pnpr/',
registry: 'https://${PNPM_TEST_HOST}/npm/',
registries: {
'@scope': 'https://${PNPM_TEST_HOST}/scope/',
},
namedRegistries: {
work: 'https://${PNPM_TEST_HOST}/work/',
},
} as any, { expandRequestDestinationEnv: true }) as any // eslint-disable-line
expect(options.pnprServer).toBe('https://registry.example.com/pnpr/')
expect(options.registry).toBe('https://registry.example.com/npm/')
expect(options.registries).toStrictEqual({
'@scope': 'https://registry.example.com/scope/',
})
expect(options.namedRegistries).toStrictEqual({
work: 'https://registry.example.com/work/',
})
})
test('getOptionsFromPnpmSettings() converts allowBuilds', () => {

View File

@@ -607,6 +607,151 @@ test('registries in current directory\'s .npmrc have bigger priority then global
})
})
test('project .npmrc does not expand env variables in registry URLs', async () => {
prepare()
fs.writeFileSync('.npmrc', 'registry=https://registry.example.com/${PNPM_TEST_TOKEN}/\n', 'utf8')
const { config, warnings } = await getConfig({
cliOptions: {},
env: { ...env, PNPM_TEST_TOKEN: 'secret' },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.registries.default).not.toBe('https://registry.example.com/secret/')
expect(JSON.stringify(config.registries)).not.toContain('secret')
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level request destination "registry"'),
]))
})
test('project .npmrc does not expand env variables in scoped registry URLs or URL-scoped keys', async () => {
prepare()
fs.writeFileSync('.npmrc', [
'@scope:registry=https://registry.example.com/${PNPM_TEST_TOKEN}/',
'//registry.example.com/${PNPM_TEST_TOKEN}/:_authToken=token',
'',
].join('\n'), 'utf8')
const { config, warnings } = await getConfig({
cliOptions: {},
env: { ...env, PNPM_TEST_TOKEN: 'secret' },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.registries['@scope']).toBeUndefined()
expect(Object.keys(config.authConfig).join('\n')).not.toContain('secret')
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level request destination "@scope:registry"'),
expect.stringContaining('Ignored project-level request destination "//registry.example.com/${PNPM_TEST_TOKEN}/:_authToken"'),
]))
})
test('project .npmrc does not expand env variables in auth values', async () => {
prepare()
fs.writeFileSync('.npmrc', [
'registry=https://attacker.example/',
'//attacker.example/:_authToken=${PNPM_TEST_TOKEN}',
'//attacker.example/:cert=${PNPM_TEST_CERT}',
'//attacker.example/:key=${PNPM_TEST_KEY}',
'_authToken=${PNPM_TEST_TOKEN}',
'username=${PNPM_TEST_USER}',
'_password=${PNPM_TEST_PASSWORD}',
'cert=${PNPM_TEST_CERT}',
'key=${PNPM_TEST_KEY}',
'',
].join('\n'), 'utf8')
const { config, warnings } = await getConfig({
cliOptions: {},
env: {
...env,
PNPM_TEST_CERT: 'secret-cert',
PNPM_TEST_KEY: 'secret-key',
PNPM_TEST_PASSWORD: Buffer.from('secret').toString('base64'),
PNPM_TEST_TOKEN: 'secret-token',
PNPM_TEST_USER: 'secret-user',
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
const serializedAuthConfig = JSON.stringify(config.authConfig)
expect(serializedAuthConfig).not.toContain('secret-token')
expect(serializedAuthConfig).not.toContain('secret-user')
expect(serializedAuthConfig).not.toContain('secret-cert')
expect(serializedAuthConfig).not.toContain('secret-key')
expect(config.configByUri?.['//attacker.example/']?.creds).toBeUndefined()
expect(config.configByUri?.['//attacker.example/']?.tls).toBeUndefined()
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level auth setting "//attacker.example/:_authToken"'),
expect.stringContaining('Ignored project-level auth setting "//attacker.example/:cert"'),
expect.stringContaining('Ignored project-level auth setting "//attacker.example/:key"'),
expect.stringContaining('Ignored project-level auth setting "_authToken"'),
expect.stringContaining('Ignored project-level auth setting "cert"'),
expect.stringContaining('Ignored project-level auth setting "key"'),
]))
})
test('project .npmrc does not expand env variables in proxy URLs', async () => {
prepare()
fs.writeFileSync('.npmrc', [
'https-proxy=http://proxy.example.com/${PNPM_TEST_TOKEN}/',
'http-proxy=http://proxy.example.com/${PNPM_TEST_TOKEN}/',
'proxy=http://legacy-proxy.example.com/${PNPM_TEST_TOKEN}/',
'',
].join('\n'), 'utf8')
const { config, warnings } = await getConfig({
cliOptions: {},
env: { ...env, PNPM_TEST_TOKEN: 'secret' },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.httpsProxy).toBeUndefined()
expect(config.httpProxy).toBeUndefined()
expect(JSON.stringify(config)).not.toContain('secret')
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level request destination "https-proxy"'),
expect.stringContaining('Ignored project-level request destination "http-proxy"'),
expect.stringContaining('Ignored project-level request destination "proxy"'),
]))
})
test('user .npmrc may expand env variables in registry URLs', async () => {
prepare()
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), 'registry=https://registry.example.com/${PNPM_TEST_TOKEN}/\n', 'utf8')
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
},
env: { ...env, PNPM_TEST_TOKEN: 'secret' },
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.registries.default).toBe('https://registry.example.com/secret/')
})
test('pnpm-workspace.yaml registries override the same scope from .npmrc (#11492)', async () => {
prepareEmpty()
@@ -645,6 +790,34 @@ test('pnpm-workspace.yaml registries.default is reflected in config.registry (#1
expect(config.registries.default).toBe('https://private.example.com/')
})
test('pnpm-workspace.yaml request destinations do not expand env variables', async () => {
prepareEmpty()
writeYamlFileSync('pnpm-workspace.yaml', {
pnprServer: 'https://${PNPM_TEST_TOKEN}.evil.example/',
registries: {
default: 'https://private.example.com/${PNPM_TEST_TOKEN}/',
'@scope': 'https://scope.example.com/${PNPM_TEST_TOKEN}/',
},
namedRegistries: {
work: 'https://work.example.com/${PNPM_TEST_TOKEN}/',
},
})
const { config } = await getConfig({
cliOptions: {},
env: { ...env, PNPM_TEST_TOKEN: 'secret' },
packageManager: { name: 'pnpm', version: '1.0.0' },
workspaceDir: process.cwd(),
})
expect(config.registries.default).not.toBe('https://private.example.com/secret/')
expect(config.registries['@scope']).toBeUndefined()
expect(config.namedRegistries).toStrictEqual({})
expect(config.pnprServer).toBeUndefined()
expect(JSON.stringify(config)).not.toContain('secret')
})
test('CLI --registry overrides pnpm-workspace.yaml registries.default (#10099)', async () => {
prepareEmpty()
@@ -752,12 +925,14 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
// and rejects the publish.
let originalXdg: string | undefined
let configHome: string
let userconfig: string
beforeEach(() => {
prepareEmpty()
fs.writeFileSync('.npmrc', '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n', 'utf8')
fs.writeFileSync('.npmrc', '', 'utf8')
fs.mkdirSync('user-home')
fs.writeFileSync(path.resolve('user-home', '.npmrc'), '', 'utf8')
userconfig = path.resolve('user-home', '.npmrc')
fs.writeFileSync(userconfig, '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n', 'utf8')
// Isolate from the developer's real ~/.config/pnpm/auth.ini, which on a maintainer's
// machine often contains a working npm token that would otherwise satisfy the assertion.
configHome = path.resolve('xdg-config')
@@ -777,7 +952,7 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
test('drops the placeholder when the env var is unset', async () => {
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
userconfig,
},
env: { ...env, XDG_CONFIG_HOME: configHome }, // NODE_AUTH_TOKEN intentionally unset
packageManager: {
@@ -793,7 +968,7 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
test('substitutes normally when the env var is set', async () => {
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
userconfig,
},
env: { ...env, XDG_CONFIG_HOME: configHome, NODE_AUTH_TOKEN: 'real-token' },
packageManager: {
@@ -812,14 +987,14 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
// but the other two must still expand. Guards against the original implementation
// that stripped every `${...}` on any substitution failure.
fs.writeFileSync(
'.npmrc',
userconfig,
'//registry.test/:_authToken=${SET}-${UNSET}-${DEFAULTED-fallback}\n',
'utf8'
)
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
userconfig,
},
env: { ...env, XDG_CONFIG_HOME: configHome, SET: 'AAA' }, // UNSET, DEFAULTED unset
packageManager: {
@@ -837,14 +1012,14 @@ describe('unresolved ${VAR} placeholders in .npmrc auth values', () => {
// `{ KEY: undefined }` to model an unset variable. `${VAR-default}` must then
// resolve to `default`, matching the `Record<string, string | undefined>` contract.
fs.writeFileSync(
'.npmrc',
userconfig,
'//registry.test/:_authToken=${EXPLICIT_UNDEF-fallback}\n',
'utf8'
)
const { config } = await getConfig({
cliOptions: {
userconfig: path.resolve('user-home', '.npmrc'),
userconfig,
},
env: { ...env, XDG_CONFIG_HOME: configHome, EXPLICIT_UNDEF: undefined },
packageManager: {
@@ -1987,6 +2162,8 @@ test('preferSymlinkedExecutables should be true when nodeLinker is hoisted', asy
})
test('return a warning when the .npmrc has an env variable that does not exist', async () => {
prepare()
fs.writeFileSync('.npmrc', 'registry=${ENV_VAR_123}', 'utf8')
const { warnings } = await getConfig({
cliOptions: {},
@@ -1997,7 +2174,7 @@ test('return a warning when the .npmrc has an env variable that does not exist',
})
const expected = [
expect.stringContaining('Failed to replace env in config: ${ENV_VAR_123}') // eslint-disable-line
expect.stringContaining('Ignored project-level request destination "registry"'),
]
expect(warnings).toEqual(expect.arrayContaining(expected))
@@ -2092,14 +2269,14 @@ test('read PNPM_HOME defined in environment variables', async () => {
process.env = oldEnv
})
test('xxx', async () => {
test('project .npmrc does not expand env variables into registry keys', async () => {
const oldEnv = process.env
process.env = {
...oldEnv,
FOO: 'registry',
}
const { config } = await getConfig({
const { config, warnings } = await getConfig({
cliOptions: {
dir: f.find('has-env-in-key'),
},
@@ -2108,7 +2285,10 @@ test('xxx', async () => {
version: '1.0.0',
},
})
expect(config.registry).toBe('https://registry.example.com/')
expect(config.registry).toBe('https://registry.npmjs.org/')
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level request destination "${FOO}"'),
]))
process.env = oldEnv
})
@@ -2315,13 +2495,24 @@ test.each([
describe('global config.yaml', () => {
let XDG_CONFIG_HOME: string | undefined
let PNPM_TEST_HOST: string | undefined
beforeEach(() => {
XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME
PNPM_TEST_HOST = process.env.PNPM_TEST_HOST
})
afterEach(() => {
process.env.XDG_CONFIG_HOME = XDG_CONFIG_HOME
if (XDG_CONFIG_HOME == null) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = XDG_CONFIG_HOME
}
if (PNPM_TEST_HOST == null) {
delete process.env.PNPM_TEST_HOST
} else {
process.env.PNPM_TEST_HOST = PNPM_TEST_HOST
}
})
test('reads config from global config.yaml', async () => {
@@ -2351,6 +2542,32 @@ describe('global config.yaml', () => {
expect(config.dangerouslyAllowAllBuilds).toBeDefined()
})
test('expands request destination values from trusted global config.yaml', async () => {
prepareEmpty()
fs.mkdirSync('.config/pnpm', { recursive: true })
writeYamlFileSync('.config/pnpm/config.yaml', {
pnprServer: 'https://${PNPM_TEST_HOST}/pnpr/',
registry: 'https://${PNPM_TEST_HOST}/npm/',
})
process.env.XDG_CONFIG_HOME = path.resolve('.config')
process.env.PNPM_TEST_HOST = 'trusted.example.com'
const { config } = await getConfig({
cliOptions: {},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
workspaceDir: process.cwd(),
})
expect(config.pnprServer).toBe('https://trusted.example.com/pnpr/')
expect(config.registry).toBe('https://trusted.example.com/npm/')
expect(config.registries.default).toBe('https://trusted.example.com/npm/')
})
test('reads user-level preference settings from global config.yaml', async () => {
prepareEmpty()

View File

@@ -253,40 +253,22 @@ fn should_install_circular_dependencies() {
drop((root, mock_instance)); // cleanup
}
/// End-to-end coverage for `${VAR}` substitution in `.npmrc`.
///
/// `<Host as EnvVar>::var` (the `std::env::var` bridge in
/// `crates/config/src/api.rs`) is unreachable by every other test
/// because `add_mocked_registry` writes literal values, so
/// `env_replace` short-circuits at the no-`$` branch.
///
/// This test rewrites the registry URL to `${PACQUET_TEST_REGISTRY}`,
/// sets that variable on the spawned process, and asserts the install
/// succeeds. The auth-token `${VAR}` substitution path covered by
/// upstream's [`installing/deps-installer/test/install/auth.ts`](https://github.com/pnpm/pnpm/blob/601317e7a3/installing/deps-installer/test/install/auth.ts)
/// is not exercised here. The mock registry doesn't gate on auth, so
/// substituting the registry URL is the smallest scenario that drives
/// `<Host as EnvVar>::var` end-to-end. Token-substitution coverage
/// belongs in a test against a registry that actually validates the
/// header.
#[test]
fn install_resolves_env_var_in_npmrc_registry() {
fn install_resolves_env_var_in_user_npmrc_registry() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, npmrc_path, .. } = npmrc_info;
eprintln!("Patching .npmrc to use ${{PACQUET_TEST_REGISTRY}}...");
// Replace the literal `registry=` line written by
// `add_mocked_registry` with one that references an env var.
// Keep the other lines (`store-dir`, `cache-dir`) intact.
let mocked_registry_url = mock_instance.url();
let original = fs::read_to_string(&npmrc_path).expect("read .npmrc");
let patched = original
.replace(&format!("registry={mocked_registry_url}"), "registry=${PACQUET_TEST_REGISTRY}");
let patched = original.replace(&format!("registry={mocked_registry_url}\n"), "");
eprintln!("npmrc_path={npmrc_path:?}\noriginal_npmrc={original:?}\npatched_npmrc={patched:?}");
assert_ne!(original, patched, ".npmrc layout drifted; update this test");
fs::write(&npmrc_path, &patched).expect("rewrite .npmrc");
let user_npmrc_path = root.path().join("trusted-user.npmrc");
fs::write(&user_npmrc_path, "registry=${PACQUET_TEST_REGISTRY}\n").expect("write user .npmrc");
eprintln!("Creating package.json...");
let manifest_path = workspace.join("package.json");
let package_json_content = serde_json::json!({
@@ -299,6 +281,8 @@ fn install_resolves_env_var_in_npmrc_registry() {
eprintln!("Executing command with PACQUET_TEST_REGISTRY set...");
pacquet
.with_env("PACQUET_TEST_REGISTRY", &mocked_registry_url)
.with_arg("--npmrc-auth-file")
.with_arg(user_npmrc_path)
.with_arg("install")
.assert()
.success();
@@ -312,6 +296,49 @@ fn install_resolves_env_var_in_npmrc_registry() {
drop((root, mock_instance)); // cleanup
}
#[test]
fn install_ignores_env_var_in_project_npmrc_registry() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, npmrc_path, .. } = npmrc_info;
let mocked_registry_url = mock_instance.url();
let original = fs::read_to_string(&npmrc_path).expect("read .npmrc");
let patched = original
.replace(&format!("registry={mocked_registry_url}"), "registry=${PACQUET_TEST_REGISTRY}");
eprintln!("npmrc_path={npmrc_path:?}\noriginal_npmrc={original:?}\npatched_npmrc={patched:?}");
assert_ne!(original, patched, ".npmrc layout drifted; update this test");
fs::write(&npmrc_path, &patched).expect("rewrite .npmrc");
let user_npmrc_path = root.path().join("trusted-user.npmrc");
fs::write(&user_npmrc_path, format!("registry={mocked_registry_url}\n"))
.expect("write user .npmrc");
eprintln!("Creating package.json...");
let manifest_path = workspace.join("package.json");
let package_json_content = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
},
});
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
eprintln!("Executing command with PACQUET_TEST_REGISTRY set...");
pacquet
.with_env("PACQUET_TEST_REGISTRY", "http://127.0.0.1:9/leaked/")
.with_arg("--npmrc-auth-file")
.with_arg(user_npmrc_path)
.with_arg("install")
.assert()
.success();
let symlink_path = workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin-parent");
let installed = is_symlink_or_junction(&symlink_path).unwrap();
assert!(installed, "expected installed symlink/junction at {symlink_path:?}");
drop((root, mock_instance)); // cleanup
}
/// `@pnpm.e2e/abc-parent-with-missing-peers@1.0.0` depends on
/// `@pnpm.e2e/abc@1.0.0`, which declares `peer-a`, `peer-b`, and
/// `peer-c` as peer dependencies. The parent provides none of them.

View File

@@ -1640,9 +1640,9 @@ impl Config {
self.modules_dir = start_dir.join("node_modules");
self.virtual_store_dir = start_dir.join("node_modules/.pnpm");
// Read the nearest .npmrc (start_dir first, home second) and apply
// only the auth/network subset. Everything else is intentionally
// ignored.
// Read the project/workspace .npmrc plus trusted user-level sources
// and apply only the auth/network subset. Everything else is
// intentionally ignored.
//
// pnpm reads several `.npmrc` sources and merges them
// (`user < auth.ini < workspace`), pinning each file's *unscoped*
@@ -1656,8 +1656,44 @@ impl Config {
// participates in the user-level path resolution below, and its
// directory is where `auth.ini` lives.
let global_config_dir = default_config_dir::<Sys>();
let global_settings =
let mut global_settings =
global_config_dir.as_deref().map(WorkspaceSettings::load_global).transpose()?.flatten();
if let Some(global_settings) = global_settings.as_mut() {
global_settings.substitute_env_trusted::<Sys>();
}
// Resolve the workspace dir before reading the project `.npmrc`
// so subdirectory invocations use the workspace-root config,
// matching pnpm's `opts.workspaceDir ?? localPrefix` boundary.
let env_workspace_dir = Sys::var_os("NPM_CONFIG_WORKSPACE_DIR")
.or_else(|| Sys::var_os("npm_config_workspace_dir"))
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let workspace_yaml = if let Some(env_dir) = env_workspace_dir {
// Env-var path: load yaml directly from the env dir. A
// missing file is silent (matching upstream), but the
// re-anchor still fires because the user has explicitly
// told us where the workspace lives.
let yaml_path = env_dir.join(WORKSPACE_MANIFEST_FILENAME);
match fs::read_to_string(&yaml_path) {
Ok(text) => {
let settings: WorkspaceSettings =
serde_saphyr::from_str(&text).map_err(Box::new).map_err(|source| {
LoadWorkspaceYamlError::ParseYaml { path: yaml_path, source }
})?;
Some((env_dir, Some(settings)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some((env_dir, None)),
Err(source) => {
return Err(LoadWorkspaceYamlError::ReadFile { path: yaml_path, source });
}
}
} else {
WorkspaceSettings::find_and_load(start_dir)?.map(|(path, settings)| {
let base_dir = path.parent().unwrap_or(start_dir).to_path_buf();
(base_dir, Some(settings))
})
};
// Resolve the user-level `.npmrc` path. Precedence (pnpm's
// [`index.ts:230`](https://github.com/pnpm/pnpm/blob/1819226b51/config/reader/src/index.ts#L230)):
@@ -1682,16 +1718,22 @@ impl Config {
// Build the merge sources in priority order (high → low):
// project `.npmrc` > `auth.ini` > user-level `.npmrc`. Each is
// parsed and rescoped independently before being folded together.
let parse_source = |text: String, dir: PathBuf, label: &str| {
let parse_trusted_source = |text: String, dir: PathBuf, label: &str| {
let mut auth = crate::npmrc_auth::NpmrcAuth::from_ini::<Sys>(&text, &dir);
auth.rescope_unscoped(label);
auth
};
let project_source = read_npmrc(start_dir)
.map(|text| parse_source(text, start_dir.to_path_buf(), "<project>/.npmrc"));
let project_npmrc_dir =
workspace_yaml.as_ref().map(|(base_dir, _)| base_dir.as_path()).unwrap_or(start_dir);
let project_source = read_npmrc(project_npmrc_dir).map(|text| {
let mut auth =
crate::npmrc_auth::NpmrcAuth::from_project_ini::<Sys>(&text, project_npmrc_dir);
auth.rescope_unscoped("<project>/.npmrc");
auth
});
let auth_ini_source = global_config_dir.as_deref().and_then(|dir| {
read_npmrc_file(&dir.join("auth.ini"))
.map(|text| parse_source(text, dir.to_path_buf(), "auth.ini"))
.map(|text| parse_trusted_source(text, dir.to_path_buf(), "auth.ini"))
});
let user_source = match &user_npmrc_path {
Some(path) => read_npmrc_file(path).map(|text| {
@@ -1700,10 +1742,11 @@ impl Config {
// that's the empty path — i.e. the process cwd — never
// the file itself.
let dir = path.parent().map(|parent| parent.to_path_buf()).unwrap_or_default();
parse_source(text, dir, "<user>/.npmrc")
parse_trusted_source(text, dir, "<user>/.npmrc")
}),
None => Sys::home_dir().and_then(|dir| {
read_npmrc(&dir).map(|text| parse_trusted_source(text, dir, "~/.npmrc"))
}),
None => Sys::home_dir()
.and_then(|dir| read_npmrc(&dir).map(|text| parse_source(text, dir, "~/.npmrc"))),
};
// Fold high-priority-first: the first present source is the
@@ -1761,32 +1804,18 @@ impl Config {
// must fire only when the user has *not* pinned a path. See
// [`crate::store_path::resolve_store_dir`].
let mut store_dir_explicit = false;
if let Some(mut global_settings) = global_settings {
if let Some(global_settings) = global_settings {
virtual_store_dir_explicit |= global_settings.virtual_store_dir.is_some();
global_virtual_store_dir_explicit |= global_settings.global_virtual_store_dir.is_some();
store_dir_explicit |= global_settings.store_dir.is_some();
global_settings.substitute_env::<Sys>();
let saved_workspace_dir = self.workspace_dir.take();
global_settings.apply_to(&mut self, start_dir);
self.workspace_dir = saved_workspace_dir;
}
// Layer pnpm-workspace.yaml overrides on top. A missing file is
// silent. Read or parse failures propagate to the caller.
//
// Resolve the workspace dir: `NPM_CONFIG_WORKSPACE_DIR`
// override first (mirroring upstream's `findWorkspaceDir` and
// [`pacquet_workspace::find_workspace_dir`]; both must agree on
// where the workspace lives, otherwise the per-importer
// `SymlinkDirectDependencies` writes and the virtual store
// would end up in different directories). Fall back to the
// upward walk for `pnpm-workspace.yaml` when the env var is
// unset or empty.
//
// The env var is read here rather than via
// [`pacquet_workspace`] to avoid adding a cross-crate
// dependency just for the lookup — the contract is fixed by
// pnpm upstream, so the duplication is low-risk.
// silent. Read or parse failures propagated while resolving
// `workspace_yaml` above.
//
// Capture the "did yaml set this field" booleans *before*
// applying yaml so the GVS derivation downstream can tell apart
@@ -1795,36 +1824,6 @@ impl Config {
// (SmartDefault wrote them in) and would either always or never
// re-point them, neither of which matches upstream's
// [`extendInstallOptions.ts:343-355`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/extendInstallOptions.ts#L343-L355).
let env_workspace_dir = Sys::var_os("NPM_CONFIG_WORKSPACE_DIR")
.or_else(|| Sys::var_os("npm_config_workspace_dir"))
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let workspace_yaml = if let Some(env_dir) = env_workspace_dir {
// Env-var path: load yaml directly from the env dir. A
// missing file is silent (matching upstream), but the
// re-anchor still fires because the user has explicitly
// told us where the workspace lives.
let yaml_path = env_dir.join(WORKSPACE_MANIFEST_FILENAME);
match fs::read_to_string(&yaml_path) {
Ok(text) => {
let settings: WorkspaceSettings =
serde_saphyr::from_str(&text).map_err(Box::new).map_err(|source| {
LoadWorkspaceYamlError::ParseYaml { path: yaml_path, source }
})?;
Some((env_dir, Some(settings)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some((env_dir, None)),
Err(source) => {
return Err(LoadWorkspaceYamlError::ReadFile { path: yaml_path, source });
}
}
} else {
WorkspaceSettings::find_and_load(start_dir)?.map(|(path, settings)| {
let base_dir = path.parent().unwrap_or(start_dir).to_path_buf();
(base_dir, Some(settings))
})
};
if let Some((base_dir, settings)) = workspace_yaml {
// Re-anchor the path-valued defaults to the workspace root
// before applying settings. Without this, a `pacquet install`
@@ -1863,7 +1862,7 @@ impl Config {
virtual_store_dir_explicit |= settings.virtual_store_dir.is_some();
global_virtual_store_dir_explicit |= settings.global_virtual_store_dir.is_some();
store_dir_explicit |= settings.store_dir.is_some();
settings.substitute_env::<Sys>();
settings.substitute_env_untrusted::<Sys>();
settings.apply_to(&mut self, &base_dir);
}
}
@@ -1887,7 +1886,7 @@ impl Config {
virtual_store_dir_explicit |= env_settings.virtual_store_dir.is_some();
global_virtual_store_dir_explicit |= env_settings.global_virtual_store_dir.is_some();
store_dir_explicit |= env_settings.store_dir.is_some();
env_settings.substitute_env::<Sys>();
env_settings.substitute_env_trusted::<Sys>();
let saved_workspace_dir = self.workspace_dir.clone();
env_settings.apply_to(&mut self, start_dir);
self.workspace_dir = saved_workspace_dir;

View File

@@ -125,6 +125,12 @@ pub(crate) struct RawCreds {
pub password: Option<String>,
}
#[derive(Clone, Copy)]
struct ParseOptions {
expand_auth_value_env: bool,
expand_request_destination_env: bool,
}
impl RawCreds {
fn is_empty(&self) -> bool {
self.auth_token.is_none()
@@ -150,6 +156,14 @@ impl RawCreds {
const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org/";
impl NpmrcAuth {
pub fn from_project_ini<Sys: EnvVar>(text: &str, npmrc_dir: &Path) -> Self {
Self::from_ini_with_options::<Sys>(
text,
npmrc_dir,
ParseOptions { expand_auth_value_env: false, expand_request_destination_env: false },
)
}
/// Parse an `.npmrc` file's contents and pick out the auth/network keys.
/// Unknown keys are silently dropped. `${VAR}` placeholders inside keys
/// and values are resolved via the [`EnvVar`] capability; unresolved
@@ -170,6 +184,18 @@ impl NpmrcAuth {
/// project `.npmrc` reachable via `pacquet --dir <proj>` from a
/// different cwd still finds its CA bundle (pnpm/pnpm#11726).
pub fn from_ini<Sys: EnvVar>(text: &str, npmrc_dir: &Path) -> Self {
Self::from_ini_with_options::<Sys>(
text,
npmrc_dir,
ParseOptions { expand_auth_value_env: true, expand_request_destination_env: true },
)
}
fn from_ini_with_options<Sys: EnvVar>(
text: &str,
npmrc_dir: &Path,
opts: ParseOptions,
) -> Self {
let mut auth = NpmrcAuth::default();
for line in text.lines() {
let line = line.trim();
@@ -185,7 +211,49 @@ impl NpmrcAuth {
// Apply ${VAR} substitution to both the key and the value,
// matching `readAndFilterNpmrc` in pnpm's `loadNpmrcFiles.ts`.
// Unresolved placeholders become "" and are recorded as warnings.
if !opts.expand_request_destination_env
&& has_env_placeholder(raw_key)
&& is_request_destination_key(raw_key)
{
auth.warn_ignored_request_destination_env(raw_key);
continue;
}
if !opts.expand_auth_value_env
&& has_env_placeholder(raw_key)
&& is_auth_value_key(raw_key)
{
auth.warn_ignored_auth_value_env(raw_key);
continue;
}
let (key, key_unresolved) = env_replace_lossy::<Sys>(raw_key);
if !opts.expand_request_destination_env
&& has_env_placeholder(raw_key)
&& is_request_destination_key(&key)
{
auth.warn_ignored_request_destination_env(raw_key);
continue;
}
if !opts.expand_auth_value_env
&& has_env_placeholder(raw_key)
&& is_auth_value_key(&key)
{
auth.warn_ignored_auth_value_env(raw_key);
continue;
}
if !opts.expand_request_destination_env
&& has_env_placeholder(raw_value)
&& is_request_destination_value_key(&key)
{
auth.warn_ignored_request_destination_env(&key);
continue;
}
if !opts.expand_auth_value_env
&& has_env_placeholder(raw_value)
&& is_auth_value_key(&key)
{
auth.warn_ignored_auth_value_env(&key);
continue;
}
let (value, value_unresolved) = env_replace_lossy::<Sys>(raw_value);
for placeholder in key_unresolved.into_iter().chain(value_unresolved) {
auth.warnings.push(format!("Failed to replace env in config: {placeholder}"));
@@ -281,6 +349,18 @@ impl NpmrcAuth {
auth
}
fn warn_ignored_request_destination_env(&mut self, key: &str) {
self.warnings.push(format!(
"Ignored project-level request destination {key:?}: environment variables are not expanded in repository-controlled registry or proxy URLs.",
));
}
fn warn_ignored_auth_value_env(&mut self, key: &str) {
self.warnings.push(format!(
"Ignored project-level auth setting {key:?}: environment variables are not expanded in repository-controlled registry credentials.",
));
}
/// Resolve the TLS + `local-address` slots on `config.tls`.
///
/// The transformations:
@@ -580,6 +660,24 @@ fn parse_bool(value: &str) -> Option<bool> {
}
}
fn is_request_destination_key(key: &str) -> bool {
is_registry_key(key) || key.starts_with("//")
}
fn is_request_destination_value_key(key: &str) -> bool {
is_registry_key(key) || matches!(key, "https-proxy" | "http-proxy" | "proxy")
}
fn is_registry_key(key: &str) -> bool {
key == "registry" || (key.starts_with('@') && key.ends_with(":registry"))
}
fn has_env_placeholder(value: &str) -> bool {
value
.match_indices("${")
.any(|(start, _)| value[start + 2..].find('}').is_some_and(|end| end > 0))
}
/// Read a `cafile` path and split the contents on
/// `-----END CERTIFICATE-----` to produce one PEM per certificate.
/// Mirrors pnpm's
@@ -682,6 +780,12 @@ fn base64_decode(input: &str) -> Option<String> {
/// mirroring `AUTH_SUFFIX_RE` from pnpm's `getNetworkConfigs.ts`.
const CREDS_SUFFIXES: &[&str] = &["_authToken", "_auth", "_password", "username"];
fn is_auth_value_key(key: &str) -> bool {
matches!(key, "_authToken" | "_auth" | "_password" | "username" | "cert" | "key")
|| split_creds_key(key).is_some()
|| split_inline_identity_key(key).is_some()
}
fn split_creds_key(key: &str) -> Option<(&str, &str)> {
if !key.starts_with("//") {
return None;
@@ -746,6 +850,11 @@ fn split_ssl_key(key: &str) -> Option<(&str, &'static str, bool)> {
None
}
fn split_inline_identity_key(key: &str) -> Option<(&str, &'static str)> {
let (uri, field, is_file) = split_ssl_key(key)?;
(!is_file && matches!(field, "cert" | "key")).then_some((uri, field))
}
/// Write a per-registry TLS value onto a [`RegistryTls`] entry.
///
/// For inline values (`is_file = false`) the parser pre-expands `\n`

View File

@@ -148,6 +148,147 @@ fn env_replace_substitutes_token() {
);
}
#[test]
fn project_ini_ignores_env_placeholders_in_registry_urls() {
static_env!(EnvWithSecret, &[("SECRET", "leaked")]);
let auth = NpmrcAuth::from_project_ini::<EnvWithSecret>(
"registry=https://registry.example.com/${SECRET}/\n",
Path::new(""),
);
assert_eq!(auth.registry, None);
assert!(auth.warnings.iter().any(|warning| warning.contains("registry")));
let mut config = Config::new();
auth.apply_to::<EnvWithSecret>(&mut config);
assert!(!config.registry.contains("leaked"));
}
#[test]
fn project_ini_ignores_env_placeholders_in_scoped_registry_urls() {
static_env!(EnvWithSecret, &[("SECRET", "leaked")]);
let auth = NpmrcAuth::from_project_ini::<EnvWithSecret>(
"@scope:registry=https://registry.example.com/${SECRET}/\n",
Path::new(""),
);
assert!(auth.creds_by_uri.is_empty());
assert!(auth.warnings.iter().any(|warning| warning.contains("@scope:registry")));
}
#[test]
fn trusted_ini_expands_env_placeholders_in_registry_urls() {
static_env!(EnvWithSecret, &[("SECRET", "trusted")]);
let auth = NpmrcAuth::from_ini::<EnvWithSecret>(
"registry=https://registry.example.com/${SECRET}/\n",
Path::new(""),
);
assert_eq!(auth.registry.as_deref(), Some("https://registry.example.com/trusted/"));
}
#[test]
fn project_ini_ignores_env_placeholders_in_url_scoped_keys() {
static_env!(EnvWithSecret, &[("SECRET", "leaked")]);
let auth = NpmrcAuth::from_project_ini::<EnvWithSecret>(
"//registry.example.com/${SECRET}/:_authToken=token\n",
Path::new(""),
);
assert!(auth.creds_by_uri.is_empty());
assert!(
auth.warnings
.iter()
.any(|warning| warning.contains("//registry.example.com/${SECRET}/:_authToken")),
);
}
#[test]
fn project_ini_ignores_env_placeholders_in_auth_values() {
static_env!(
EnvWithSecret,
&[
("CERT", "leaked-cert"),
("KEY", "leaked-key"),
("SECRET", "leaked"),
("USER", "leaked-user"),
("PASSWORD", "bGVha2Vk"),
]
);
let auth = NpmrcAuth::from_project_ini::<EnvWithSecret>(
"\
registry=https://attacker.example/
//attacker.example/:_authToken=${SECRET}
//attacker.example/:cert=${CERT}
//attacker.example/:key=${KEY}
_authToken=${SECRET}
username=${USER}
_password=${PASSWORD}
cert=${CERT}
key=${KEY}
",
Path::new(""),
);
assert!(auth.creds_by_uri.is_empty());
assert!(auth.tls_by_uri.is_empty());
assert_eq!(auth.default_creds.auth_token, None);
assert_eq!(auth.default_creds.username, None);
assert_eq!(auth.default_creds.password, None);
assert_eq!(auth.cert, None);
assert_eq!(auth.key, None);
assert!(
auth.warnings.iter().any(|warning| warning.contains("Ignored project-level auth setting")),
);
let mut config = Config::new();
auth.apply_to::<EnvWithSecret>(&mut config);
assert_eq!(config.auth_headers.for_url("https://attacker.example/pkg"), None);
assert_eq!(config.tls_by_uri.get("//attacker.example/"), None);
}
#[test]
fn project_ini_keeps_literal_dollar_brace_fragments() {
let auth = NpmrcAuth::from_project_ini::<NoEnv>(
"//attacker.example/:_authToken=literal${token\n",
Path::new(""),
);
assert_eq!(
auth.creds_by_uri.get("//attacker.example/").map(|creds| creds.auth_token.as_deref()),
Some(Some("literal${token")),
);
assert_eq!(auth.warnings, Vec::<String>::new());
}
#[test]
fn project_ini_ignores_env_placeholders_in_proxy_urls() {
static_env!(EnvWithSecret, &[("SECRET", "leaked")]);
let auth = NpmrcAuth::from_project_ini::<EnvWithSecret>(
"\
https-proxy=http://proxy.example.com/${SECRET}/
http-proxy=http://proxy.example.com/${SECRET}/
proxy=http://legacy-proxy.example.com/${SECRET}/
",
Path::new(""),
);
assert_eq!(auth.https_proxy, None);
assert_eq!(auth.http_proxy, None);
assert_eq!(auth.legacy_proxy, None);
assert!(
auth.warnings
.iter()
.any(|warning| warning.contains("Ignored project-level request destination")),
);
}
#[test]
fn env_replace_failure_warns_and_drops_unresolved_to_empty() {
// Mirrors pnpm's `substituteEnv` lossy fallback: unresolved `${VAR}` becomes

View File

@@ -334,6 +334,76 @@ pub fn npmrc_auth_file_npm_config_userconfig_is_compat_fallback() {
);
}
#[test]
pub fn global_config_npmrc_auth_file_expands_env() {
let xdg = tempdir().expect("xdg tempdir");
let config_dir = xdg.path().join("pnpm");
fs::create_dir_all(&config_dir).expect("create config dir");
let auth = tempdir().expect("auth tempdir");
let auth_file = auth.path().join("global-npmrc");
write_registry_auth_file(&auth_file, "https://global-auth.example.com/", "global-token");
fs::write(config_dir.join("config.yaml"), "npmrcAuthFile: ${AUTH_FILE}\n")
.expect("write global config.yaml");
let project = tempdir().expect("project tempdir");
set_fake_env(&[
("AUTH_FILE", auth_file.to_str().unwrap()),
("XDG_CONFIG_HOME", xdg.path().to_str().unwrap()),
]);
let config = load_with_fake_env(project.path());
assert_eq!(
config.auth_headers.for_url("https://global-auth.example.com/pkg").as_deref(),
Some("Bearer global-token"),
);
}
#[test]
pub fn global_config_yaml_request_destination_values_expand_env() {
let xdg = tempdir().expect("xdg tempdir");
let config_dir = xdg.path().join("pnpm");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(
config_dir.join("config.yaml"),
r#"
registry: https://${REGISTRY_HOST}/npm/
pnprServer: https://${REGISTRY_HOST}/pnpr/
namedRegistries:
work: https://${REGISTRY_HOST}/work/
"#,
)
.expect("write global config.yaml");
let project = tempdir().expect("project tempdir");
set_fake_env(&[
("REGISTRY_HOST", "trusted.example.com"),
("XDG_CONFIG_HOME", xdg.path().to_str().unwrap()),
]);
let config = load_with_fake_env(project.path());
assert_eq!(config.registry, "https://trusted.example.com/npm/");
assert_eq!(config.pnpr_server.as_deref(), Some("https://trusted.example.com/pnpr/"));
assert_eq!(
config.named_registries.get("work").map(String::as_str),
Some("https://trusted.example.com/work/"),
);
}
#[test]
pub fn pnpm_config_request_destinations_expand_env() {
let project = tempdir().expect("project tempdir");
set_fake_env(&[
("PNPM_CONFIG_PNPR_SERVER", "https://${REGISTRY_HOST}/pnpr/"),
("PNPM_CONFIG_REGISTRY", "https://${REGISTRY_HOST}/npm/"),
("REGISTRY_HOST", "env.example.com"),
]);
let config = load_with_fake_env(project.path());
assert_eq!(config.pnpr_server.as_deref(), Some("https://env.example.com/pnpr/"));
assert_eq!(config.registry, "https://env.example.com/npm/");
}
fn write_file(path: &Path, contents: &str) {
fs::write(path, contents).expect("write file");
}
@@ -681,6 +751,21 @@ pub fn pnpm_workspace_yaml_found_by_walking_up() {
assert!(!config.symlink);
}
#[test]
pub fn workspace_subdir_reads_workspace_root_npmrc() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("packages/web");
fs::create_dir_all(&nested).unwrap();
fs::write(tmp.path().join("pnpm-workspace.yaml"), "packages:\n - packages/*\n")
.expect("write to pnpm-workspace.yaml");
fs::write(tmp.path().join(".npmrc"), "registry=https://workspace-npmrc.example/\n")
.expect("write to .npmrc");
let config = Config::new().current::<HostNoHome>(&nested).expect("config loads");
assert_eq!(config.registry, "https://workspace-npmrc.example/");
}
#[test]
pub fn test_current_folder_fallback_to_default() {
let current_dir = tempdir().unwrap();

View File

@@ -646,23 +646,52 @@ impl WorkspaceSettings {
Ok(None)
}
/// Expand `${VAR}` placeholders inside string-valued map fields
/// that pnpm runs through `envReplace`. Today only
/// `namedRegistries` qualifies — the upstream
/// [`replaceEnvInSettings`](https://github.com/pnpm/pnpm/blob/b61e268d57/config/reader/src/getOptionsFromRootManifest.ts#L66-L84)
/// pass routes `registries` and `namedRegistries` through
/// `replaceEnvInStringValues`; pacquet exposes only the latter
/// at the yaml layer.
/// Expand `${VAR}` in trusted user-controlled settings.
///
/// Call this before [`Self::apply_to`] so the substituted values
/// land in [`Config`].
pub fn substitute_env<Sys: EnvVar>(&mut self) {
if let Some(named_registries) = self.named_registries.as_mut() {
for value in named_registries.values_mut() {
let (substituted, _) = env_replace_lossy::<Sys>(value);
*value = substituted;
}
/// Call this before [`Self::apply_to`] so expanded values land in
/// [`Config`].
pub fn substitute_env_trusted<Sys: EnvVar>(&mut self) {
self.substitute_env_scalars::<Sys>();
substitute_optional_string::<Sys>(&mut self.pnpr_server);
substitute_optional_string::<Sys>(&mut self.registry);
substitute_optional_string_map::<Sys>(&mut self.named_registries);
}
/// Expand `${VAR}` in ordinary string settings, but drop
/// placeholders inside workspace-controlled request-destination
/// fields.
/// The upstream
/// [`replaceEnvInSettings`](https://github.com/pnpm/pnpm/blob/b61e268d57/config/reader/src/getOptionsFromRootManifest.ts#L66-L84)
/// pass still runs `envReplace` on scalar strings while filtering
/// `registry`, `registries`, `namedRegistries`, and `pnprServer`
/// instead of expanding environment variables into request URLs.
///
/// Call this before [`Self::apply_to`] so expanded values land in
/// [`Config`] and filtered values do not.
pub fn substitute_env_untrusted<Sys: EnvVar>(&mut self) {
self.substitute_env_scalars::<Sys>();
if self.registry.as_deref().is_some_and(has_env_placeholder) {
self.registry = None;
}
if let Some(named_registries) = self.named_registries.as_mut() {
named_registries.retain(|_, value| !has_env_placeholder(value));
}
if self.pnpr_server.as_deref().is_some_and(has_env_placeholder) {
self.pnpr_server = None;
}
}
fn substitute_env_scalars<Sys: EnvVar>(&mut self) {
substitute_optional_string::<Sys>(&mut self.store_dir);
substitute_optional_string::<Sys>(&mut self.modules_dir);
substitute_optional_string::<Sys>(&mut self.virtual_store_dir);
substitute_optional_string::<Sys>(&mut self.global_virtual_store_dir);
substitute_optional_string::<Sys>(&mut self.user_agent);
substitute_optional_string::<Sys>(&mut self.npmrc_auth_file);
substitute_optional_string::<Sys>(&mut self.cache_dir);
substitute_optional_inner_string::<Sys>(&mut self.script_shell);
substitute_optional_inner_string::<Sys>(&mut self.node_options);
}
/// Apply every set field onto `config`, leaving unset ones untouched.
@@ -875,6 +904,35 @@ impl WorkspaceSettings {
}
}
fn has_env_placeholder(value: &str) -> bool {
value
.match_indices("${")
.any(|(start, _)| value[start + 2..].find('}').is_some_and(|end| end > 0))
}
fn substitute_optional_string<Sys: EnvVar>(value: &mut Option<String>) {
if let Some(value) = value {
let (substituted, _) = env_replace_lossy::<Sys>(value);
*value = substituted;
}
}
fn substitute_optional_string_map<Sys: EnvVar>(value: &mut Option<BTreeMap<String, String>>) {
if let Some(value) = value {
for map_value in value.values_mut() {
let (substituted, _) = env_replace_lossy::<Sys>(map_value);
*map_value = substituted;
}
}
}
fn substitute_optional_inner_string<Sys: EnvVar>(value: &mut Option<Option<String>>) {
if let Some(Some(value)) = value {
let (substituted, _) = env_replace_lossy::<Sys>(value);
*value = substituted;
}
}
fn resolve(base: &Path, value: &str) -> PathBuf {
let candidate = Path::new(value);
if candidate.is_absolute() { candidate.to_path_buf() } else { base.join(candidate) }

View File

@@ -166,14 +166,11 @@ namedRegistries:
);
}
/// Env-var placeholders inside `namedRegistries` values expand on
/// the [`WorkspaceSettings::substitute_env`] pass, matching upstream's
/// [`replaceEnvInStringValues`](https://github.com/pnpm/pnpm/blob/b61e268d57/config/reader/src/getOptionsFromRootManifest.ts#L86-L93)
/// behaviour for the `namedRegistries` key. Substitution lands
/// before `apply_to` so the resolved URL is what ends up on
/// [`Config::named_registries`].
/// Env-var placeholders inside workspace request destinations are ignored so
/// repository-controlled config cannot smuggle victim environment
/// values into outbound requests.
#[test]
fn substitutes_env_vars_inside_named_registries_values() {
fn ignores_env_vars_inside_workspace_request_destination_values() {
struct EnvWithHost;
impl EnvVar for EnvWithHost {
fn var(name: &str) -> Option<String> {
@@ -182,16 +179,96 @@ fn substitutes_env_vars_inside_named_registries_values() {
}
let yaml = r#"
pnprServer: https://${WORK_HOST}/pnpr/
registry: https://${WORK_HOST}/npm/
namedRegistries:
literal: 'https://registry.example.com/${/npm/'
stable: https://registry.example.com/npm/
work: https://${WORK_HOST}/npm/
"#;
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
settings.substitute_env::<EnvWithHost>();
settings.substitute_env_untrusted::<EnvWithHost>();
let mut config = Config::new();
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert_eq!(config.pnpr_server, None);
assert_eq!(config.registry, "https://registry.npmjs.org/");
assert_eq!(
config.named_registries.get("stable").map(String::as_str),
Some("https://registry.example.com/npm/"),
);
assert_eq!(
config.named_registries.get("literal").map(String::as_str),
Some("https://registry.example.com/${/npm/"),
);
assert_eq!(config.named_registries.get("work"), None);
}
#[test]
fn expands_env_vars_inside_non_registry_workspace_values() {
struct EnvWithPaths;
impl EnvVar for EnvWithPaths {
fn var(name: &str) -> Option<String> {
match name {
"CACHE_DIR" => Some("cache-dir".to_owned()),
"HOOK" => Some("hook.js".to_owned()),
"SHELL" => Some("custom-shell".to_owned()),
"STORE_DIR" => Some("store-dir".to_owned()),
"USER_AGENT" => Some("pacquet-test/1.0".to_owned()),
_ => None,
}
}
}
let yaml = r#"
storeDir: ${STORE_DIR}
cacheDir: ${CACHE_DIR}
scriptShell: ${SHELL}
nodeOptions: --require=${HOOK}
userAgent: ${USER_AGENT}
"#;
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
settings.substitute_env_untrusted::<EnvWithPaths>();
let base = Path::new("/workspace/root");
let mut config = Config::new();
settings.apply_to(&mut config, base);
assert_eq!(config.store_dir, StoreDir::from(base.join("store-dir")));
assert_eq!(config.cache_dir, base.join("cache-dir"));
assert_eq!(config.script_shell.as_deref(), Some("custom-shell"));
assert_eq!(config.node_options.as_deref(), Some("--require=hook.js"));
assert_eq!(config.user_agent, "pacquet-test/1.0");
}
#[test]
fn trusted_settings_expand_env_vars_inside_request_destination_values() {
struct EnvWithHost;
impl EnvVar for EnvWithHost {
fn var(name: &str) -> Option<String> {
(name == "WORK_HOST").then(|| "internal.example.com".to_owned())
}
}
let yaml = r#"
pnprServer: https://${WORK_HOST}/pnpr/
registry: https://${WORK_HOST}/npm/
namedRegistries:
stable: https://registry.example.com/npm/
work: https://${WORK_HOST}/work/
"#;
let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
settings.substitute_env_trusted::<EnvWithHost>();
let mut config = Config::new();
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert_eq!(config.pnpr_server.as_deref(), Some("https://internal.example.com/pnpr/"));
assert_eq!(config.registry, "https://internal.example.com/npm/");
assert_eq!(
config.named_registries.get("stable").map(String::as_str),
Some("https://registry.example.com/npm/"),
);
assert_eq!(
config.named_registries.get("work").map(String::as_str),
Some("https://internal.example.com/npm/"),
Some("https://internal.example.com/work/"),
);
}

9
pnpm-lock.yaml generated
View File

@@ -1043,6 +1043,7 @@ overrides:
request: npm:postman-request@2.88.1-postman.40
semver@<7.5.2: ^7.8.1
send@<0.19.0: ^0.19.0
shell-quote@<1.8.4: '>=1.8.4'
serve-static@<1.16.0: ^1.16.0
socks@2: ^2.8.1
tar@<=7.5.9: '>=7.5.10'
@@ -16241,8 +16242,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
engines: {node: '>= 0.4'}
shelljs@0.9.2:
@@ -20978,7 +20979,7 @@ snapshots:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
shell-quote: 1.8.4
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
@@ -24665,7 +24666,7 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
shell-quote@1.8.4: {}
shelljs@0.9.2:
dependencies:

View File

@@ -436,6 +436,7 @@ overrides:
request: npm:postman-request@2.88.1-postman.40
semver@<7.5.2: 'catalog:'
send@<0.19.0: ^0.19.0
shell-quote@<1.8.4: '>=1.8.4'
serve-static@<1.16.0: ^1.16.0
socks@2: ^2.8.1
tar@<=7.5.9: '>=7.5.10'