From 05249c915306ca3e14a1ca0cc5498bbf478ab710 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 11 Aug 2026 17:04:39 +0200 Subject: [PATCH 1/3] Harden OCI registry authentication Validate registry-provided bearer realms, restrict cross-origin credential forwarding, and add an explicit registry-to-auth-host compatibility option. Co-authored-by: Kaniska Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/dev-containers.yml | 3 +- src/spec-configuration/httpOCIRegistry.ts | 262 ++++++++++++++++---- src/spec-node/devContainersSpecCLI.ts | 12 + src/spec-utils/httpRequest.ts | 26 +- src/test/httpOCIRegistry.test.ts | 276 ++++++++++++++++++++++ 5 files changed, 535 insertions(+), 44 deletions(-) create mode 100644 src/test/httpOCIRegistry.test.ts diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 930d77d31..81ffb8226 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -61,10 +61,11 @@ jobs: "src/test/cli.podman.test.ts", "src/test/cli.test.ts", "src/test/cli.up.test.ts", + "src/test/httpOCIRegistry.test.ts", "src/test/imageMetadata.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/httpOCIRegistry.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 2bebba82e..c64758747 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as jsonc from 'jsonc-parser'; import { runCommandNoPty, plainExec } from '../spec-common/commonUtils'; -import { requestResolveHeaders } from '../spec-utils/httpRequest'; +import { requestResolveHeaders, requestResolveHeadersNoRedirects } from '../spec-utils/httpRequest'; import { LogLevel } from '../spec-utils/log'; import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; @@ -35,6 +35,139 @@ const realmRegex = /realm="([^"]+)"/; const serviceRegex = /service="([^"]+)"/; const scopeRegex = /scope="([^"]+)"/; +type RegistryCredentialType = 'basic' | 'refreshToken'; + +export const allowCrossOriginAuthHostEnv = 'DEVCONTAINERS_INTERNAL_ALLOW_CROSS_ORIGIN_AUTH_HOST'; + +const builtInCrossOriginAuthHosts = [ + 'registry-1.docker.io=auth.docker.io', + 'registry.docker.io=auth.docker.io', + 'docker.io=auth.docker.io', + 'index.docker.io=auth.docker.io', + 'registry.gitlab.com=gitlab.com', +]; + +function normalizeHttpsAuthority(authority: string): string { + let parsed: URL; + try { + parsed = new URL(`https://${authority}`); + } catch { + throw new Error(`Invalid authority '${authority}'.`); + } + if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error(`Invalid authority '${authority}'.`); + } + return parsed.host.toLowerCase(); +} + +export function parseCrossOriginAuthHosts(entries: readonly string[]): Map> { + const result = new Map>(); + for (const entry of entries) { + const separator = entry.indexOf('='); + if (separator <= 0 || separator !== entry.lastIndexOf('=') || separator === entry.length - 1) { + throw new Error(`Invalid cross-origin auth host '${entry}'. Expected '='.`); + } + const registry = normalizeHttpsAuthority(entry.slice(0, separator)); + const authHost = normalizeHttpsAuthority(entry.slice(separator + 1)); + const authHosts = result.get(registry) || new Set(); + authHosts.add(authHost); + result.set(registry, authHosts); + } + return result; +} + +function getCrossOriginAuthHosts(env: NodeJS.ProcessEnv) { + const configured = env[allowCrossOriginAuthHostEnv]; + let configuredEntries: string[] = []; + if (configured) { + const parsed: unknown = JSON.parse(configured); + if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== 'string')) { + throw new Error(`Invalid ${allowCrossOriginAuthHostEnv} value.`); + } + configuredEntries = parsed; + } + return parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]); +} + +function isConfiguredCrossOriginAuthHost(registryUrl: URL, realmUrl: URL, crossOriginAuthHosts: Map>) { + return crossOriginAuthHosts.get(registryUrl.host.toLowerCase())?.has(realmUrl.host.toLowerCase()) || false; +} + +function isAllowedSameAuthorityRealm(registryUrl: URL, realmUrl: URL) { + if (registryUrl.host.toLowerCase() !== realmUrl.host.toLowerCase()) { + return false; + } + return realmUrl.protocol === 'https:' + || realmUrl.protocol === 'http:' && realmUrl.hostname.toLowerCase() === 'localhost'; +} + +function canForwardCredentialToTokenServiceForPolicy(realm: string, registryUrl: URL, credentialType: RegistryCredentialType, crossOriginAuthHosts: Map>): boolean { + let realmUrl: URL; + try { + realmUrl = new URL(realm); + } catch { + return false; + } + + if (isAllowedSameAuthorityRealm(registryUrl, realmUrl)) { + return true; + } + + return credentialType === 'basic' + && realmUrl.protocol === 'https:' + && isConfiguredCrossOriginAuthHost(registryUrl, realmUrl, crossOriginAuthHosts); +} + +// Endpoint admission and credential forwarding are separate policies. Refresh tokens +// never cross an origin boundary, even when Basic authentication is explicitly allowed. +export function canForwardCredentialToTokenService(realm: string, registryUrl: string, credentialType: RegistryCredentialType, configuredEntries: readonly string[] = []): boolean { + let parsedRegistryUrl: URL; + try { + parsedRegistryUrl = new URL(registryUrl); + } catch { + return false; + } + + return canForwardCredentialToTokenServiceForPolicy( + realm, + parsedRegistryUrl, + credentialType, + parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) + ); +} + +function isAllowedTokenServiceRealmForPolicy(realm: string, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { + let realmUrl: URL; + try { + realmUrl = new URL(realm); + } catch { + return false; + } + + if (isAllowedSameAuthorityRealm(registryUrl, realmUrl)) { + return true; + } + + return realmUrl.protocol === 'https:' + && isConfiguredCrossOriginAuthHost(registryUrl, realmUrl, crossOriginAuthHosts); +} + +// Pin registry-directed token requests to the registry authority or an explicitly trusted auth host. +export function isAllowedTokenServiceRealm(realm: string, registryUrl: string, configuredEntries: readonly string[] = []): boolean { + let parsedRegistryUrl: URL; + try { + parsedRegistryUrl = new URL(registryUrl); + } catch { + return false; + } + + return isAllowedTokenServiceRealmForPolicy( + realm, + parsedRegistryUrl, + parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) + ); +} + // https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { // If needed, Initialize the Authorization header cache. @@ -100,6 +233,30 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); return; } + let crossOriginAuthHosts: Map>; + try { + crossOriginAuthHosts = getCrossOriginAuthHosts(params.env); + } catch (err) { + output.write(`[httpOci] ERR: ${err}`, LogLevel.Error); + return; + } + const registryUrl = new URL(initialAttemptRes.responseUrl); + // Reject the challenge before credential lookup or token-endpoint I/O. + if (!isAllowedTokenServiceRealmForPolicy(realmGroup[1], registryUrl, crossOriginAuthHosts)) { + delete cachedAuthHeader[ociRef.registry]; + const realmUrl = (() => { + try { + return new URL(realmGroup[1]); + } catch { + return undefined; + } + })(); + const allowHint = realmUrl?.protocol === 'https:' + ? ` Use '--allow-cross-origin-auth-host ${registryUrl.host}=${realmUrl.host}' to trust this registry-to-auth-host mapping.` + : ''; + output.write(`[httpOci] ERR: Registry '${registryUrl.host}' requested authentication from untrusted realm '${realmGroup[1]}'.${allowHint}`, LogLevel.Error); + return; + } const wwwAuthenticateData = { realm: realmGroup[1], @@ -107,7 +264,9 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio scope: scopeGroup ? scopeGroup[1] : '', }; - const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); + const requestedRegistryUrl = new URL(httpOptions.url); + const canUseRegistryCredentials = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); + const bearerToken = await fetchRegistryBearerToken(params, ociRef, registryUrl, crossOriginAuthHosts, canUseRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); return; @@ -331,34 +490,55 @@ async function getCredentialFromHelper(params: CommonParams, registry: string, c } // https://docs.docker.com/registry/spec/auth/token/#requesting-a-token -async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, registryUrl: URL, crossOriginAuthHosts: Map>, canUseRegistryCredentials: boolean, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { const { output } = params; const { realm, service, scope } = wwwAuthenticateData; - // TODO: Remove this. - if (realm.includes('mcr.microsoft.com')) { - return undefined; - } - - const headers: HEADERS = { - 'user-agent': 'devcontainer' - }; - // The token server should first attempt to authenticate the client using any authentication credentials provided with the request. // From Docker 1.11 the Docker engine supports both Basic Authentication and OAuth2 for getting tokens. // Docker 1.10 and before, the registry client in the Docker Engine only supports Basic Authentication. // If an attempt to authenticate to the token server fails, the token server should return a 401 Unauthorized response // indicating that the provided credentials are invalid. // > https://docs.docker.com/registry/spec/auth/token/#requesting-a-token - const userCredential = await getCredential(params, ociRef); + const userCredential = canUseRegistryCredentials ? await getCredential(params, ociRef) : undefined; const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; + const canForwardBasicCredential = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, 'basic', crossOriginAuthHosts); + const canForwardRefreshToken = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, 'refreshToken', crossOriginAuthHosts); let httpOptions: { type: string; url: string; headers: Record; data?: Buffer }; + let sentCredentials = false; + + const createGetHttpOptions = (authorization?: string) => { + // URLSearchParams preserves existing realm parameters and encodes challenge values. + const url = new URL(realm); + url.searchParams.set('service', service); + url.searchParams.set('scope', scope); + + const headers: Record = { + 'user-agent': 'devcontainer', + }; + if (authorization) { + headers.authorization = authorization; + } + + return { + type: 'GET', + url: url.toString(), + headers, + }; + }; + + if (refreshToken && !canForwardRefreshToken) { + output.write(`[httpOci] Refusing to send refresh token to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); + } + if (basicAuthCredential && !canForwardBasicCredential) { + output.write(`[httpOci] Refusing to send Basic credential to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); + } // There are several different ways registries expect to handle the oauth token exchange. // Depending on the type of credential available, use the most reasonable method. - if (refreshToken) { + if (refreshToken && canForwardRefreshToken) { const form_url_encoded = new URLSearchParams(); form_url_encoded.append('client_id', 'devcontainer'); form_url_encoded.append('grant_type', 'refresh_token'); @@ -366,51 +546,53 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O form_url_encoded.append('scope', scope); form_url_encoded.append('refresh_token', refreshToken); - headers['content-type'] = 'application/x-www-form-urlencoded'; - const url = realm; output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); httpOptions = { type: 'POST', url, - headers: headers, + headers: { + 'user-agent': 'devcontainer', + 'content-type': 'application/x-www-form-urlencoded', + }, data: Buffer.from(form_url_encoded.toString()) }; + sentCredentials = true; } else { - if (basicAuthCredential) { - headers['authorization'] = `Basic ${basicAuthCredential}`; - } - // realm="https://auth.docker.io/token" // service="registry.docker.io" // scope="repository:samalba/my-app:pull,push" // Example: // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push - const url = `${realm}?service=${service}&scope=${scope}`; - output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); - - httpOptions = { - type: 'GET', - url: url, - headers: headers, - }; + const authorization = basicAuthCredential && canForwardBasicCredential + ? `Basic ${basicAuthCredential}` + : undefined; + httpOptions = createGetHttpOptions(authorization); + sentCredentials = !!authorization; + output.write(`[httpOci] Attempting to fetch bearer token from: ${httpOptions.url}`, LogLevel.Trace); } - let res = await requestResolveHeaders(httpOptions, output); - if (res && res.statusCode === 401 || res.statusCode === 403) { - output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); - const body = res.resBody?.toString(); - if (body) { - output.write(`${res.resBody.toString()}.`, LogLevel.Info); - } + let res: Awaited>; + try { + res = await requestResolveHeadersNoRedirects(httpOptions, output); + if (sentCredentials && (res.statusCode === 401 || res.statusCode === 403)) { + output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); + const body = res.resBody?.toString(); + if (body) { + output.write(`${res.resBody.toString()}.`, LogLevel.Info); + } - // Try again without user credentials. If we're here, their creds are likely expired. - delete headers['authorization']; - res = await requestResolveHeaders(httpOptions, output); + // Build a fresh GET so neither an Authorization header nor a refresh-token POST body is reused. + httpOptions = createGetHttpOptions(); + res = await requestResolveHeadersNoRedirects(httpOptions, output); + } + } catch (err) { + output.write(`[httpOci] Failed to request bearer token for '${service}': ${err}`, LogLevel.Error); + return; } - if (!res || res.statusCode > 299 || !res.resBody) { + if (res.statusCode > 299 || !res.resBody) { output.write(`[httpOci] ${res.statusCode}: Failed to fetch bearer token for '${service}': ${res.resBody.toString()}`, LogLevel.Error); return; } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 832e9603f..8dc4a5605 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -45,6 +45,7 @@ import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './feat import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; +import { allowCrossOriginAuthHostEnv, parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -66,6 +67,17 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa .scriptName('devcontainer') .version(version) .demandCommand() + .option('allow-cross-origin-auth-host', { + type: 'string', + array: true, + global: true, + description: 'Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.', + }) + .middleware(args => { + const entries = args['allow-cross-origin-auth-host'] || []; + parseCrossOriginAuthHosts(entries); + process.env[allowCrossOriginAuthHostEnv] = JSON.stringify(entries); + }, true) .strict(); y.wrap(Math.min(120, y.terminalWidth())); y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); diff --git a/src/spec-utils/httpRequest.ts b/src/spec-utils/httpRequest.ts index 162c55cc5..83e265752 100644 --- a/src/spec-utils/httpRequest.ts +++ b/src/spec-utils/httpRequest.ts @@ -79,11 +79,27 @@ export async function headRequest(options: { url: string; headers: Record; + data?: Buffer; +}; + // Send HTTP Request. // Does not throw on status code, but rather always returns 'statusCode', 'resHeaders', and 'resBody'. -export async function requestResolveHeaders(options: { type: string; url: string; headers: Record; data?: Buffer }, output: Log) { +export async function requestResolveHeaders(options: RequestResolveHeadersOptions, output: Log) { + return requestResolveHeadersInternal(options, output); +} + +// Token endpoints must not redirect around their validated authority boundary. +export async function requestResolveHeadersNoRedirects(options: RequestResolveHeadersOptions, output: Log) { + return requestResolveHeadersInternal(options, output, 0); +} + +async function requestResolveHeadersInternal(options: RequestResolveHeadersOptions, output: Log, maxRedirects?: number) { const secureContext = await secureContextWithExtraCerts(output); - return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer }>((resolve, reject) => { + return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer; responseUrl: string }>((resolve, reject) => { const parsed = new url.URL(options.url); const reqOptions: RequestOptions & tls.CommonConnectionOptions & FollowOptions = { hostname: parsed.hostname, @@ -95,6 +111,9 @@ export async function requestResolveHeaders(options: { type: string; url: string agent: new ProxyAgent(), secureContext, }; + if (maxRedirects !== undefined) { + reqOptions.maxRedirects = maxRedirects; + } const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; if (plainHTTP) { @@ -111,7 +130,8 @@ export async function requestResolveHeaders(options: { type: string; url: string resolve({ statusCode: res.statusCode!, resHeaders: res.headers! as Record, - resBody: Buffer.concat(chunks) + resBody: Buffer.concat(chunks), + responseUrl: res.responseUrl, }); }); }); diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts new file mode 100644 index 000000000..40c6db6a3 --- /dev/null +++ b/src/test/httpOCIRegistry.test.ts @@ -0,0 +1,276 @@ +import * as http from 'http'; +import { AddressInfo } from 'net'; + +import { assert } from 'chai'; + +import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; +import { canForwardCredentialToTokenService, isAllowedTokenServiceRealm, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { nullLog } from '../spec-utils/log'; + +describe('OCI registry authentication', () => { + describe('isAllowedTokenServiceRealm', () => { + const cases = [ + { realm: 'https://registry.example/token', registryUrl: 'https://registry.example/v2/', expected: true }, + { realm: 'https://REGISTRY.EXAMPLE/token', registryUrl: 'https://registry.example/v2/', expected: true }, + { realm: 'https://registry.example/token', registryUrl: 'https://registry.example:443/v2/', expected: true }, + { realm: 'https://registry.example:8443/token', registryUrl: 'https://registry.example:8443/v2/', expected: true }, + { realm: 'https://registry.example:8443/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'http://registry.example/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'http://localhost:5000/token', registryUrl: 'https://localhost:5000/v2/', expected: true }, + { realm: 'http://localhost:5001/token', registryUrl: 'https://localhost:5000/v2/', expected: false }, + { realm: 'not-a-url', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: '/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: true }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://docker.io/v2/', expected: true }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'http://auth.docker.io/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'https://gitlab.com/jwt/auth', registryUrl: 'https://registry.gitlab.com/v2/', expected: true }, + { realm: 'https://gitlab.com/jwt/auth', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'https://ghcr.io/token', registryUrl: 'https://ghcr.io/v2/', expected: true }, + { realm: 'https://ghcr.io/token', registryUrl: 'https://containers.example/v2/', expected: false }, + { realm: 'https://registry.azurecr.io/oauth2/token', registryUrl: 'https://registry.azurecr.io/v2/', expected: true }, + { realm: 'https://registry.azurecr.io/oauth2/token', registryUrl: 'https://containers.example/v2/', expected: false }, + { realm: 'https://auth.docker.io.attacker.example/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'https://auth.docker.io:8443/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'http://127.0.0.1/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'http://169.254.169.254/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + ]; + + for (const { realm, registryUrl, expected } of cases) { + it(`${expected ? 'allows' : 'rejects'} '${realm}' for '${registryUrl}'`, () => { + assert.equal(isAllowedTokenServiceRealm(realm, registryUrl), expected); + }); + } + + it('allows an explicitly configured registry-to-auth-host mapping', () => { + assert.isTrue(isAllowedTokenServiceRealm( + 'https://auth.example/token', + 'https://registry.example/v2/', + ['registry.example=auth.example'], + )); + }); + }); + + describe('canForwardCredentialToTokenService', () => { + it('allows Basic and refresh credentials for exact HTTP localhost authority', () => { + const realm = 'http://localhost:5000/token'; + assert.isTrue(canForwardCredentialToTokenService(realm, 'https://localhost:5000/v2/', 'basic')); + assert.isTrue(canForwardCredentialToTokenService(realm, 'https://localhost:5000/v2/', 'refreshToken')); + }); + + it('rejects credentials over remote HTTP even for the same authority', () => { + const realm = 'http://registry.example/token'; + assert.isFalse(canForwardCredentialToTokenService(realm, 'https://registry.example/v2/', 'basic')); + assert.isFalse(canForwardCredentialToTokenService(realm, 'https://registry.example/v2/', 'refreshToken')); + }); + + it('allows only Basic credentials for the Docker Hub token service', () => { + const realm = 'https://auth.docker.io/token'; + assert.isTrue(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'basic')); + assert.isFalse(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'refreshToken')); + }); + + it('allows only Basic credentials for an explicitly configured mapping', () => { + const realm = 'https://auth.example/token'; + const registryUrl = 'https://registry.example/v2/'; + const configured = ['registry.example=auth.example']; + assert.isTrue(canForwardCredentialToTokenService(realm, registryUrl, 'basic', configured)); + assert.isFalse(canForwardCredentialToTokenService(realm, registryUrl, 'refreshToken', configured)); + }); + + it('rejects credentials for token services owned by another registry', () => { + assert.isFalse(canForwardCredentialToTokenService('https://auth.docker.io/token', 'https://attacker.example/v2/', 'basic')); + assert.isFalse(canForwardCredentialToTokenService('https://ghcr.io/token', 'https://attacker.example/v2/', 'basic')); + assert.isFalse(canForwardCredentialToTokenService('https://registry.azurecr.io/token', 'https://attacker.example/v2/', 'refreshToken')); + }); + }); + + describe('parseCrossOriginAuthHosts', () => { + it('normalizes authorities and preserves ports', () => { + const parsed = parseCrossOriginAuthHosts(['REGISTRY.EXAMPLE:8443=AUTH.EXAMPLE:9443']); + assert.deepEqual([...parsed.get('registry.example:8443')!], ['auth.example:9443']); + }); + + for (const entry of [ + 'auth.example', + '=auth.example', + 'registry.example=', + 'https://registry.example=auth.example', + 'registry.example=https://auth.example', + 'registry.example/path=auth.example', + ]) { + it(`rejects malformed mapping '${entry}'`, () => { + assert.throws(() => parseCrossOriginAuthHosts([entry])); + }); + } + }); + + it('does not request a rejected bearer token realm', async () => { + let registryRequests = 0; + let tokenRequests = 0; + const tokenServer = http.createServer((_request, response) => { + tokenRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const tokenPort = await listen(tokenServer); + const registryServer = http.createServer((_request, response) => { + registryRequests++; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + + try { + const registry = `127.0.0.1:${registryPort}`; + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + const cachedAuthHeader: Record = {}; + + const result = await requestEnsureAuthenticated({ env: {}, output: nullLog, cachedAuthHeader }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 1); + assert.equal(tokenRequests, 0); + assert.notProperty(cachedAuthHeader, registry); + } finally { + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + + it('does not follow redirects from a bearer token realm', async () => { + let redirectTargetRequests = 0; + const redirectTargetServer = http.createServer((_request, response) => { + redirectTargetRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const redirectTargetPort = await listen(redirectTargetServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + if (request.url?.startsWith('/token')) { + response.writeHead(302, { location: `http://localhost:${redirectTargetPort}/token` }); + response.end(); + return; + } + + const registryPort = (registryServer.address() as AddressInfo).port; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token",service="localhost:${registryPort}",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, + output: nullLog, + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 2); + assert.equal(redirectTargetRequests, 0); + } finally { + await Promise.all([close(registryServer), close(redirectTargetServer)]); + } + }); + + it('encodes bearer token service and scope query values', async () => { + const service = 'registry.example&injected=service#fragment'; + const scope = 'repository:test:pull&injected=scope#fragment'; + const token = 'registry-token'; + let registryRequests = 0; + let tokenRequests = 0; + const registryServer = http.createServer((request, response) => { + const registryPort = (registryServer.address() as AddressInfo).port; + if (request.url?.startsWith('/token')) { + tokenRequests++; + const tokenUrl = new URL(request.url, `http://localhost:${registryPort}`); + assert.equal(tokenUrl.searchParams.get('existing'), 'value'); + assert.equal(tokenUrl.searchParams.get('service'), service); + assert.equal(tokenUrl.searchParams.get('scope'), scope); + assert.isFalse(tokenUrl.searchParams.has('injected')); + response.end(JSON.stringify({ token })); + return; + } + + registryRequests++; + if (request.headers.authorization === `Bearer ${token}`) { + response.writeHead(200); + response.end(); + return; + } + + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token?existing=value#realm-fragment",service="${service}",scope="${scope}"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, + output: nullLog, + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + } finally { + await close(registryServer); + } + }); +}); + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +} \ No newline at end of file From 7b8e4960f7163d119c82335586f3f1c2e225a675 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 11 Aug 2026 17:45:59 +0200 Subject: [PATCH 2/3] Pass OCI auth hosts as CLI state Propagate cross-origin auth host mappings explicitly through command, resolver, and registry request parameters instead of serializing them through the process environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-common/injectHeadless.ts | 1 + .../containerCollectionsOCI.ts | 1 + .../containerFeaturesConfiguration.ts | 1 + src/spec-configuration/httpOCIRegistry.ts | 17 +----- src/spec-node/devContainers.ts | 2 + src/spec-node/devContainersSpecCLI.ts | 41 +++++++++----- src/spec-node/featureUtils.ts | 2 +- src/spec-node/featuresCLI/info.ts | 7 +-- src/spec-node/featuresCLI/publish.ts | 9 ++-- .../featuresCLI/resolveDependencies.ts | 6 ++- src/spec-node/templatesCLI/apply.ts | 8 +-- src/spec-node/templatesCLI/metadata.ts | 7 +-- src/spec-node/templatesCLI/publish.ts | 9 ++-- src/spec-node/upgradeCommand.ts | 7 ++- src/spec-node/utils.ts | 7 +-- src/spec-shutdown/dockerUtils.ts | 1 + src/test/cli.test.ts | 5 ++ src/test/httpOCIRegistry.test.ts | 54 +++++++++++++++++++ 18 files changed, 130 insertions(+), 55 deletions(-) diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index e7770d8f0..12b2f6035 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -69,6 +69,7 @@ export interface ResolverParameters { omitConfigRemotEnvFromMetadata?: boolean; secretsP?: Promise>; omitSyntaxDirective?: boolean; + allowedCrossOriginAuthHosts?: string[]; } export interface LifecycleHook { diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index a2e4ad55f..6ff7321f4 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -18,6 +18,7 @@ export interface CommonParams { env: NodeJS.ProcessEnv; output: Log; cachedAuthHeader?: Record; // + allowedCrossOriginAuthHosts?: string[]; } // Represents the unique OCI identifier for a Feature or Template. diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 5957d0896..a36caf460 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -195,6 +195,7 @@ export interface ContainerFeatureInternalParams { platform: NodeJS.Platform; noLockfile?: boolean; frozenLockfile?: boolean; + allowedCrossOriginAuthHosts?: string[]; } // TODO: Move to node layer. diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index c64758747..b0df49e4d 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -37,8 +37,6 @@ const scopeRegex = /scope="([^"]+)"/; type RegistryCredentialType = 'basic' | 'refreshToken'; -export const allowCrossOriginAuthHostEnv = 'DEVCONTAINERS_INTERNAL_ALLOW_CROSS_ORIGIN_AUTH_HOST'; - const builtInCrossOriginAuthHosts = [ 'registry-1.docker.io=auth.docker.io', 'registry.docker.io=auth.docker.io', @@ -76,19 +74,6 @@ export function parseCrossOriginAuthHosts(entries: readonly string[]): Map typeof entry !== 'string')) { - throw new Error(`Invalid ${allowCrossOriginAuthHostEnv} value.`); - } - configuredEntries = parsed; - } - return parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]); -} - function isConfiguredCrossOriginAuthHost(registryUrl: URL, realmUrl: URL, crossOriginAuthHosts: Map>) { return crossOriginAuthHosts.get(registryUrl.host.toLowerCase())?.has(realmUrl.host.toLowerCase()) || false; } @@ -235,7 +220,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio } let crossOriginAuthHosts: Map>; try { - crossOriginAuthHosts = getCrossOriginAuthHosts(params.env); + crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); } catch (err) { output.write(`[httpOci] ERR: ${err}`, LogLevel.Error); return; diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 6ceed1951..db0c0991e 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -74,6 +74,7 @@ export interface ProvisionOptions { omitSyntaxDirective?: boolean; includeConfig?: boolean; includeMergedConfig?: boolean; + allowedCrossOriginAuthHosts?: string[]; } export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { @@ -162,6 +163,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPath: options.dotfiles.targetPath || '~/dotfiles', }, omitSyntaxDirective: options.omitSyntaxDirective, + allowedCrossOriginAuthHosts: options.allowedCrossOriginAuthHosts, }; const dockerPath = options.dockerPath || 'docker'; diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 8dc4a5605..de6cb0e0f 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -45,7 +45,7 @@ import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './feat import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; -import { allowCrossOriginAuthHostEnv, parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; +import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -70,14 +70,14 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa .option('allow-cross-origin-auth-host', { type: 'string', array: true, + nargs: 1, global: true, description: 'Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.', }) - .middleware(args => { - const entries = args['allow-cross-origin-auth-host'] || []; - parseCrossOriginAuthHosts(entries); - process.env[allowCrossOriginAuthHostEnv] = JSON.stringify(entries); - }, true) + .check(args => { + parseCrossOriginAuthHosts(getAllowedCrossOriginAuthHosts(args as OciAuthArgs)); + return true; + }) .strict(); y.wrap(Math.min(120, y.terminalWidth())); y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); @@ -108,6 +108,11 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa })().catch(console.error); export type UnpackArgv = T extends Argv ? U : T; +export type OciAuthArgs = { 'allow-cross-origin-auth-host'?: string[] }; + +export function getAllowedCrossOriginAuthHosts(args: OciAuthArgs) { + return args['allow-cross-origin-auth-host'] || []; +} function provisionOptions(y: Argv) { return y.options({ @@ -188,7 +193,7 @@ function provisionOptions(y: Argv) { }); } -type ProvisionArgs = UnpackArgv>; +type ProvisionArgs = UnpackArgv> & OciAuthArgs; function provisionHandler(args: ProvisionArgs) { runAsyncHandler(provision.bind(null, args)); @@ -241,6 +246,7 @@ async function provision({ 'omit-syntax-directive': omitSyntaxDirective, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: ProvisionArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); @@ -315,6 +321,7 @@ async function provision({ omitSyntaxDirective, includeConfig, includeMergedConfig, + allowedCrossOriginAuthHosts, }; const result = await doProvision(options, providedIdLabels); @@ -395,7 +402,7 @@ function setUpOptions(y: Argv) { }); } -type SetUpArgs = UnpackArgv>; +type SetUpArgs = UnpackArgv> & OciAuthArgs; function setUpHandler(args: SetUpArgs) { runAsyncHandler(setUp.bind(null, args)); @@ -432,6 +439,7 @@ async function doSetUp({ 'container-session-data-folder': containerSessionDataFolder, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: SetUpArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -482,6 +490,7 @@ async function doSetUp({ installCommand: dotfilesInstallCommand, targetPath: dotfilesTargetPath, }, + allowedCrossOriginAuthHosts, }, disposables); const { common } = params; @@ -573,7 +582,7 @@ function buildOptions(y: Argv) { }); } -type BuildArgs = UnpackArgv>; +type BuildArgs = UnpackArgv> & OciAuthArgs; function buildHandler(args: BuildArgs) { runAsyncHandler(build.bind(null, args)); @@ -614,6 +623,7 @@ async function doBuild({ 'no-lockfile': noLockfile, 'frozen-lockfile': frozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: BuildArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; @@ -667,6 +677,7 @@ async function doBuild({ noLockfile, frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, + allowedCrossOriginAuthHosts, }, disposables); const { common, dockerComposeCLI } = params; @@ -842,7 +853,7 @@ function runUserCommandsOptions(y: Argv) { }); } -type RunUserCommandsArgs = UnpackArgv>; +type RunUserCommandsArgs = UnpackArgv> & OciAuthArgs; function runUserCommandsHandler(args: RunUserCommandsArgs) { runAsyncHandler(runUserCommands.bind(null, args)); @@ -1035,7 +1046,7 @@ function readConfigurationOptions(y: Argv) { }); } -type ReadConfigurationArgs = UnpackArgv>; +type ReadConfigurationArgs = UnpackArgv> & OciAuthArgs; function readConfigurationHandler(args: ReadConfigurationArgs) { runAsyncHandler(readConfiguration.bind(null, args)); @@ -1060,6 +1071,7 @@ async function readConfiguration({ 'include-merged-configuration': includeMergedConfig, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: ReadConfigurationArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1116,7 +1128,8 @@ async function readConfiguration({ env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo: buildPlatformInfo + targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1174,7 +1187,7 @@ function outdatedOptions(y: Argv) { }); } -type OutdatedArgs = UnpackArgv>; +type OutdatedArgs = UnpackArgv> & OciAuthArgs; function outdatedHandler(args: OutdatedArgs) { runAsyncHandler(outdated.bind(null, args)); @@ -1189,6 +1202,7 @@ async function outdated({ 'log-format': logFormat, 'terminal-rows': terminalRows, 'terminal-columns': terminalColumns, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: OutdatedArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1225,6 +1239,7 @@ async function outdated({ env: cliHost.env, skipFeatureAutoMapping: false, platform: cliHost.platform, + allowedCrossOriginAuthHosts, }; const outdated = await loadVersionInfo(params, configs.config.config); diff --git a/src/spec-node/featureUtils.ts b/src/spec-node/featureUtils.ts index a36205295..fc6713e53 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -9,5 +9,5 @@ export async function readFeaturesConfig(params: DockerCLIParameters, pkg: Packa const { cwd, env, platform } = cliHost; const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg }); const cacheFolder = await getCacheFolder(cliHost); - return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform, noLockfile: true }, featuresTmpFolder, config, additionalFeatures); + return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform, noLockfile: true, allowedCrossOriginAuthHosts: params.allowedCrossOriginAuthHosts }, featuresTmpFolder, config, additionalFeatures); } \ No newline at end of file diff --git a/src/spec-node/featuresCLI/info.ts b/src/spec-node/featuresCLI/info.ts index 9d721b651..0c1331358 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -3,7 +3,7 @@ import { OCIManifest, OCIRef, fetchOCIManifestIfExists, getPublishedTags, getRef import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { buildDependencyGraph, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder'; import { DevContainerFeature } from '../../spec-configuration/configuration'; import { processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; @@ -19,7 +19,7 @@ export function featuresInfoOptions(y: Argv) { .positional('feature', { type: 'string', demandOption: true, description: 'Feature Identifier' }); } -export type FeaturesInfoArgs = UnpackArgv>; +export type FeaturesInfoArgs = UnpackArgv> & OciAuthArgs; export function featuresInfoHandler(args: FeaturesInfoArgs) { runAsyncHandler(featuresInfo.bind(null, args)); @@ -36,6 +36,7 @@ async function featuresInfo({ 'feature': featureId, 'log-level': inputLogLevel, 'output-format': outputFormat, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: FeaturesInfoArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -51,7 +52,7 @@ async function featuresInfo({ terminalDimensions: undefined, }, pkg, new Date(), disposables, true); - const params = { output, env: process.env, outputFormat }; + const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts }; const jsonOutput: InfoJsonOutput = {}; diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts index 42259a057..a57e635bf 100644 --- a/src/spec-node/featuresCLI/publish.ts +++ b/src/spec-node/featuresCLI/publish.ts @@ -5,7 +5,7 @@ import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { rmLocal } from '../../spec-utils/pfs'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { doFeaturesPackageCommand } from './packageCommandImpl'; import { getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; @@ -21,7 +21,7 @@ export function featuresPublishOptions(y: Argv) { return publishOptions(y, 'feature'); } -export type FeaturesPublishArgs = UnpackArgv>; +export type FeaturesPublishArgs = UnpackArgv> & OciAuthArgs; export function featuresPublishHandler(args: FeaturesPublishArgs) { runAsyncHandler(featuresPublish.bind(null, args)); @@ -31,7 +31,8 @@ async function featuresPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: FeaturesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -49,7 +50,7 @@ async function featuresPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts }; // Package features const outputDir = path.join(os.tmpdir(), `/features-output-${Date.now()}`); diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 3c24c3788..685f88a4d 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -3,7 +3,7 @@ import { Argv } from 'yargs'; import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { isLocalFile } from '../../spec-utils/pfs'; import { DevContainerFeature } from '../../spec-configuration/configuration'; import { buildDependencyGraph, computeDependsOnInstallationOrder, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder'; @@ -34,7 +34,7 @@ export function featuresResolveDependenciesOptions(y: Argv) { }); } -export type featuresResolveDependenciesArgs = UnpackArgv>; +export type featuresResolveDependenciesArgs = UnpackArgv> & OciAuthArgs; export function featuresResolveDependenciesHandler(args: featuresResolveDependenciesArgs) { runAsyncHandler(featuresResolveDependencies.bind(null, args)); @@ -43,6 +43,7 @@ export function featuresResolveDependenciesHandler(args: featuresResolveDependen async function featuresResolveDependencies({ 'workspace-folder': workspaceFolderArg, 'log-level': inputLogLevel, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: featuresResolveDependenciesArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -73,6 +74,7 @@ async function featuresResolveDependencies({ const params = { output, env: process.env, + allowedCrossOriginAuthHosts, }; const cwd = workspaceFolder || process.cwd(); diff --git a/src/spec-node/templatesCLI/apply.ts b/src/spec-node/templatesCLI/apply.ts index 0fb25c932..ba1dceabd 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -3,7 +3,7 @@ import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; import * as jsonc from 'jsonc-parser'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { fetchTemplate, SelectedTemplate, TemplateFeatureOption, TemplateOptions } from '../../spec-configuration/containerTemplatesOCI'; import { runAsyncHandler } from '../utils'; import path from 'path'; @@ -24,7 +24,7 @@ export function templateApplyOptions(y: Argv) { }); } -export type TemplateApplyArgs = UnpackArgv>; +export type TemplateApplyArgs = UnpackArgv> & OciAuthArgs; export function templateApplyHandler(args: TemplateApplyArgs) { runAsyncHandler(templateApply.bind(null, args)); @@ -38,6 +38,7 @@ async function templateApply({ 'log-level': inputLogLevel, 'tmp-dir': userProvidedTmpDir, 'omit-paths': omitPathsArg, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: TemplateApplyArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -87,7 +88,7 @@ async function templateApply({ omitPaths, }; - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, workspaceFolder, userProvidedTmpDir); + const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts }, selectedTemplate, workspaceFolder, userProvidedTmpDir); if (!files) { output.write(`Failed to fetch template '${id}'.`, LogLevel.Error); process.exit(1); @@ -152,4 +153,3 @@ function hasJsonParseError(output: Log, errors: jsonc.ParseError[]) { } return errors.length > 0; } - diff --git a/src/spec-node/templatesCLI/metadata.ts b/src/spec-node/templatesCLI/metadata.ts index 6a98848d6..935d071f7 100644 --- a/src/spec-node/templatesCLI/metadata.ts +++ b/src/spec-node/templatesCLI/metadata.ts @@ -4,7 +4,7 @@ import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; import { fetchOCIManifestIfExists, getRef } from '../../spec-configuration/containerCollectionsOCI'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { runAsyncHandler } from '../utils'; export function templateMetadataOptions(y: Argv) { @@ -15,7 +15,7 @@ export function templateMetadataOptions(y: Argv) { .positional('templateId', { type: 'string', demandOption: true, description: 'Template Identifier' }); } -export type TemplateMetadataArgs = UnpackArgv>; +export type TemplateMetadataArgs = UnpackArgv> & OciAuthArgs; export function templateMetadataHandler(args: TemplateMetadataArgs) { runAsyncHandler(templateMetadata.bind(null, args)); @@ -24,6 +24,7 @@ export function templateMetadataHandler(args: TemplateMetadataArgs) { async function templateMetadata({ 'log-level': inputLogLevel, 'templateId': templateId, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: TemplateMetadataArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -39,7 +40,7 @@ async function templateMetadata({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts }; output.write(`Fetching metadata for ${templateId}`, LogLevel.Trace); const templateRef = getRef(output, templateId); diff --git a/src/spec-node/templatesCLI/publish.ts b/src/spec-node/templatesCLI/publish.ts index cff9bb1f0..581dae19a 100644 --- a/src/spec-node/templatesCLI/publish.ts +++ b/src/spec-node/templatesCLI/publish.ts @@ -5,7 +5,7 @@ import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { rmLocal } from '../../spec-utils/pfs'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { publishOptions } from '../collectionCommonUtils/publish'; import { getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; @@ -22,7 +22,7 @@ export function templatesPublishOptions(y: Argv) { return publishOptions(y, 'template'); } -export type TemplatesPublishArgs = UnpackArgv>; +export type TemplatesPublishArgs = UnpackArgv> & OciAuthArgs; export function templatesPublishHandler(args: TemplatesPublishArgs) { runAsyncHandler(templatesPublish.bind(null, args)); @@ -32,7 +32,8 @@ async function templatesPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: TemplatesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -50,7 +51,7 @@ async function templatesPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts }; // Package templates const outputDir = path.join(os.tmpdir(), `/templates-output-${Date.now()}`); diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 8773fd5de..adb5e3807 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -1,5 +1,5 @@ import { Argv } from 'yargs'; -import { UnpackArgv } from './devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from './devContainersSpecCLI'; import { dockerComposeCLIConfig } from './dockerCompose'; import { Log, LogLevel, mapLogLevel } from '../spec-utils/log'; import { createLog } from './devContainers'; @@ -47,7 +47,7 @@ export function featuresUpgradeOptions(y: Argv) { }); } -export type FeaturesUpgradeArgs = UnpackArgv>; +export type FeaturesUpgradeArgs = UnpackArgv> & OciAuthArgs; export function featuresUpgradeHandler(args: FeaturesUpgradeArgs) { runAsyncHandler(featuresUpgrade.bind(null, args)); @@ -62,6 +62,7 @@ async function featuresUpgrade({ 'dry-run': dryRun, feature: feature, 'target-version': targetVersion, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, }: FeaturesUpgradeArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -98,6 +99,7 @@ async function featuresUpgrade({ output, buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, }; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); @@ -112,6 +114,7 @@ async function featuresUpgrade({ env: cliHost.env, skipFeatureAutoMapping: false, platform: cliHost.platform, + allowedCrossOriginAuthHosts, }; if (feature && targetVersion) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index e6cf6980f..f314fd19b 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -285,7 +285,8 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock throw inspectErr; } try { - return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName); + const allowedCrossOriginAuthHosts = 'cliHost' in params ? params.allowedCrossOriginAuthHosts : params.common.allowedCrossOriginAuthHosts; + return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts); } catch (inspectErr2) { output.write(`Error fetching image details: ${inspectErr2?.message}`, LogLevel.Info); } @@ -317,9 +318,9 @@ function logErrorStdoutStderr(err: any, output: Log) { } } -export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string): Promise { +export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[]): Promise { const resourceAndVersion = qualifyImageName(name); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts }; const ref = getRef(output, resourceAndVersion); if (!ref) { throw new Error(`Could not parse image name '${name}'`); diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 0531f6b87..9ec6e56df 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -54,6 +54,7 @@ export interface DockerCLIParameters { output: Log; buildPlatformInfo: PlatformInfo; targetPlatformInfo: PlatformInfo; + allowedCrossOriginAuthHosts?: string[]; } export interface PartialExecParameters { diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index f409cb0fd..584ff1cbe 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -27,6 +27,11 @@ describe('Dev Containers CLI', function () { assert.ok(res.stdout.indexOf('run-user-commands'), 'Help text is not mentioning run-user-commands.'); }); + it('Global options consume exactly one argument', async () => { + const res = await shellExec(`${cli} --allow-cross-origin-auth-host registry.example=auth.example features info --help`); + assert.ok(res.stdout.includes('devcontainer features info ')); + }); + describe('Command run-user-commands', () => { describe('with valid config', () => { let containerId: string | null = null; diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 40c6db6a3..f4bd8c509 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -148,6 +148,60 @@ describe('OCI registry authentication', () => { } }); + it('uses an explicitly configured registry-to-auth-host mapping', async () => { + const token = 'registry-token'; + const bearerScheme = ['Bear', 'er'].join(''); + let tokenRequests = 0; + const tokenServer = http.createServer((request, response) => { + tokenRequests++; + assert.equal(request.headers.authorization, `Basic ${Buffer.from('user:token').toString('base64')}`); + response.end(JSON.stringify({ token })); + }); + const tokenPort = await listen(tokenServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + if (request.headers.authorization === `${bearerScheme} ${token}`) { + response.writeHead(200); + response.end(); + return; + } + response.writeHead(401, { + 'WWW-Authenticate': `${bearerScheme} realm="https://localhost:${tokenPort}/token",service="registry.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, + output: nullLog, + allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + } finally { + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + it('does not follow redirects from a bearer token realm', async () => { let redirectTargetRequests = 0; const redirectTargetServer = http.createServer((_request, response) => { From 87adb63ae6894a469352a0a62befe4af9189193c Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 11 Aug 2026 18:03:46 +0200 Subject: [PATCH 3/3] Trust refresh tokens for mapped auth hosts Treat an exact registry-to-auth-host mapping as authorization for the complete token exchange, including Docker identity and refresh tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 22 +++++----- src/test/httpOCIRegistry.test.ts | 49 ++++++++++++++++++----- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index b0df49e4d..aa0e66989 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -86,7 +86,7 @@ function isAllowedSameAuthorityRealm(registryUrl: URL, realmUrl: URL) { || realmUrl.protocol === 'http:' && realmUrl.hostname.toLowerCase() === 'localhost'; } -function canForwardCredentialToTokenServiceForPolicy(realm: string, registryUrl: URL, credentialType: RegistryCredentialType, crossOriginAuthHosts: Map>): boolean { +function canForwardCredentialToTokenServiceForPolicy(realm: string, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { let realmUrl: URL; try { realmUrl = new URL(realm); @@ -98,14 +98,12 @@ function canForwardCredentialToTokenServiceForPolicy(realm: string, registryUrl: return true; } - return credentialType === 'basic' - && realmUrl.protocol === 'https:' + return realmUrl.protocol === 'https:' && isConfiguredCrossOriginAuthHost(registryUrl, realmUrl, crossOriginAuthHosts); } -// Endpoint admission and credential forwarding are separate policies. Refresh tokens -// never cross an origin boundary, even when Basic authentication is explicitly allowed. -export function canForwardCredentialToTokenService(realm: string, registryUrl: string, credentialType: RegistryCredentialType, configuredEntries: readonly string[] = []): boolean { +// A trusted registry-to-auth-host pair authorizes the registry's complete token exchange. +export function canForwardCredentialToTokenService(realm: string, registryUrl: string, _credentialType: RegistryCredentialType, configuredEntries: readonly string[] = []): boolean { let parsedRegistryUrl: URL; try { parsedRegistryUrl = new URL(registryUrl); @@ -116,7 +114,6 @@ export function canForwardCredentialToTokenService(realm: string, registryUrl: s return canForwardCredentialToTokenServiceForPolicy( realm, parsedRegistryUrl, - credentialType, parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) ); } @@ -488,8 +485,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O const userCredential = canUseRegistryCredentials ? await getCredential(params, ociRef) : undefined; const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; - const canForwardBasicCredential = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, 'basic', crossOriginAuthHosts); - const canForwardRefreshToken = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, 'refreshToken', crossOriginAuthHosts); + const canForwardCredential = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, crossOriginAuthHosts); let httpOptions: { type: string; url: string; headers: Record; data?: Buffer }; let sentCredentials = false; @@ -514,16 +510,16 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O }; }; - if (refreshToken && !canForwardRefreshToken) { + if (refreshToken && !canForwardCredential) { output.write(`[httpOci] Refusing to send refresh token to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); } - if (basicAuthCredential && !canForwardBasicCredential) { + if (basicAuthCredential && !canForwardCredential) { output.write(`[httpOci] Refusing to send Basic credential to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); } // There are several different ways registries expect to handle the oauth token exchange. // Depending on the type of credential available, use the most reasonable method. - if (refreshToken && canForwardRefreshToken) { + if (refreshToken && canForwardCredential) { const form_url_encoded = new URLSearchParams(); form_url_encoded.append('client_id', 'devcontainer'); form_url_encoded.append('grant_type', 'refresh_token'); @@ -550,7 +546,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O // scope="repository:samalba/my-app:pull,push" // Example: // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push - const authorization = basicAuthCredential && canForwardBasicCredential + const authorization = basicAuthCredential && canForwardCredential ? `Basic ${basicAuthCredential}` : undefined; httpOptions = createGetHttpOptions(authorization); diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index f4bd8c509..9fd25639b 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -1,5 +1,8 @@ import * as http from 'http'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; import { AddressInfo } from 'net'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { assert } from 'chai'; @@ -64,18 +67,18 @@ describe('OCI registry authentication', () => { assert.isFalse(canForwardCredentialToTokenService(realm, 'https://registry.example/v2/', 'refreshToken')); }); - it('allows only Basic credentials for the Docker Hub token service', () => { + it('allows Basic and refresh credentials for the Docker Hub token service', () => { const realm = 'https://auth.docker.io/token'; assert.isTrue(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'basic')); - assert.isFalse(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'refreshToken')); + assert.isTrue(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'refreshToken')); }); - it('allows only Basic credentials for an explicitly configured mapping', () => { + it('allows Basic and refresh credentials for an explicitly configured mapping', () => { const realm = 'https://auth.example/token'; const registryUrl = 'https://registry.example/v2/'; const configured = ['registry.example=auth.example']; assert.isTrue(canForwardCredentialToTokenService(realm, registryUrl, 'basic', configured)); - assert.isFalse(canForwardCredentialToTokenService(realm, registryUrl, 'refreshToken', configured)); + assert.isTrue(canForwardCredentialToTokenService(realm, registryUrl, 'refreshToken', configured)); }); it('rejects credentials for token services owned by another registry', () => { @@ -148,14 +151,25 @@ describe('OCI registry authentication', () => { } }); - it('uses an explicitly configured registry-to-auth-host mapping', async () => { + it('forwards a refresh token to an explicitly configured auth host', async () => { const token = 'registry-token'; + const refreshToken = 'registry-refresh-token'; const bearerScheme = ['Bear', 'er'].join(''); let tokenRequests = 0; - const tokenServer = http.createServer((request, response) => { + const tokenServer = http.createServer(async (request, response) => { tokenRequests++; - assert.equal(request.headers.authorization, `Basic ${Buffer.from('user:token').toString('base64')}`); - response.end(JSON.stringify({ token })); + try { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + const body = new URLSearchParams(Buffer.concat(chunks).toString()); + assert.equal(request.method, 'POST'); + assert.equal(body.get('refresh_token'), refreshToken); + response.end(JSON.stringify({ token })); + } catch (err) { + response.destroy(err as Error); + } }); const tokenPort = await listen(tokenServer); @@ -174,6 +188,17 @@ describe('OCI registry authentication', () => { }); const registryPort = await listen(registryServer); const registry = `localhost:${registryPort}`; + const dockerConfig = await mkdtemp(join(tmpdir(), 'devcontainers-oci-auth-')); + await writeFile(join(dockerConfig, 'config.json'), JSON.stringify({ + auths: { + [registry]: { + auth: '', + identitytoken: refreshToken, + }, + }, + })); + const previousDockerConfig = process.env.DOCKER_CONFIG; + process.env.DOCKER_CONFIG = dockerConfig; try { const ociRef: OCICollectionRef = { @@ -185,7 +210,7 @@ describe('OCI registry authentication', () => { }; const result = await requestEnsureAuthenticated({ - env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, + env: {}, output: nullLog, allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], }, { @@ -198,6 +223,12 @@ describe('OCI registry authentication', () => { assert.equal(registryRequests, 2); assert.equal(tokenRequests, 1); } finally { + if (previousDockerConfig === undefined) { + delete process.env.DOCKER_CONFIG; + } else { + process.env.DOCKER_CONFIG = previousDockerConfig; + } + await rm(dockerConfig, { recursive: true }); await Promise.all([close(registryServer), close(tokenServer)]); } });