From 05249c915306ca3e14a1ca0cc5498bbf478ab710 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 11 Aug 2026 17:04:39 +0200 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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)]); } }); From 146b161aef9cf1159de597f6fc059e7af6396859 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 13 Aug 2026 12:10:25 +0200 Subject: [PATCH 04/11] Simplify bearer auth test setup Use the literal HTTP authentication scheme instead of constructing it at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/test/httpOCIRegistry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 9fd25639b..9d3587022 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -154,7 +154,7 @@ describe('OCI registry authentication', () => { 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(''); + const bearerScheme = 'Bearer'; let tokenRequests = 0; const tokenServer = http.createServer(async (request, response) => { tokenRequests++; From c9783d01ce0e692d5978d998b6e39ad63e18627f Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 13 Aug 2026 12:41:40 +0200 Subject: [PATCH 05/11] Consolidate OCI auth realm policy Parse and validate token realms once before credential lookup and reuse that decision for the complete trusted authentication exchange. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 108 +++++----------------- src/test/httpOCIRegistry.test.ts | 36 +------- 2 files changed, 26 insertions(+), 118 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index aa0e66989..db2980b8a 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -35,8 +35,6 @@ const realmRegex = /realm="([^"]+)"/; const serviceRegex = /service="([^"]+)"/; const scopeRegex = /scope="([^"]+)"/; -type RegistryCredentialType = 'basic' | 'refreshToken'; - const builtInCrossOriginAuthHosts = [ 'registry-1.docker.io=auth.docker.io', 'registry.docker.io=auth.docker.io', @@ -86,46 +84,7 @@ function isAllowedSameAuthorityRealm(registryUrl: URL, realmUrl: URL) { || realmUrl.protocol === 'http:' && realmUrl.hostname.toLowerCase() === 'localhost'; } -function canForwardCredentialToTokenServiceForPolicy(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); -} - -// 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); - } catch { - return false; - } - - return canForwardCredentialToTokenServiceForPolicy( - realm, - parsedRegistryUrl, - parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) - ); -} - -function isAllowedTokenServiceRealmForPolicy(realm: string, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { - let realmUrl: URL; - try { - realmUrl = new URL(realm); - } catch { - return false; - } - +function isAllowedTokenServiceRealmForPolicy(realmUrl: URL, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { if (isAllowedSameAuthorityRealm(registryUrl, realmUrl)) { return true; } @@ -136,18 +95,15 @@ function isAllowedTokenServiceRealmForPolicy(realm: string, registryUrl: URL, cr // 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); + return isAllowedTokenServiceRealmForPolicy( + new URL(realm), + new URL(registryUrl), + parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) + ); } catch { return false; } - - return isAllowedTokenServiceRealmForPolicy( - realm, - parsedRegistryUrl, - parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) - ); } // https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate @@ -215,40 +171,34 @@ 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>; + let realmUrl: URL; + let registryUrl: URL; try { - crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); + realmUrl = new URL(realmGroup[1]); + registryUrl = new URL(initialAttemptRes.responseUrl); + const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); + if (!isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts)) { + delete cachedAuthHeader[ociRef.registry]; + 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; + } } 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], + realm: realmUrl, service: serviceGroup[1], scope: scopeGroup ? scopeGroup[1] : '', }; const requestedRegistryUrl = new URL(httpOptions.url); const canUseRegistryCredentials = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); - const bearerToken = await fetchRegistryBearerToken(params, ociRef, registryUrl, crossOriginAuthHosts, canUseRegistryCredentials, wwwAuthenticateData); + const bearerToken = await fetchRegistryBearerToken(params, ociRef, canUseRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); return; @@ -472,7 +422,7 @@ 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, registryUrl: URL, crossOriginAuthHosts: Map>, canUseRegistryCredentials: boolean, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, canUseRegistryCredentials: boolean, wwwAuthenticateData: { realm: URL; service: string; scope: string }): Promise { const { output } = params; const { realm, service, scope } = wwwAuthenticateData; @@ -485,7 +435,6 @@ 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 canForwardCredential = canForwardCredentialToTokenServiceForPolicy(realm, registryUrl, crossOriginAuthHosts); let httpOptions: { type: string; url: string; headers: Record; data?: Buffer }; let sentCredentials = false; @@ -510,16 +459,9 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O }; }; - if (refreshToken && !canForwardCredential) { - output.write(`[httpOci] Refusing to send refresh token to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); - } - 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 && canForwardCredential) { + if (refreshToken) { const form_url_encoded = new URLSearchParams(); form_url_encoded.append('client_id', 'devcontainer'); form_url_encoded.append('grant_type', 'refresh_token'); @@ -527,7 +469,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O form_url_encoded.append('scope', scope); form_url_encoded.append('refresh_token', refreshToken); - const url = realm; + const url = realm.toString(); output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); httpOptions = { @@ -546,7 +488,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 && canForwardCredential + const authorization = basicAuthCredential ? `Basic ${basicAuthCredential}` : undefined; httpOptions = createGetHttpOptions(authorization); diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 9d3587022..e80f56a19 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -7,7 +7,7 @@ import { join } from 'path'; import { assert } from 'chai'; import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; -import { canForwardCredentialToTokenService, isAllowedTokenServiceRealm, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { isAllowedTokenServiceRealm, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; import { nullLog } from '../spec-utils/log'; describe('OCI registry authentication', () => { @@ -54,40 +54,6 @@ describe('OCI registry authentication', () => { }); }); - 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 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.isTrue(canForwardCredentialToTokenService(realm, 'https://registry-1.docker.io/v2/', 'refreshToken')); - }); - - 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.isTrue(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']); From 5a7dc8768e10924b3e64fcefb9e0dd64080e1afb Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 13 Aug 2026 13:00:01 +0200 Subject: [PATCH 06/11] Clarify registry credential gate Name the redirect-origin check after the requested registry credentials it protects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index db2980b8a..10929cce7 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -197,8 +197,8 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio }; const requestedRegistryUrl = new URL(httpOptions.url); - const canUseRegistryCredentials = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); - const bearerToken = await fetchRegistryBearerToken(params, ociRef, canUseRegistryCredentials, wwwAuthenticateData); + const challengeCanUseRequestedRegistryCredentials = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); + const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); return; @@ -422,7 +422,7 @@ 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, canUseRegistryCredentials: boolean, wwwAuthenticateData: { realm: URL; service: string; scope: string }): Promise { +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, challengeCanUseRequestedRegistryCredentials: boolean, wwwAuthenticateData: { realm: URL; service: string; scope: string }): Promise { const { output } = params; const { realm, service, scope } = wwwAuthenticateData; @@ -432,7 +432,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O // 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 = canUseRegistryCredentials ? await getCredential(params, ociRef) : undefined; + const userCredential = challengeCanUseRequestedRegistryCredentials ? await getCredential(params, ociRef) : undefined; const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; From 7c650d527eeae5004b85bb247a955d82657bf9d2 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 14 Aug 2026 08:44:52 +0200 Subject: [PATCH 07/11] Make OCI auth hardening opt-in Gate realm restrictions, registry credential origin checks, and token redirect refusal behind --oci-auth-hardening while preserving legacy behavior by default. 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 | 28 +++++---- src/spec-node/devContainers.ts | 2 + src/spec-node/devContainersSpecCLI.ts | 28 ++++++++- src/spec-node/featureUtils.ts | 13 +++- src/spec-node/featuresCLI/info.ts | 3 +- src/spec-node/featuresCLI/publish.ts | 3 +- .../featuresCLI/resolveDependencies.ts | 2 + src/spec-node/templatesCLI/apply.ts | 3 +- src/spec-node/templatesCLI/metadata.ts | 3 +- src/spec-node/templatesCLI/publish.ts | 3 +- src/spec-node/upgradeCommand.ts | 3 + src/spec-node/utils.ts | 7 ++- src/spec-shutdown/dockerUtils.ts | 1 + src/test/cli.test.ts | 2 +- src/test/httpOCIRegistry.test.ts | 59 ++++++++++++++++++- 18 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index 12b2f6035..84c385168 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -70,6 +70,7 @@ export interface ResolverParameters { secretsP?: Promise>; omitSyntaxDirective?: boolean; allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } export interface LifecycleHook { diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 6ff7321f4..9a2414cef 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -19,6 +19,7 @@ export interface CommonParams { output: Log; cachedAuthHeader?: Record; // allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } // 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 a36caf460..7aed59a9d 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -196,6 +196,7 @@ export interface ContainerFeatureInternalParams { noLockfile?: boolean; frozenLockfile?: boolean; allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } // TODO: Move to node layer. diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 10929cce7..28553555e 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -176,14 +176,16 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio try { realmUrl = new URL(realmGroup[1]); registryUrl = new URL(initialAttemptRes.responseUrl); - const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); - if (!isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts)) { - delete cachedAuthHeader[ociRef.registry]; - 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; + if (params.ociAuthHardening) { + const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); + if (!isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts)) { + delete cachedAuthHeader[ociRef.registry]; + 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; + } } } catch (err) { output.write(`[httpOci] ERR: ${err}`, LogLevel.Error); @@ -197,7 +199,8 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio }; const requestedRegistryUrl = new URL(httpOptions.url); - const challengeCanUseRequestedRegistryCredentials = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); + const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening + || requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); @@ -496,9 +499,10 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O output.write(`[httpOci] Attempting to fetch bearer token from: ${httpOptions.url}`, LogLevel.Trace); } - let res: Awaited>; + const requestToken = params.ociAuthHardening ? requestResolveHeadersNoRedirects : requestResolveHeaders; + let res: Awaited>; try { - res = await requestResolveHeadersNoRedirects(httpOptions, output); + res = await requestToken(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(); @@ -508,7 +512,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O // Build a fresh GET so neither an Authorization header nor a refresh-token POST body is reused. httpOptions = createGetHttpOptions(); - res = await requestResolveHeadersNoRedirects(httpOptions, output); + res = await requestToken(httpOptions, output); } } catch (err) { output.write(`[httpOci] Failed to request bearer token for '${service}': ${err}`, LogLevel.Error); diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index db0c0991e..f777f8df9 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -75,6 +75,7 @@ export interface ProvisionOptions { includeConfig?: boolean; includeMergedConfig?: boolean; allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { @@ -164,6 +165,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: }, omitSyntaxDirective: options.omitSyntaxDirective, allowedCrossOriginAuthHosts: options.allowedCrossOriginAuthHosts, + ociAuthHardening: options.ociAuthHardening, }; const dockerPath = options.dockerPath || 'docker'; diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index de6cb0e0f..7a131ae0e 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -67,6 +67,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa .scriptName('devcontainer') .version(version) .demandCommand() + .option('oci-auth-hardening', { + type: 'boolean', + default: false, + global: true, + description: 'Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects.', + }) .option('allow-cross-origin-auth-host', { type: 'string', array: true, @@ -75,7 +81,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa description: 'Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.', }) .check(args => { - parseCrossOriginAuthHosts(getAllowedCrossOriginAuthHosts(args as OciAuthArgs)); + const ociAuthArgs = args as OciAuthArgs; + const allowedCrossOriginAuthHosts = getAllowedCrossOriginAuthHosts(ociAuthArgs); + if (allowedCrossOriginAuthHosts.length && !ociAuthArgs['oci-auth-hardening']) { + throw new Error('--allow-cross-origin-auth-host requires --oci-auth-hardening.'); + } + parseCrossOriginAuthHosts(allowedCrossOriginAuthHosts); return true; }) .strict(); @@ -108,7 +119,10 @@ 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 type OciAuthArgs = { + 'allow-cross-origin-auth-host'?: string[]; + 'oci-auth-hardening'?: boolean; +}; export function getAllowedCrossOriginAuthHosts(args: OciAuthArgs) { return args['allow-cross-origin-auth-host'] || []; @@ -247,6 +261,7 @@ async function provision({ 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: ProvisionArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); @@ -322,6 +337,7 @@ async function provision({ includeConfig, includeMergedConfig, allowedCrossOriginAuthHosts, + ociAuthHardening, }; const result = await doProvision(options, providedIdLabels); @@ -440,6 +456,7 @@ async function doSetUp({ 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: SetUpArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -491,6 +508,7 @@ async function doSetUp({ targetPath: dotfilesTargetPath, }, allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common } = params; @@ -624,6 +642,7 @@ async function doBuild({ 'frozen-lockfile': frozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: BuildArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; @@ -678,6 +697,7 @@ async function doBuild({ frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common, dockerComposeCLI } = params; @@ -1072,6 +1092,7 @@ async function readConfiguration({ 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: ReadConfigurationArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1130,6 +1151,7 @@ async function readConfiguration({ buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, allowedCrossOriginAuthHosts, + ociAuthHardening, }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1203,6 +1225,7 @@ async function outdated({ 'terminal-rows': terminalRows, 'terminal-columns': terminalColumns, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: OutdatedArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1240,6 +1263,7 @@ async function outdated({ skipFeatureAutoMapping: false, platform: cliHost.platform, allowedCrossOriginAuthHosts, + ociAuthHardening, }; const outdated = await loadVersionInfo(params, configs.config.config); diff --git a/src/spec-node/featureUtils.ts b/src/spec-node/featureUtils.ts index fc6713e53..5bedd5798 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -9,5 +9,16 @@ 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, allowedCrossOriginAuthHosts: params.allowedCrossOriginAuthHosts }, featuresTmpFolder, config, additionalFeatures); + return generateFeaturesConfig({ + extensionPath, + cacheFolder, + cwd, + output, + env, + skipFeatureAutoMapping, + platform, + noLockfile: true, + allowedCrossOriginAuthHosts: params.allowedCrossOriginAuthHosts, + ociAuthHardening: params.ociAuthHardening, + }, 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 0c1331358..cd88d06df 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -37,6 +37,7 @@ async function featuresInfo({ 'log-level': inputLogLevel, 'output-format': outputFormat, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesInfoArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -52,7 +53,7 @@ async function featuresInfo({ terminalDimensions: undefined, }, pkg, new Date(), disposables, true); - const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts }; + const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts, ociAuthHardening }; const jsonOutput: InfoJsonOutput = {}; diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts index a57e635bf..bff3d21b6 100644 --- a/src/spec-node/featuresCLI/publish.ts +++ b/src/spec-node/featuresCLI/publish.ts @@ -33,6 +33,7 @@ async function featuresPublish({ 'registry': registry, 'namespace': namespace, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -50,7 +51,7 @@ async function featuresPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; // 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 685f88a4d..1856d3797 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -44,6 +44,7 @@ async function featuresResolveDependencies({ 'workspace-folder': workspaceFolderArg, 'log-level': inputLogLevel, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: featuresResolveDependenciesArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -75,6 +76,7 @@ async function featuresResolveDependencies({ output, env: process.env, allowedCrossOriginAuthHosts, + ociAuthHardening, }; const cwd = workspaceFolder || process.cwd(); diff --git a/src/spec-node/templatesCLI/apply.ts b/src/spec-node/templatesCLI/apply.ts index ba1dceabd..11a636cfb 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -39,6 +39,7 @@ async function templateApply({ 'tmp-dir': userProvidedTmpDir, 'omit-paths': omitPathsArg, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplateApplyArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -88,7 +89,7 @@ async function templateApply({ omitPaths, }; - const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts }, selectedTemplate, workspaceFolder, userProvidedTmpDir); + const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }, selectedTemplate, workspaceFolder, userProvidedTmpDir); if (!files) { output.write(`Failed to fetch template '${id}'.`, LogLevel.Error); process.exit(1); diff --git a/src/spec-node/templatesCLI/metadata.ts b/src/spec-node/templatesCLI/metadata.ts index 935d071f7..958fad235 100644 --- a/src/spec-node/templatesCLI/metadata.ts +++ b/src/spec-node/templatesCLI/metadata.ts @@ -25,6 +25,7 @@ async function templateMetadata({ 'log-level': inputLogLevel, 'templateId': templateId, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplateMetadataArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -40,7 +41,7 @@ async function templateMetadata({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; 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 581dae19a..2cb3aea30 100644 --- a/src/spec-node/templatesCLI/publish.ts +++ b/src/spec-node/templatesCLI/publish.ts @@ -34,6 +34,7 @@ async function templatesPublish({ 'registry': registry, 'namespace': namespace, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplatesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -51,7 +52,7 @@ async function templatesPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; // 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 adb5e3807..3926deb87 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -63,6 +63,7 @@ async function featuresUpgrade({ feature: feature, 'target-version': targetVersion, 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesUpgradeArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -100,6 +101,7 @@ async function featuresUpgrade({ buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, allowedCrossOriginAuthHosts, + ociAuthHardening, }; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); @@ -115,6 +117,7 @@ async function featuresUpgrade({ skipFeatureAutoMapping: false, platform: cliHost.platform, allowedCrossOriginAuthHosts, + ociAuthHardening, }; if (feature && targetVersion) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index f314fd19b..6ea5104fe 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -286,7 +286,8 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock } try { const allowedCrossOriginAuthHosts = 'cliHost' in params ? params.allowedCrossOriginAuthHosts : params.common.allowedCrossOriginAuthHosts; - return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts); + const ociAuthHardening = 'cliHost' in params ? params.ociAuthHardening : params.common.ociAuthHardening; + return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts, ociAuthHardening); } catch (inspectErr2) { output.write(`Error fetching image details: ${inspectErr2?.message}`, LogLevel.Info); } @@ -318,9 +319,9 @@ function logErrorStdoutStderr(err: any, output: Log) { } } -export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[]): Promise { +export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[], ociAuthHardening?: boolean): Promise { const resourceAndVersion = qualifyImageName(name); - const params = { output, env: process.env, allowedCrossOriginAuthHosts }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; 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 9ec6e56df..4daf4ff82 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -55,6 +55,7 @@ export interface DockerCLIParameters { buildPlatformInfo: PlatformInfo; targetPlatformInfo: PlatformInfo; allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } export interface PartialExecParameters { diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 584ff1cbe..a33220716 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -28,7 +28,7 @@ describe('Dev Containers CLI', function () { }); 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`); + const res = await shellExec(`${cli} --oci-auth-hardening --allow-cross-origin-auth-host registry.example=auth.example features info --help`); assert.ok(res.stdout.includes('devcontainer features info ')); }); diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index e80f56a19..e821fba29 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -102,7 +102,7 @@ describe('OCI registry authentication', () => { }; const cachedAuthHeader: Record = {}; - const result = await requestEnsureAuthenticated({ env: {}, output: nullLog, cachedAuthHeader }, { + const result = await requestEnsureAuthenticated({ env: {}, output: nullLog, cachedAuthHeader, ociAuthHardening: true }, { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, headers: {}, @@ -117,6 +117,61 @@ describe('OCI registry authentication', () => { } }); + it('uses cross-origin realms and follows token redirects when hardening is disabled', async () => { + const token = 'registry-token'; + const bearerScheme = 'Bearer'; + let redirectTargetRequests = 0; + const redirectTargetServer = http.createServer((_request, response) => { + redirectTargetRequests++; + response.end(JSON.stringify({ token })); + }); + const redirectTargetPort = await listen(redirectTargetServer); + + let tokenRequests = 0; + const tokenServer = http.createServer((_request, response) => { + tokenRequests++; + response.writeHead(307, { + location: `http://localhost:${redirectTargetPort}/token`, + }); + response.end(); + }); + const tokenPort = await listen(tokenServer); + + let registryRequests = 0; + const registryServer = http.createServer((_request, response) => { + registryRequests++; + response.writeHead(401, { + 'WWW-Authenticate': `${bearerScheme} realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `127.0.0.1:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ env: {}, output: nullLog }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 401); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + assert.equal(redirectTargetRequests, 1); + } finally { + await Promise.all([close(registryServer), close(tokenServer), close(redirectTargetServer)]); + } + }); + it('forwards a refresh token to an explicitly configured auth host', async () => { const token = 'registry-token'; const refreshToken = 'registry-refresh-token'; @@ -179,6 +234,7 @@ describe('OCI registry authentication', () => { env: {}, output: nullLog, allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], + ociAuthHardening: true, }, { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, @@ -237,6 +293,7 @@ describe('OCI registry authentication', () => { const result = await requestEnsureAuthenticated({ env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, output: nullLog, + ociAuthHardening: true, }, { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, From 3ba63a5509e9e820d35f1217900f86e0a4d4cbd0 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 14 Aug 2026 10:50:22 +0200 Subject: [PATCH 08/11] Report OCI auth hardening impact Collect shadow diagnostics for blocked auth realms, registry redirects that prevent credential forwarding, and token redirects, and surface them in CLI results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-common/injectHeadless.ts | 2 + src/spec-common/ociAuth.ts | 18 +++++++ .../containerCollectionsOCI.ts | 2 + .../containerFeaturesConfiguration.ts | 6 ++- src/spec-configuration/httpOCIRegistry.ts | 52 ++++++++++++++---- src/spec-node/configContainer.ts | 2 +- src/spec-node/devContainers.ts | 9 +++- src/spec-node/devContainersSpecCLI.ts | 7 ++- src/spec-node/dockerCompose.ts | 4 +- src/spec-node/featureUtils.ts | 1 + src/spec-node/featuresCLI/info.ts | 11 ++-- src/spec-node/featuresCLI/publish.ts | 3 +- .../featuresCLI/resolveDependencies.ts | 2 + src/spec-node/imageMetadata.ts | 3 +- src/spec-node/templatesCLI/apply.ts | 3 +- src/spec-node/templatesCLI/metadata.ts | 3 +- src/spec-node/templatesCLI/publish.ts | 3 +- src/spec-node/upgradeCommand.ts | 4 ++ src/spec-node/utils.ts | 8 +-- src/spec-shutdown/dockerUtils.ts | 2 + src/spec-utils/httpRequest.ts | 4 +- .../containerFeaturesOCI.test.ts | 5 +- .../containerFeaturesOCIPush.test.ts | 11 ++-- .../containerFeaturesOrder.test.ts | 6 +-- .../container-features/featureHelpers.test.ts | 3 +- .../featuresCLICommands.test.ts | 9 ++-- .../generateFeaturesConfig.test.ts | 4 +- .../containerTemplatesOCI.test.ts | 18 +++---- src/test/httpOCIRegistry.test.ts | 53 ++++++++++++++----- src/test/testUtils.ts | 18 +++++-- 30 files changed, 200 insertions(+), 76 deletions(-) create mode 100644 src/spec-common/ociAuth.ts diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index 84c385168..00254aee5 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -13,6 +13,7 @@ import { launch, ShellServer } from './shellServer'; import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { PackageConfiguration } from '../spec-utils/product'; +import { OCIAuthDiagnostics } from './ociAuth'; import { URI } from 'vscode-uri'; import { containerSubstitute } from './variableSubstitution'; import { delay } from './async'; @@ -71,6 +72,7 @@ export interface ResolverParameters { omitSyntaxDirective?: boolean; allowedCrossOriginAuthHosts?: string[]; ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } export interface LifecycleHook { diff --git a/src/spec-common/ociAuth.ts b/src/spec-common/ociAuth.ts new file mode 100644 index 000000000..b0817da38 --- /dev/null +++ b/src/spec-common/ociAuth.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface OCIAuthDiagnostics { + authLookupWouldBeBlocked: boolean; + registryRedirectWouldPreventCredentialForwarding: boolean; + authServerRedirect: boolean; +} + +export function createOCIAuthDiagnostics(): OCIAuthDiagnostics { + return { + authLookupWouldBeBlocked: false, + registryRedirectWouldPreventCredentialForwarding: false, + authServerRedirect: false, + }; +} diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 9a2414cef..12f26d2bd 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -8,6 +8,7 @@ import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; import { requestEnsureAuthenticated } from './httpOCIRegistry'; import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers'; export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar'; @@ -20,6 +21,7 @@ export interface CommonParams { cachedAuthHeader?: Record; // allowedCrossOriginAuthHosts?: string[]; ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } // 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 7aed59a9d..bfe049d07 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -19,6 +19,7 @@ import { request } from '../spec-utils/httpRequest'; import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI'; import { uriToFsPath } from './configurationCommonUtils'; import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; @@ -197,6 +198,7 @@ export interface ContainerFeatureInternalParams { frozenLockfile?: boolean; allowedCrossOriginAuthHosts?: string[]; ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } // TODO: Move to node layer. @@ -391,7 +393,7 @@ const cleanupIterationFetchAndMerge = async (tempTarballPath: string, output: Lo } }; -function getRequestHeaders(params: CommonParams, sourceInformation: SourceInformation) { +function getRequestHeaders(params: { env: NodeJS.ProcessEnv; output: Log }, sourceInformation: SourceInformation) { const { env, output } = params; let headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string } = { 'user-agent': 'devcontainer' @@ -957,7 +959,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // throw new Error(`Unsupported feature source type: ${type}`); } -async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { +async function fetchFeatures(params: ContainerFeatureInternalParams, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { const featureSets = featuresConfig.featureSets; for (let idx = 0; idx < featureSets.length; idx++) { // Index represents the previously computed installation order. const featureSet = featureSets[idx]; diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 28553555e..83d58ce12 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -7,6 +7,7 @@ import { requestResolveHeaders, requestResolveHeadersNoRedirects } from '../spec import { LogLevel } from '../spec-utils/log'; import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export type HEADERS = { 'authorization'?: string; 'user-agent'?: string; 'content-type'?: string; 'Accept'?: string; 'content-length'?: string }; @@ -106,6 +107,31 @@ export function isAllowedTokenServiceRealm(realm: string, registryUrl: string, c } } +function recordOCIAuthDiagnostic(params: CommonParams, key: keyof OCIAuthDiagnostics, message: string) { + if (!params.ociAuthDiagnostics[key]) { + params.ociAuthDiagnostics[key] = true; + params.output.write(`[httpOci] OCI auth diagnostics: ${message}`, LogLevel.Info); + } +} + +function withOCIAuthDiagnostics(params: CommonParams, result: T) { + return { + ...result, + ociAuthDiagnostics: { ...params.ociAuthDiagnostics }, + }; +} + +function recordAuthServerRedirect(params: CommonParams, requestedUrl: string, response: { responseUrl: string; redirected: boolean }) { + if (response.redirected) { + const requestedOrigin = new URL(requestedUrl).origin; + const responseOrigin = new URL(response.responseUrl).origin; + const redirectDescription = requestedOrigin === responseOrigin + ? `within origin '${requestedOrigin}'` + : `from origin '${requestedOrigin}' to '${responseOrigin}'`; + recordOCIAuthDiagnostic(params, 'authServerRedirect', `Authentication server redirected a token request ${redirectDescription}.`); + } +} + // 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. @@ -124,12 +150,18 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio } const initialAttemptRes = await requestResolveHeaders(httpOptions, output); + const requestedRegistryUrl = new URL(httpOptions.url); + const registryUrl = new URL(initialAttemptRes.responseUrl); + const challengeFromRequestedRegistry = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); + if (!challengeFromRequestedRegistry) { + recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Registry redirect from '${requestedRegistryUrl.host}' to '${registryUrl.host}' would prevent forwarding the requested registry's credentials with OCI auth hardening.`); + } // For anything except a 401 (invalid/no token) or 403 (insufficient scope) // response simply return the original response to the caller. if (initialAttemptRes.statusCode !== 401 && initialAttemptRes.statusCode !== 403) { output.write(`[httpOci] ${initialAttemptRes.statusCode} (${maybeCachedAuthHeader ? 'Cached' : 'NoAuth'}): ${httpOptions.url}`, LogLevel.Trace); - return initialAttemptRes; + return withOCIAuthDiagnostics(params, initialAttemptRes); } // -- 'responseAttempt' status code was 401 or 403 at this point. @@ -172,13 +204,13 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio return; } let realmUrl: URL; - let registryUrl: URL; try { realmUrl = new URL(realmGroup[1]); - registryUrl = new URL(initialAttemptRes.responseUrl); - if (params.ociAuthHardening) { - const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); - if (!isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts)) { + const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); + const authLookupWouldBeBlocked = !isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts); + if (authLookupWouldBeBlocked) { + recordOCIAuthDiagnostic(params, 'authLookupWouldBeBlocked', `Authentication lookup from registry '${registryUrl.host}' to realm origin '${realmUrl.origin}' would be blocked by OCI auth hardening.`); + if (params.ociAuthHardening) { delete cachedAuthHeader[ociRef.registry]; const allowHint = realmUrl.protocol === 'https:' ? ` Use '--allow-cross-origin-auth-host ${registryUrl.host}=${realmUrl.host}' to trust this registry-to-auth-host mapping.` @@ -198,9 +230,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio scope: scopeGroup ? scopeGroup[1] : '', }; - const requestedRegistryUrl = new URL(httpOptions.url); - const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening - || requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); + const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || challengeFromRequestedRegistry; const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); @@ -224,7 +254,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; } - return reattemptRes; + return withOCIAuthDiagnostics(params, reattemptRes); } // Attempts to get the Basic auth credentials for the provided registry. @@ -503,6 +533,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O let res: Awaited>; try { res = await requestToken(httpOptions, output); + recordAuthServerRedirect(params, httpOptions.url, res); 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(); @@ -513,6 +544,7 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O // Build a fresh GET so neither an Authorization header nor a refresh-token POST body is reused. httpOptions = createGetHttpOptions(); res = await requestToken(httpOptions, output); + recordAuthServerRedirect(params, httpOptions.url, res); } } catch (err) { output.write(`[httpOci] Failed to request bearer token for '${service}': ${err}`, LogLevel.Error); diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index c0b21eb82..3ee8873ee 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -60,7 +60,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu const { dockerCLI, dockerComposeCLI } = params; const { env } = common; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels); await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index f777f8df9..2c93d651e 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -8,6 +8,7 @@ import * as crypto from 'crypto'; import * as os from 'os'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils'; import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; @@ -94,6 +95,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string remoteWorkspaceFolder: result.properties.remoteWorkspaceFolder, configuration: options.includeConfig ? result.config : undefined, mergedConfiguration: options.includeMergedConfig ? result.mergedConfig : undefined, + ociAuthDiagnostics: params.common.ociAuthDiagnostics, finishBackgroundTasks: async () => { try { await finishBackgroundTasks(result.params.backgroundTasks); @@ -166,6 +168,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: omitSyntaxDirective: options.omitSyntaxDirective, allowedCrossOriginAuthHosts: options.allowedCrossOriginAuthHosts, ociAuthHardening: options.ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const dockerPath = options.dockerPath || 'docker'; @@ -214,7 +217,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo + targetPlatformInfo, + ociAuthDiagnostics: common.ociAuthDiagnostics, })); const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }); @@ -226,7 +230,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo + targetPlatformInfo, + ociAuthDiagnostics: common.ociAuthDiagnostics, }, { useSimpleVersion: cliVariant === CLIVariant.Wslc }); return { diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 7a131ae0e..782232e11 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -44,6 +44,7 @@ import { readFeaturesConfig } from './featureUtils'; import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; @@ -719,7 +720,7 @@ async function doBuild({ throw new ContainerError({ description: '--push true cannot be used with --output.' }); } - const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: params.common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined); // Support multiple use of `--image-name` @@ -806,6 +807,7 @@ async function doBuild({ return { outcome: 'success' as 'success', imageName: imageNameResult, + ociAuthDiagnostics: params.common.ociAuthDiagnostics, dispose, }; } catch (originalError) { @@ -1152,6 +1154,7 @@ async function readConfiguration({ targetPlatformInfo: buildPlatformInfo, allowedCrossOriginAuthHosts, ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1181,6 +1184,7 @@ async function readConfiguration({ workspace: configs?.workspaceConfig, featuresConfiguration, mergedConfiguration: mergedConfig, + ociAuthDiagnostics: params.ociAuthDiagnostics, }) + '\n', err => err ? reject(err) : resolve()); }); } catch (err) { @@ -1264,6 +1268,7 @@ async function outdated({ platform: cliHost.platform, allowedCrossOriginAuthHosts, ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const outdated = await loadVersionInfo(params, configs.config.config); diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 13b6caace..8e7750040 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -27,7 +27,7 @@ const serviceLabel = 'com.docker.compose.service'; export async function openDockerComposeDevContainer(params: DockerResolverParameters, workspace: Workspace, config: SubstitutedConfig, idLabels: string[], additionalFeatures: Record>): Promise { const { common, dockerCLI, dockerComposeCLI } = params; const { cliHost, env, output } = common; - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; return _openDockerComposeDevContainer(params, buildParams, workspace, config, getRemoteWorkspaceFolder(config.config), idLabels, additionalFeatures); } @@ -155,7 +155,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf const { cliHost, env, output } = common; const { config } = configWithRaw; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; const composeConfig = await readDockerComposeConfig(cliParams, localComposeFiles, envFile); const composeService = composeConfig.services[config.service]; diff --git a/src/spec-node/featureUtils.ts b/src/spec-node/featureUtils.ts index 5bedd5798..f52ca0be7 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -20,5 +20,6 @@ export async function readFeaturesConfig(params: DockerCLIParameters, pkg: Packa noLockfile: true, allowedCrossOriginAuthHosts: params.allowedCrossOriginAuthHosts, ociAuthHardening: params.ociAuthHardening, + ociAuthDiagnostics: params.ociAuthDiagnostics, }, 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 cd88d06df..2ee958726 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -1,6 +1,6 @@ import { Argv } from 'yargs'; -import { OCIManifest, OCIRef, fetchOCIManifestIfExists, getPublishedTags, getRef } from '../../spec-configuration/containerCollectionsOCI'; -import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; +import { CommonParams, OCIManifest, OCIRef, fetchOCIManifestIfExists, getPublishedTags, getRef } from '../../spec-configuration/containerCollectionsOCI'; +import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; @@ -8,6 +8,7 @@ import { buildDependencyGraph, generateMermaidDiagram } from '../../spec-configu import { DevContainerFeature } from '../../spec-configuration/configuration'; import { processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function featuresInfoOptions(y: Argv) { return y @@ -53,7 +54,7 @@ async function featuresInfo({ terminalDimensions: undefined, }, pkg, new Date(), disposables, true); - const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts, ociAuthHardening }; + const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; const jsonOutput: InfoJsonOutput = {}; @@ -131,7 +132,7 @@ async function featuresInfo({ } -async function getManifest(params: { output: Log; env: NodeJS.ProcessEnv; outputFormat: string }, featureRef: OCIRef) { +async function getManifest(params: CommonParams & { outputFormat: string }, featureRef: OCIRef) { const { outputFormat } = params; const manifestContainer = await fetchOCIManifestIfExists(params, featureRef, undefined); @@ -146,7 +147,7 @@ async function getManifest(params: { output: Log; env: NodeJS.ProcessEnv; output return manifestContainer; } -async function getTags(params: { output: Log; env: NodeJS.ProcessEnv; outputFormat: string }, featureRef: OCIRef) { +async function getTags(params: CommonParams & { outputFormat: string }, featureRef: OCIRef) { const { outputFormat } = params; const publishedTags = await getPublishedTags(params, featureRef); if (!publishedTags || publishedTags.length === 0) { diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts index bff3d21b6..46f38ea14 100644 --- a/src/spec-node/featuresCLI/publish.ts +++ b/src/spec-node/featuresCLI/publish.ts @@ -15,6 +15,7 @@ import { publishOptions } from '../collectionCommonUtils/publish'; import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; const collectionType = 'feature'; export function featuresPublishOptions(y: Argv) { @@ -51,7 +52,7 @@ async function featuresPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; // 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 1856d3797..1c183ba30 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -17,6 +17,7 @@ import { uriToFsPath } from '../../spec-configuration/configurationCommonUtils'; import { workspaceFromPath } from '../../spec-utils/workspaces'; import { readDevContainerConfigFile } from '../configContainer'; import { URI } from 'vscode-uri'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; interface JsonOutput { @@ -77,6 +78,7 @@ async function featuresResolveDependencies({ env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const cwd = workspaceFolder || process.cwd(); diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 60884592e..3f10914af 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -350,7 +350,8 @@ export async function getImageBuildInfo(params: DockerResolverParameters | Docke const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, cliHost.cwd); - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const ociAuthDiagnostics = 'cliHost' in params ? params.ociAuthDiagnostics : params.common.ociAuthDiagnostics; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics }; const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); const services = Object.keys(composeConfig.services || {}); diff --git a/src/spec-node/templatesCLI/apply.ts b/src/spec-node/templatesCLI/apply.ts index 11a636cfb..507c74f97 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -7,6 +7,7 @@ import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { fetchTemplate, SelectedTemplate, TemplateFeatureOption, TemplateOptions } from '../../spec-configuration/containerTemplatesOCI'; import { runAsyncHandler } from '../utils'; import path from 'path'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateApplyOptions(y: Argv) { return y @@ -89,7 +90,7 @@ async function templateApply({ omitPaths, }; - const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }, selectedTemplate, workspaceFolder, userProvidedTmpDir); + const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }, selectedTemplate, workspaceFolder, userProvidedTmpDir); if (!files) { output.write(`Failed to fetch template '${id}'.`, LogLevel.Error); process.exit(1); diff --git a/src/spec-node/templatesCLI/metadata.ts b/src/spec-node/templatesCLI/metadata.ts index 958fad235..3fea84f89 100644 --- a/src/spec-node/templatesCLI/metadata.ts +++ b/src/spec-node/templatesCLI/metadata.ts @@ -6,6 +6,7 @@ import { fetchOCIManifestIfExists, getRef } from '../../spec-configuration/conta import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateMetadataOptions(y: Argv) { return y @@ -41,7 +42,7 @@ async function templateMetadata({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; 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 2cb3aea30..1e6ad3c9e 100644 --- a/src/spec-node/templatesCLI/publish.ts +++ b/src/spec-node/templatesCLI/publish.ts @@ -15,6 +15,7 @@ import { packageTemplates } from './packageImpl'; import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; const collectionType = 'template'; @@ -52,7 +53,7 @@ async function templatesPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; // 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 3926deb87..dbb123cf6 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -19,6 +19,7 @@ import { isLocalFile, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; import { readFeaturesConfig } from './featureUtils'; import { DevContainerConfig } from '../spec-configuration/configuration'; import { mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; export function featuresUpgradeOptions(y: Argv) { return y @@ -92,6 +93,7 @@ async function featuresUpgrade({ os: mapNodeOSToGOOS(cliHost.platform), arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; + const ociAuthDiagnostics = createOCIAuthDiagnostics(); const dockerParams: DockerCLIParameters = { cliHost, dockerCLI: dockerPath, @@ -102,6 +104,7 @@ async function featuresUpgrade({ targetPlatformInfo: buildPlatformInfo, allowedCrossOriginAuthHosts, ociAuthHardening, + ociAuthDiagnostics, }; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); @@ -118,6 +121,7 @@ async function featuresUpgrade({ platform: cliHost.platform, allowedCrossOriginAuthHosts, ociAuthHardening, + ociAuthDiagnostics, }; if (feature && targetVersion) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 6ea5104fe..ebbe51887 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -27,6 +27,7 @@ import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { PackageConfiguration } from '../spec-utils/product'; import { ImageMetadataEntry, MergedDevContainerConfig } from './imageMetadata'; import { getImageIndexEntryForPlatform, getManifest, getRef } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics, OCIAuthDiagnostics } from '../spec-common/ociAuth'; import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; import { configFileLabel, findDevContainer, hostFolderLabel } from './singleContainer'; export { getConfigFilePath, getDockerfilePath, isDockerFileConfig } from '../spec-configuration/configuration'; @@ -287,7 +288,8 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock try { const allowedCrossOriginAuthHosts = 'cliHost' in params ? params.allowedCrossOriginAuthHosts : params.common.allowedCrossOriginAuthHosts; const ociAuthHardening = 'cliHost' in params ? params.ociAuthHardening : params.common.ociAuthHardening; - return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts, ociAuthHardening); + const ociAuthDiagnostics = 'cliHost' in params ? params.ociAuthDiagnostics : params.common.ociAuthDiagnostics; + return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics); } catch (inspectErr2) { output.write(`Error fetching image details: ${inspectErr2?.message}`, LogLevel.Info); } @@ -319,9 +321,9 @@ function logErrorStdoutStderr(err: any, output: Log) { } } -export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[], ociAuthHardening?: boolean): Promise { +export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[], ociAuthHardening?: boolean, ociAuthDiagnostics: OCIAuthDiagnostics = createOCIAuthDiagnostics()): Promise { const resourceAndVersion = qualifyImageName(name); - const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics }; 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 4daf4ff82..3a15aa6b7 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -10,6 +10,7 @@ import { Log, makeLog } from '../spec-utils/log'; import { Event } from '../spec-utils/event'; import { escapeRegExCharacters } from '../spec-utils/strings'; import { delay } from '../spec-common/async'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export interface ContainerDetails { Id: string; @@ -56,6 +57,7 @@ export interface DockerCLIParameters { targetPlatformInfo: PlatformInfo; allowedCrossOriginAuthHosts?: string[]; ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } export interface PartialExecParameters { diff --git a/src/spec-utils/httpRequest.ts b/src/spec-utils/httpRequest.ts index 83e265752..b5097aae0 100644 --- a/src/spec-utils/httpRequest.ts +++ b/src/spec-utils/httpRequest.ts @@ -99,7 +99,7 @@ export async function requestResolveHeadersNoRedirects(options: RequestResolveHe async function requestResolveHeadersInternal(options: RequestResolveHeadersOptions, output: Log, maxRedirects?: number) { const secureContext = await secureContextWithExtraCerts(output); - return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer; responseUrl: string }>((resolve, reject) => { + return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer; responseUrl: string; redirected: boolean }>((resolve, reject) => { const parsed = new url.URL(options.url); const reqOptions: RequestOptions & tls.CommonConnectionOptions & FollowOptions = { hostname: parsed.hostname, @@ -110,6 +110,7 @@ async function requestResolveHeadersInternal(options: RequestResolveHeadersOptio headers: options.headers, agent: new ProxyAgent(), secureContext, + trackRedirects: true, }; if (maxRedirects !== undefined) { reqOptions.maxRedirects = maxRedirects; @@ -132,6 +133,7 @@ async function requestResolveHeadersInternal(options: RequestResolveHeadersOptio resHeaders: res.headers! as Record, resBody: Buffer.concat(chunks), responseUrl: res.responseUrl, + redirected: res.redirects.length > 1, }); }); }); diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index 9281a7498..529b07099 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -1,5 +1,6 @@ import { assert } from 'chai'; import { getRef, getManifest, getBlob, getCollectionRef } from '../../spec-configuration/containerCollectionsOCI'; +import { createTestCommonParams } from '../testUtils'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -280,7 +281,7 @@ describe('Test OCI Pull', async function () { if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const manifest = await getManifest({ output, env: process.env }, 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.13', featureRef); + const manifest = await getManifest(createTestCommonParams(output), 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.13', featureRef); assert.isNotNull(manifest); assert.exists(manifest); @@ -306,7 +307,7 @@ describe('Test OCI Pull', async function () { if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const blobResult = await getBlob({ output, env: process.env }, 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb', '/tmp', '/tmp/featureTest', featureRef, 'sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb'); + const blobResult = await getBlob(createTestCommonParams(output), 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb', '/tmp', '/tmp/featureTest', featureRef, 'sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb'); assert.isDefined(blobResult); assert.isArray(blobResult?.files); }); diff --git a/src/test/container-features/containerFeaturesOCIPush.test.ts b/src/test/container-features/containerFeaturesOCIPush.test.ts index b6672ffc9..8a4b6bf40 100644 --- a/src/test/container-features/containerFeaturesOCIPush.test.ts +++ b/src/test/container-features/containerFeaturesOCIPush.test.ts @@ -3,7 +3,7 @@ import { DEVCONTAINER_TAR_LAYER_MEDIATYPE, getRef } from '../../spec-configurati import { fetchOCIFeatureManifestIfExistsFromUserIdentifier } from '../../spec-configuration/containerFeaturesOCI'; import { calculateDataLayer, checkIfBlobExists, calculateManifestAndContentDigest } from '../../spec-configuration/containerCollectionsOCIPush'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; -import { ExecResult, shellExec } from '../testUtils'; +import { createTestCommonParams, ExecResult, shellExec } from '../testUtils'; import * as path from 'path'; import * as fs from 'fs'; import { readLocalFile, writeLocalFile } from '../../spec-utils/pfs'; @@ -352,7 +352,7 @@ describe('Test OCI Push Helper Functions', function () { }); it('Can fetch an artifact from a digest reference', async () => { - const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier({ output, env: process.env }, 'ghcr.io/codspace/non-empty-config-layer/color', 'sha256:dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc'); + const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier(createTestCommonParams(output), 'ghcr.io/codspace/non-empty-config-layer/color', 'sha256:dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc'); assert.strictEqual(manifest?.manifestObj.layers[0].annotations['org.opencontainers.image.title'], 'devcontainer-feature-color.tgz'); }); @@ -363,13 +363,14 @@ describe('Test OCI Push Helper Functions', function () { } - const tarLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1'); + const params = createTestCommonParams(output); + const tarLayerBlobExists = await checkIfBlobExists(params, ociFeatureRef, 'sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1'); assert.isTrue(tarLayerBlobExists); - const configLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'); + const configLayerBlobExists = await checkIfBlobExists(params, ociFeatureRef, 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'); assert.isTrue(configLayerBlobExists); - const randomStringDoesNotExist = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3'); + const randomStringDoesNotExist = await checkIfBlobExists(params, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3'); assert.isFalse(randomStringDoesNotExist); }); }); \ No newline at end of file diff --git a/src/test/container-features/containerFeaturesOrder.test.ts b/src/test/container-features/containerFeaturesOrder.test.ts index d4c880809..e4d6d8d84 100644 --- a/src/test/container-features/containerFeaturesOrder.test.ts +++ b/src/test/container-features/containerFeaturesOrder.test.ts @@ -10,15 +10,15 @@ import { DevContainerConfig, DevContainerFeature } from '../../spec-configuratio import { CommonParams } from '../../spec-configuration/containerCollectionsOCI'; import { LogLevel, createPlainLog, makeLog } from '../../spec-utils/log'; import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; +import { createTestCommonParams } from '../testUtils'; // const pkg = require('../../../package.json'); export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Info)); async function setupInstallOrderTest(testWorkspaceFolder: string) { const params: CommonParams = { - env: process.env, - output, - cachedAuthHeader: {} + ...createTestCommonParams(output), + cachedAuthHeader: {}, }; const configPath = `${testWorkspaceFolder}/.devcontainer/devcontainer.json`; diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 3e08c0648..3ff6f3c81 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -7,10 +7,11 @@ import { getSafeId, findContainerUsers } from '../../spec-node/containerFeatures import { ImageMetadataEntry } from '../../spec-node/imageMetadata'; import { SubstitutedConfig } from '../../spec-node/utils'; import { createPlainLog, LogLevel, makeLog, nullLog } from '../../spec-utils/log'; +import { createTestCommonParams } from '../testUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); -const params = { output, env: process.env }; +const params = createTestCommonParams(output); describe('getIdSafe should return safe environment variable name', function () { diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 2dd2bd0e8..74bc375c8 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import { existsSync } from 'fs'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; -import { ExecResult, shellExec } from '../testUtils'; +import { createTestCommonParams, ExecResult, shellExec } from '../testUtils'; import { getSemanticTags } from '../../spec-node/collectionCommonUtils/publishCommandImpl'; import { getRef, getPublishedTags, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI'; import { generateFeaturesDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; @@ -661,13 +661,14 @@ describe('test function getSermanticVersions', () => { }); describe('test functions getVersionsStrictSorted and getPublishedTags', async () => { + const params = createTestCommonParams(output); it('should list published versions', async () => { const resource = 'ghcr.io/devcontainers/features/node'; const featureRef = getRef(output, resource); if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const publishedTags = await getPublishedTags({ output, env: process.env }, featureRef) ?? []; + const publishedTags = await getPublishedTags(params, featureRef) ?? []; assert.includeMembers(publishedTags, ['1', '1.0', '1.0.0', 'latest']); }); @@ -678,7 +679,7 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () if (!ref) { assert.fail('ref should not be undefined'); } - const versionsList = await getVersionsStrictSorted({ output, env: process.env }, ref) ?? []; + const versionsList = await getVersionsStrictSorted(params, ref) ?? []; console.log(versionsList); const expectedVersions = [ '0.0.0', @@ -722,7 +723,7 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () assert.deepStrictEqual(versionsList, expectedVersions); - const publishedTags = await getPublishedTags({ output, env: process.env }, ref) ?? []; + const publishedTags = await getPublishedTags(params, ref) ?? []; const expectedTags = [ 'latest', '0', diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 915c3e2da..3186e3bb8 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -9,7 +9,7 @@ import { mkdirpLocal } from '../../spec-utils/pfs'; import { DevContainerConfig } from '../../spec-configuration/configuration'; import { URI } from 'vscode-uri'; import { getLocalCacheFolder } from '../../spec-node/utils'; -import { shellExec } from '../testUtils'; +import { createTestCommonParams, shellExec } from '../testUtils'; import { getEntPasswdShellCommand } from '../../spec-common/commonUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -21,7 +21,7 @@ describe('validate generateFeaturesConfig()', function () { const env = { 'SOME_KEY': 'SOME_VAL' }; const platform = process.platform; const cacheFolder = path.join(os.tmpdir(), `devcontainercli-test-${crypto.randomUUID()}`); - const params = { extensionPath: '', cwd: '', output, env, cacheFolder, persistedFolder: '', skipFeatureAutoMapping: false, platform, noLockfile: true }; + const params = { ...createTestCommonParams(output, env), extensionPath: '', cwd: '', cacheFolder, persistedFolder: '', skipFeatureAutoMapping: false, platform, noLockfile: true }; it('should correctly return a featuresConfig with v2 local features', async function () { const version = 'unittest'; diff --git a/src/test/container-templates/containerTemplatesOCI.test.ts b/src/test/container-templates/containerTemplatesOCI.test.ts index 42e73b5b5..52b5efd9f 100644 --- a/src/test/container-templates/containerTemplatesOCI.test.ts +++ b/src/test/container-templates/containerTemplatesOCI.test.ts @@ -5,9 +5,11 @@ import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); import { fetchTemplate, SelectedTemplate } from '../../spec-configuration/containerTemplatesOCI'; import { readLocalFile } from '../../spec-utils/pfs'; +import { createTestCommonParams } from '../testUtils'; describe('fetchTemplate', async function () { this.timeout('120s'); + const params = createTestCommonParams(output); it('template apply docker-from-docker without features and with user options', async () => { @@ -20,7 +22,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp1')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -50,7 +52,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp2')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -80,7 +82,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp3')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -113,7 +115,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp4')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Expected: // ./environment.yml, ./.devcontainer/.env, ./.devcontainer/Dockerfile, ./.devcontainer/devcontainer.json, ./.devcontainer/docker-compose.yml, ./.devcontainer/noop.txt, ./.github/dependabot.yml @@ -161,7 +163,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -182,7 +184,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -209,7 +211,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -232,5 +234,3 @@ describe('fetchTemplate', async function () { }); - - diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index e821fba29..1b4e568d7 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -9,6 +9,7 @@ import { assert } from 'chai'; import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; import { isAllowedTokenServiceRealm, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; import { nullLog } from '../spec-utils/log'; +import { createTestCommonParams } from './testUtils'; describe('OCI registry authentication', () => { describe('isAllowedTokenServiceRealm', () => { @@ -101,8 +102,9 @@ describe('OCI registry authentication', () => { version: 'latest', }; const cachedAuthHeader: Record = {}; + const params = createTestCommonParams(nullLog, {}); - const result = await requestEnsureAuthenticated({ env: {}, output: nullLog, cachedAuthHeader, ociAuthHardening: true }, { + const result = await requestEnsureAuthenticated({ ...params, cachedAuthHeader, ociAuthHardening: true }, { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, headers: {}, @@ -112,12 +114,13 @@ describe('OCI registry authentication', () => { assert.equal(registryRequests, 1); assert.equal(tokenRequests, 0); assert.notProperty(cachedAuthHeader, registry); + assert.isTrue(params.ociAuthDiagnostics.authLookupWouldBeBlocked); } finally { await Promise.all([close(registryServer), close(tokenServer)]); } }); - it('uses cross-origin realms and follows token redirects when hardening is disabled', async () => { + it('surfaces shadow diagnostics when hardening is disabled', async () => { const token = 'registry-token'; const bearerScheme = 'Bearer'; let redirectTargetRequests = 0; @@ -137,16 +140,31 @@ describe('OCI registry authentication', () => { }); const tokenPort = await listen(tokenServer); - let registryRequests = 0; - const registryServer = http.createServer((_request, response) => { - registryRequests++; + let challengeRegistryRequests = 0; + const challengeRegistryServer = http.createServer((_request, response) => { + challengeRegistryRequests++; response.writeHead(401, { 'WWW-Authenticate': `${bearerScheme} realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, }); response.end(); }); + const challengeRegistryPort = await listen(challengeRegistryServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + response.writeHead(307, { + location: `http://localhost:${challengeRegistryPort}${request.url}`, + }); + response.end(); + }); const registryPort = await listen(registryServer); const registry = `127.0.0.1:${registryPort}`; + const logMessages: string[] = []; + const output = { + ...nullLog, + write: (text: string) => logMessages.push(text), + }; try { const ociRef: OCICollectionRef = { @@ -157,7 +175,7 @@ describe('OCI registry authentication', () => { version: 'latest', }; - const result = await requestEnsureAuthenticated({ env: {}, output: nullLog }, { + const result = await requestEnsureAuthenticated(createTestCommonParams(output, {}), { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, headers: {}, @@ -165,10 +183,17 @@ describe('OCI registry authentication', () => { assert.equal(result?.statusCode, 401); assert.equal(registryRequests, 2); + assert.equal(challengeRegistryRequests, 2); assert.equal(tokenRequests, 1); assert.equal(redirectTargetRequests, 1); + assert.deepEqual(result?.ociAuthDiagnostics, { + authLookupWouldBeBlocked: true, + registryRedirectWouldPreventCredentialForwarding: true, + authServerRedirect: true, + }); + assert.lengthOf(logMessages.filter(message => message.includes('OCI auth diagnostics:')), 3); } finally { - await Promise.all([close(registryServer), close(tokenServer), close(redirectTargetServer)]); + await Promise.all([close(registryServer), close(challengeRegistryServer), close(tokenServer), close(redirectTargetServer)]); } }); @@ -231,8 +256,7 @@ describe('OCI registry authentication', () => { }; const result = await requestEnsureAuthenticated({ - env: {}, - output: nullLog, + ...createTestCommonParams(nullLog, {}), allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], ociAuthHardening: true, }, { @@ -244,6 +268,11 @@ describe('OCI registry authentication', () => { assert.equal(result?.statusCode, 200); assert.equal(registryRequests, 2); assert.equal(tokenRequests, 1); + assert.deepEqual(result?.ociAuthDiagnostics, { + authLookupWouldBeBlocked: false, + registryRedirectWouldPreventCredentialForwarding: false, + authServerRedirect: false, + }); } finally { if (previousDockerConfig === undefined) { delete process.env.DOCKER_CONFIG; @@ -291,8 +320,7 @@ describe('OCI registry authentication', () => { }; const result = await requestEnsureAuthenticated({ - env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, - output: nullLog, + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), ociAuthHardening: true, }, { type: 'GET', @@ -352,8 +380,7 @@ describe('OCI registry authentication', () => { }; const result = await requestEnsureAuthenticated({ - env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, - output: nullLog, + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), }, { type: 'GET', url: `http://${registry}/v2/test/features/manifests/latest`, diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 22597dce2..45a50fd99 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -6,10 +6,11 @@ import * as assert from 'assert'; import * as cp from 'child_process'; import { getCLIHost, loadNativeModule, plainExec, plainPtyExec, runCommand, runCommandNoPty } from '../spec-common/commonUtils'; import { SubstituteConfig } from '../spec-node/utils'; -import { LogLevel, createPlainLog, makeLog, nullLog } from '../spec-utils/log'; +import { Log, LogLevel, createPlainLog, makeLog, nullLog } from '../spec-utils/log'; import { dockerComposeCLIConfig } from '../spec-node/dockerCompose'; import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; -import { mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { CommonParams, mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; export interface BuildKitOption { text: string; @@ -147,6 +148,14 @@ export const testSubstitute: SubstituteConfig = value => { export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); +export function createTestCommonParams(output: Log, env: NodeJS.ProcessEnv = process.env): CommonParams { + return { + output, + env, + ociAuthDiagnostics: createOCIAuthDiagnostics(), + }; +} + export async function createCLIParams(hostPath: string) { const cliHost = await getCLIHost(hostPath, loadNativeModule, true); const dockerComposeCLI = dockerComposeCLIConfig({ @@ -159,13 +168,12 @@ export async function createCLIParams(hostPath: string) { arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; const cliParams: DockerCLIParameters = { + ...createTestCommonParams(output, {}), cliHost, dockerCLI: 'docker', dockerComposeCLI, - env: {}, - output, buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, -}; + }; return cliParams; } From 422a92e7283357f1bc34d74657323917712778ea Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 14 Aug 2026 10:58:12 +0200 Subject: [PATCH 09/11] Prepare 0.89.0 Document the opt-in OCI authentication hardening and compatibility diagnostics release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 134e0266e..3dcbac73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notable changes. +## August 2026 + +### [0.89.0] +- Add opt-in OCI authentication hardening with `--oci-auth-hardening`, trusted cross-origin authentication host mappings, and diagnostics for measuring compatibility impact. (https://github.com/devcontainers/cli/pull/1278) + ## June 2026 ### [0.88.0] diff --git a/package.json b/package.json index 4ca76180b..ed05dce90 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.88.0", + "version": "0.89.0", "bin": { "devcontainer": "devcontainer.js" }, From 987bbc77348dbd7877b29a9f3592dd865d5603f4 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 14 Aug 2026 11:58:20 +0200 Subject: [PATCH 10/11] Refine OCI auth impact diagnostics Require a shared diagnostics collector, reuse test parameter helpers, and only report registry redirects that end in an authentication challenge and would change credential forwarding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 6 ++-- src/test/httpOCIRegistry.test.ts | 38 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 83d58ce12..5cf907db5 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -153,9 +153,6 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio const requestedRegistryUrl = new URL(httpOptions.url); const registryUrl = new URL(initialAttemptRes.responseUrl); const challengeFromRequestedRegistry = requestedRegistryUrl.host.toLowerCase() === registryUrl.host.toLowerCase(); - if (!challengeFromRequestedRegistry) { - recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Registry redirect from '${requestedRegistryUrl.host}' to '${registryUrl.host}' would prevent forwarding the requested registry's credentials with OCI auth hardening.`); - } // For anything except a 401 (invalid/no token) or 403 (insufficient scope) // response simply return the original response to the caller. @@ -165,6 +162,9 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio } // -- 'responseAttempt' status code was 401 or 403 at this point. + if (!challengeFromRequestedRegistry) { + recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Registry redirect from '${requestedRegistryUrl.host}' to '${registryUrl.host}' would prevent forwarding the requested registry's credentials with OCI auth hardening.`); + } // Attempt to authenticate via WWW-Authenticate Header. const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate']; diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 1b4e568d7..6ead623a6 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -197,6 +197,44 @@ describe('OCI registry authentication', () => { } }); + it('ignores cross-origin redirects that do not produce an auth challenge', async () => { + const contentServer = http.createServer((_request, response) => { + response.writeHead(200); + response.end('blob'); + }); + const contentPort = await listen(contentServer); + const registryServer = http.createServer((_request, response) => { + response.writeHead(307, { + location: `http://localhost:${contentPort}/blob`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `127.0.0.1:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + const params = createTestCommonParams(nullLog, {}); + + const result = await requestEnsureAuthenticated(params, { + type: 'GET', + url: `http://${registry}/v2/test/features/blobs/sha256:test`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.isFalse(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await Promise.all([close(registryServer), close(contentServer)]); + } + }); + it('forwards a refresh token to an explicitly configured auth host', async () => { const token = 'registry-token'; const refreshToken = 'registry-refresh-token'; From 98e182ebbcdc3628bae71779e905305c36873cbd Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Sat, 15 Aug 2026 17:59:33 +0200 Subject: [PATCH 11/11] Fix rootless Podman tests on hosted runners Disable the static Podman bundle's single-owner storage mode before Feature builds so APT can use its unprivileged sandbox. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/test/cli.podman.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/cli.podman.test.ts b/src/test/cli.podman.test.ts index 932d9a358..59da2f95a 100644 --- a/src/test/cli.podman.test.ts +++ b/src/test/cli.podman.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import { readFile, writeFile } from 'fs/promises'; import { shellExec } from './testUtils'; const pkg = require('../../package.json'); @@ -18,6 +19,20 @@ describe('Dev Containers CLI using Podman', function () { before('Install', async () => { await shellExec(`rm -rf ${tmp}/node_modules`); await shellExec(`mkdir -p ${tmp}`); + if (process.env.GITHUB_ACTIONS === 'true') { + const storageConfig = path.join(process.env.HOME!, '.config', 'containers', 'storage.conf'); + const storageConfigContent = await readFile(storageConfig, 'utf8'); + const updatedStorageConfigContent = storageConfigContent.replace( + /^(\s*ignore_chown_errors\s*=\s*)"true"/m, + '$1"false"' + ); + if (updatedStorageConfigContent !== storageConfigContent) { + // The hosted runner's Podman bundle enables ownership squashing, + // which prevents APT's unprivileged _apt user from writing during builds. + await shellExec('podman system reset --force'); + await writeFile(storageConfig, updatedStorageConfigContent); + } + } await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); });