diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 64992a3f..2e934969 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -267,6 +267,10 @@ "description": "The URL to use for downloading Node.js distributions", "default": "https://nodejs.org/dist" }, + "nodeDistAuthHeader": { + "type": ["zpm_utils::Secret", "null"], + "description": "The Authorization header to send when downloading Node.js distributions" + }, "nodeLinker": { "type": "crate::NodeLinker", "description": "The linker to use for node_modules", diff --git a/packages/zpm/src/builtins/node.rs b/packages/zpm/src/builtins/node.rs index 2d090c08..ffbe54c8 100644 --- a/packages/zpm/src/builtins/node.rs +++ b/packages/zpm/src/builtins/node.rs @@ -29,8 +29,12 @@ pub async fn resolve_nodejs_version(context: &InstallContext<'_>, range: &zpm_se let release_url = format!("{}/index.json", project.config.settings.node_dist_url.value); - let text - = project.http_client.get(&release_url)?.send_text().await?; + let node_dist_auth_header = project.config.settings.node_dist_auth_header.value.as_ref() + .map(|header| header.value.as_str()); + + let text = project.http_client.get(&release_url)? + .header("authorization", node_dist_auth_header) + .send_text().await?; #[derive(Deserialize)] struct NodejsManifest { @@ -154,6 +158,9 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo let url = format!("{}/v{}/node-v{}-{}.tar.gz", project.config.settings.node_dist_url.value, version_str, version_str, file_name); + let node_dist_auth_header = project.config.settings.node_dist_auth_header.value.as_ref() + .map(|header| header.value.as_str()); + let package_cache = context.package_cache .expect("The package cache is required for fetching npm packages"); let cache_packer @@ -175,6 +182,7 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo let cached_blob = package_cache.ensure_blob(locator.clone(), ".zip", || async move { let (_, bytes) = project.http_client.get(&url)? + .header("authorization", node_dist_auth_header) .send_bytes().await?; let archive = tokio::task::spawn_blocking(move || -> Result, Error> { diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts index 0ce80f47..5dbe0028 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts @@ -250,9 +250,11 @@ export type Request = { localName: string; } | { type: RequestType.NodeDistIndex; + private: boolean; } | { type: RequestType.NodeDistTarball; name: string; + private: boolean; } | { type: RequestType.OtelTraces; body?: unknown; @@ -532,6 +534,8 @@ export const validLogins = { otpUserWithNotice: new Login(`otp-user-with-notice`, {otp: true, notice: true}), } as const; +export const validNodeDistAuthHeader = `secret-token`; + let whitelist = new Map(); let recording: Array | null = null; @@ -1320,14 +1324,16 @@ exit 0 return { type: RequestType.Repository, }; - } else if ((match = url.match(/^\/node\/dist\/index.json$/))) { + } else if ((match = url.match(/^\/node(-private)?\/dist\/index.json$/))) { return { type: RequestType.NodeDistIndex, + private: typeof match[1] !== `undefined`, }; - } else if ((match = url.match(/^\/node\/dist\/v([0-9]+\.[0-9]+\.[0-9]+)\/(node-v(\1)-[a-z0-9-]+)\.tar\.gz$/))) { + } else if ((match = url.match(/^\/node(-private)?\/dist\/v([0-9]+\.[0-9]+\.[0-9]+)\/(node-v(\2)-[a-z0-9-]+)\.tar\.gz$/))) { return { type: RequestType.NodeDistTarball, - name: match[2]!, + name: match[3]!, + private: typeof match[1] !== `undefined`, }; } else if (url === `/v1/traces`) { return { @@ -1506,7 +1512,22 @@ exit 0 recording.push(parsedRequest); const {authorization} = req.headers; - if (authorization != null) { + const isPrivateNodeDistRequest = ( + parsedRequest.type === RequestType.NodeDistIndex + || parsedRequest.type === RequestType.NodeDistTarball + ) && parsedRequest.private; + + if (isPrivateNodeDistRequest) { + if (authorization == null) { + sendError(res, 401, `Authentication required`); + return; + } + + if (authorization !== validNodeDistAuthHeader) { + sendError(res, 401, `Invalid token`); + return; + } + } else if (authorization != null) { const user = validAuthorizations.get(authorization); if (!user) { sendError(res, 401, `Invalid token`); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/config.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/config.test.ts index 6b7d9953..b8ae3394 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/config.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/config.test.ts @@ -74,11 +74,15 @@ const options = { describe(`Commands`, () => { describe(`config`, () => { test(`should redact secrets by default`, makeTemporaryEnv({}, async ({path, run}) => { - await xfs.writeFilePromise(ppath.join(path, RC_FILENAME), `npmAuthToken: super-secret-token\n`); + await xfs.writeFilePromise(ppath.join(path, RC_FILENAME), [ + `npmAuthToken: super-secret-token`, + `nodeDistAuthHeader: Bearer super-secret-header`, + ].join(`\n`)); const {stdout} = await run(`config`, `--json`); expect(stdout).toContain(``); expect(stdout).not.toContain(`super-secret-token`); + expect(stdout).not.toContain(`super-secret-header`); })); test(`should reveal secrets when --no-redacted is passed`, makeTemporaryEnv({}, async ({path, run}) => { @@ -86,10 +90,14 @@ describe(`Commands`, () => { // previously the flag was a silent no-op and the token stayed // `` even when the user explicitly opted out of // redaction. - await xfs.writeFilePromise(ppath.join(path, RC_FILENAME), `npmAuthToken: super-secret-token\n`); + await xfs.writeFilePromise(ppath.join(path, RC_FILENAME), [ + `npmAuthToken: super-secret-token`, + `nodeDistAuthHeader: Bearer super-secret-header`, + ].join(`\n`)); const {stdout} = await run(`config`, `--no-redacted`, `--json`); expect(stdout).toContain(`super-secret-token`); + expect(stdout).toContain(`super-secret-header`); expect(stdout).not.toContain(``); })); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts index 0c40d341..5de6cb44 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts @@ -1,5 +1,7 @@ import {Filename, ppath, PortablePath, xfs} from '@yarnpkg/fslib'; -import {yarn} from 'pkg-tests-core'; +import {tests, yarn} from 'pkg-tests-core'; + +const {startPackageServer, validNodeDistAuthHeader} = tests; describe(`Features`, () => { describe(`Node.js Versioning`, () => { @@ -63,6 +65,44 @@ describe(`Features`, () => { }), ); + describe(`Distribution authentication`, () => { + test( + `it should send the configured authorization header`, + makeTemporaryEnv({ + dependencies: { + [`@yarnpkg/node`]: `builtin:^22.0.0`, + }, + }, async ({run}) => { + await run(`install`, { + nodeDistUrl: `${await startPackageServer()}/node-private/dist`, + nodeDistAuthHeader: validNodeDistAuthHeader, + env: { + YARN_CPU_OVERRIDE: `x64`, + YARN_OS_OVERRIDE: `linux`, + }, + }); + }), + ); + + test( + `it should fail with a descriptive error when the authorization header is invalid`, + makeTemporaryEnv({ + dependencies: { + [`@yarnpkg/node`]: `builtin:^22.0.0`, + }, + }, async ({run}) => { + await expect(run(`install`, { + nodeDistUrl: `${await startPackageServer()}/node-private/dist`, + nodeDistAuthHeader: `Bearer invalid-node-dist-token`, + env: { + YARN_CPU_OVERRIDE: `x64`, + YARN_OS_OVERRIDE: `linux`, + }, + })).rejects.toThrow(/Network error: HTTP status client error \(401 Unauthorized\) for url .*\/node-private\/dist\/index\.json/); + }), + ); + }); + describe(`Monorepo support`, () => { test( `it should allow declaring @yarnpkg/node in a workspace profile`, diff --git a/website/config/yarnrc.json b/website/config/yarnrc.json index 769998c1..0e7104d7 100644 --- a/website/config/yarnrc.json +++ b/website/config/yarnrc.json @@ -1208,6 +1208,26 @@ } ] }, + "nodeDistAuthHeader": { + "_package": "@yarnpkg/core", + "title": "Authorization header to send when downloading Node.js distributions.", + "description": "When specified, the value will be sent as an `Authorization` header when fetching both the release index and distribution archives from `nodeDistUrl`. This is typically paired with a custom `nodeDistUrl` for an internal node.js distribution mirror.", + "type": ["string", "null"], + "_examples": [ + { + "description": "Do not pass an `Authorization` header.", + "value": null + }, + { + "description": "Pass a Bearer token in the `Authorization` header.", + "value": "Bearer a-token-here" + }, + { + "description": "Pass a Bearer token from an environment variable.", + "value": "Bearer ${NODE_DIST_BEARER_TOKEN}" + } + ] + }, "npmMinimalAgeGate": { "_package": "@yarnpkg/plugin-npm", "title": "Minimum package version age required before installation.",