From 2a6cab18bd3bd59f1de0d7bcfc2eccaae52d233f Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Wed, 20 May 2026 17:31:14 -0400 Subject: [PATCH 01/37] Add WSLC support --- src/spec-node/devContainers.ts | 7 ++-- src/spec-node/singleContainer.ts | 56 ++++++++++++++++++++++++-------- src/spec-node/utils.ts | 35 ++++++++++++++++++++ src/spec-shutdown/dockerUtils.ts | 21 +++++++++--- 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index d647bb614..b53f77fa3 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -17,7 +17,7 @@ import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminal import { dockerComposeCLIConfig } from './dockerCompose'; import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; -import { dockerBuildKitVersion, dockerEngineVersion, isPodman } from '../spec-shutdown/dockerUtils'; +import { dockerBuildKitVersion, dockerEngineVersion, isPodman, isWslc } from '../spec-shutdown/dockerUtils'; import { Event } from '../spec-utils/event'; @@ -213,6 +213,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPlatformInfo })); + const detectedWslc = await isWslc({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }); + const dockerEngineVer = await dockerEngineVersion({ cliHost, dockerCLI: dockerPath, @@ -221,13 +223,14 @@ export async function createDockerParams(options: ProvisionOptions, disposables: output, buildPlatformInfo, targetPlatformInfo - }); + }, { useSimpleVersion: detectedWslc }); return { common, parsedAuthority, dockerCLI: dockerPath, isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }), + isWslc: detectedWslc, dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, workspaceMountConsistencyDefault: workspaceMountConsistency, diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..0c371b638 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -347,8 +347,8 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const exposedPorts = typeof appPort === 'number' || typeof appPort === 'string' ? [appPort] : appPort || []; const exposed = ([]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? ['--mount', workspaceMount] : []; - const additionalMount = additionalMountString ? ['--mount', additionalMountString] : []; + const cwdMount = workspaceMount ? (params.isWslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.isWslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -360,24 +360,30 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const containerUserArgs = containerUser ? ['-u', containerUser] : []; const featureArgs: string[] = []; - if (mergedConfig.init) { + // wslc does not support --init, --privileged, --cap-add, or --security-opt + if (mergedConfig.init && !params.isWslc) { featureArgs.push('--init'); } - if (mergedConfig.privileged) { + if (mergedConfig.privileged && !params.isWslc) { featureArgs.push('--privileged'); } - for (const cap of mergedConfig.capAdd || []) { - featureArgs.push('--cap-add', cap); - } - for (const securityOpt of mergedConfig.securityOpt || []) { - featureArgs.push('--security-opt', securityOpt); + if (!params.isWslc) { + for (const cap of mergedConfig.capAdd || []) { + featureArgs.push('--cap-add', cap); + } + for (const securityOpt of mergedConfig.securityOpt || []) { + featureArgs.push('--security-opt', securityOpt); + } } const featureMounts = ([] as string[]).concat( ...[ ...mergedConfig.mounts || [], ...params.additionalMounts, - ].map(m => generateMountCommand(m)) + ].map(m => { + const mountArgs = generateMountCommand(m); + return params.isWslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + }) ); const customEntrypoints = mergedConfig.entrypoints || []; @@ -396,9 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - '--sig-proxy=false', - '-a', 'STDOUT', - '-a', 'STDERR', + ...(params.isWslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -446,6 +450,32 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai return []; } +// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc. +function convertMountToVolume(mountStr: string): string[] { + const parts = new Map(mountStr.split(',').map(p => { + const eq = p.indexOf('='); + return eq === -1 ? [p, ''] : [p.substring(0, eq), p.substring(eq + 1)]; + })); + const source = parts.get('source') || parts.get('src') || ''; + const target = parts.get('target') || parts.get('dst') || parts.get('destination') || ''; + if (source && target) { + return ['-v', `${source}:${target}`]; + } + if (target) { + return ['-v', target]; + } + // Fallback: pass as --mount and let the runtime handle it. + return ['--mount', mountStr]; +} + +// Convert --mount args array (e.g., ['--mount', 'type=bind,...']) to -v syntax for wslc. +function convertMountArgsToVolume(args: string[]): string[] { + if (args.length === 2 && args[0] === '--mount') { + return convertMountToVolume(args[1]); + } + return args; +} + function getLabels(labels: string[]): string[] { let result: string[] = []; labels.forEach(each => result.push('-l', each)); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 515294e7b..74dfb2785 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -10,6 +10,7 @@ import * as os from 'os'; import { ContainerError, toErrorText } from '../spec-common/errors'; import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils'; import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log'; +import { delay } from '../spec-common/async'; import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless'; import { Workspace } from '../spec-utils/workspaces'; @@ -109,6 +110,7 @@ export interface DockerResolverParameters { parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; isPodman: boolean; + isWslc: boolean; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -170,6 +172,9 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { + if (params.isWslc) { + return startEventSeenPolling(params, labels, canceled, output, trace); + } const eventsProcess = await getEvents(params, { event: ['start'] }); return { started: new Promise((resolve, reject) => { @@ -209,6 +214,36 @@ export async function startEventSeen(params: DockerResolverParameters, labels: R }; } +// Polling-based fallback for runtimes that don't support `events` (e.g., wslc). +function startEventSeenPolling(params: DockerResolverParameters, labels: Record, canceled: Promise, output: Log, trace: boolean) { + let stopped = false; + canceled.catch(() => { stopped = true; }); + const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`); + return { + started: new Promise((resolve, reject) => { + canceled.catch(reject); + const poll = async () => { + while (!stopped) { + try { + const containers = await listContainers(params, false, labelFilters); + if (trace) { + output.write(`Log: startEventSeenPolling found ${containers.length} container(s)\r\n`); + } + if (containers.length > 0) { + resolve(); + return; + } + } catch (e) { + // Ignore transient errors during polling. + } + await delay(500); + } + }; + poll(); + }) + }; +} + async function hasLabels(params: DockerResolverParameters, info: any, expectedLabels: Record) { const actualLabels = info.Actor?.Attributes // Docker uses 'id', Podman 'ID'. diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 9f0bce850..4632d266e 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -171,6 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { + const useEvents = !('isWslc' in params && params.isWslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -184,7 +185,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP if (i === n - 1 || !stderr.includes('already in progress')) { throw err; } - if (!removedSeenP) { + if (useEvents && !removedSeenP) { eventsProcess = await getEvents(params, { container: [nameOrId], event: ['destroy'], @@ -197,7 +198,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP }); }); } - await Promise.race([removedSeenP, delay(1000)]); + await Promise.race([removedSeenP || delay(1000), delay(1000)]); } } } finally { @@ -260,13 +261,16 @@ export async function dockerBuildKitVersion(params: DockerCLIParameters | Partia } } -export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters): Promise<{ versionString: string; versionMatch?: string } | undefined> { +export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, options?: { useSimpleVersion?: boolean }): Promise<{ versionString: string; versionMatch?: string } | undefined> { try { const execParams = { ...toExecParameters(params), print: true, }; - const result = await dockerCLI(execParams, 'version', '--format', '{{.Server.Version}}'); + const args: string[] = options?.useSimpleVersion + ? ['version'] + : ['version', '--format', '{{.Server.Version}}']; + const result = await dockerCLI(execParams, ...args); const versionString = result.stdout.toString().trim(); const versionMatch = versionString.match(/(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)/); if (!versionMatch) { @@ -295,6 +299,15 @@ export async function isPodman(params: PartialExecParameters) { } } +export async function isWslc(params: PartialExecParameters) { + try { + const { stdout } = await dockerCLI(params, '-v'); + return stdout.toString().toLowerCase().indexOf('wslc') !== -1; + } catch (err) { + return false; + } +} + export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) { const partial = toPtyExecParameters(params); return runCommand({ From 3864bdaefc5d99b754153dcf9193546b9cdf3e85 Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Thu, 21 May 2026 14:54:54 -0400 Subject: [PATCH 02/37] Updates from PRs --- src/spec-node/containerFeatures.ts | 6 +++--- src/spec-node/devContainers.ts | 9 ++++----- src/spec-node/dockerCompose.ts | 4 ++-- src/spec-node/singleContainer.ts | 18 ++++++++--------- src/spec-node/utils.ts | 7 +++---- src/spec-shutdown/dockerUtils.ts | 32 +++++++++++++++++------------- 6 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index d8912967c..b3d2dff88 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { DevContainerConfig } from '../spec-configuration/configuration'; -import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils'; import { LogLevel, makeLog } from '../spec-utils/log'; import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; @@ -364,7 +364,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise[]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? (params.isWslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; - const additionalMount = additionalMountString ? (params.isWslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; + const cwdMount = workspaceMount ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -361,13 +361,13 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const featureArgs: string[] = []; // wslc does not support --init, --privileged, --cap-add, or --security-opt - if (mergedConfig.init && !params.isWslc) { + if (mergedConfig.init && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--init'); } - if (mergedConfig.privileged && !params.isWslc) { + if (mergedConfig.privileged && params.cliVariant !== CLIVariant.Wslc) { featureArgs.push('--privileged'); } - if (!params.isWslc) { + if (params.cliVariant !== CLIVariant.Wslc) { for (const cap of mergedConfig.capAdd || []) { featureArgs.push('--cap-add', cap); } @@ -382,7 +382,7 @@ export async function spawnDevContainer(params: DockerResolverParameters, config ...params.additionalMounts, ].map(m => { const mountArgs = generateMountCommand(m); - return params.isWslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + return params.cliVariant === CLIVariant.Wslc ? convertMountArgsToVolume(mountArgs) : mountArgs; }) ); @@ -402,7 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - ...(params.isWslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), + ...(params.cliVariant === CLIVariant.Wslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -436,7 +436,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t } async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise): Promise { - if (params.isPodman && params.common.cliHost.platform === 'linux') { + if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') { const args = ['--security-opt', 'label=disable']; const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg)); if (!hasIdMapping) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 74dfb2785..e6cf6980f 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -16,7 +16,7 @@ import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, import { Workspace } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; import { ShellServer } from '../spec-common/shellServer'; -import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils'; +import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; @@ -109,8 +109,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; - isPodman: boolean; - isWslc: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -172,7 +171,7 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { - if (params.isWslc) { + if (params.cliVariant === CLIVariant.Wslc) { return startEventSeenPolling(params, labels, canceled, output, trace); } const eventsProcess = await getEvents(params, { event: ['start'] }); diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 4632d266e..d1815fe60 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -77,7 +77,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; common: { @@ -171,7 +171,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { - const useEvents = !('isWslc' in params && params.isWslc); + const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -216,7 +216,7 @@ export async function getEvents(params: DockerCLIParameters | PartialExecParamet filterArgs.push('--filter', `${filter}=${value}`); } } - const format = 'isPodman' in params && params.isPodman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 + const format = 'cliVariant' in params && params.cliVariant === CLIVariant.Podman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 const combinedArgs = (args || []).concat(['events', '--format', format, ...filterArgs]); const p = await exec({ @@ -290,22 +290,26 @@ export async function dockerCLI(params: DockerCLIParameters | PartialExecParamet }); } -export async function isPodman(params: PartialExecParameters) { - try { - const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('podman') !== -1; - } catch (err) { - return false; - } +export enum CLIVariant { + Docker = 'docker', + Podman = 'podman', + Wslc = 'wslc', } -export async function isWslc(params: PartialExecParameters) { +export async function lookupCLIVariant(params: PartialExecParameters): Promise { try { const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('wslc') !== -1; - } catch (err) { - return false; + const lower = stdout.toString().toLowerCase(); + if (lower.indexOf('wslc') !== -1) { + return CLIVariant.Wslc; + } + if (lower.indexOf('podman') !== -1) { + return CLIVariant.Podman; + } + } catch (_err) { + // fall through } + return CLIVariant.Docker; } export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) { From f4a636396ca8be13ef57cd4ce8253ab159df9489 Mon Sep 17 00:00:00 2001 From: Craig Loewen Date: Mon, 15 Jun 2026 20:58:31 -0400 Subject: [PATCH 03/37] Fixes --- src/spec-node/dockerCompose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index de0384065..b4ddad83e 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -188,7 +188,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf // determine whether we need to extend with features const version = parseVersion((await params.dockerComposeCLI()).version); - const supportsAdditionalBuildContexts = params.cliVariant !== CLIVariant.Podman && version && !isEarlierVersion(version, [2, 17, 0]); + const supportsAdditionalBuildContexts = params.cliVariant !== CLIVariant.Podman && params.cliVariant !== CLIVariant.Wslc && version && !isEarlierVersion(version, [2, 17, 0]); const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); From 328335e72f762b959978e5a0899b11b6262f8494 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 23 Jun 2026 14:58:42 +0200 Subject: [PATCH 04/37] Downgrade runner --- .github/workflows/test-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5ac9743d1..e16642aee 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -18,7 +18,7 @@ permissions: jobs: tests-matrix: name: Tests Matrix (Windows) - runs-on: windows-latest + runs-on: windows-2022 timeout-minutes: 15 strategy: fail-fast: false From 0ffb2217db4740b9ef3d26aad80889770e71eabf Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 24 Jun 2026 11:17:05 +0200 Subject: [PATCH 05/37] nits --- src/spec-node/dockerCompose.ts | 2 +- src/spec-node/singleContainer.ts | 12 ++++++------ src/spec-shutdown/dockerUtils.ts | 6 +++++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index b4ddad83e..13b6caace 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -188,7 +188,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf // determine whether we need to extend with features const version = parseVersion((await params.dockerComposeCLI()).version); - const supportsAdditionalBuildContexts = params.cliVariant !== CLIVariant.Podman && params.cliVariant !== CLIVariant.Wslc && version && !isEarlierVersion(version, [2, 17, 0]); + const supportsAdditionalBuildContexts = params.cliVariant === CLIVariant.Docker && version && !isEarlierVersion(version, [2, 17, 0]); const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index d7141886e..362559c2e 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -361,13 +361,13 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const featureArgs: string[] = []; // wslc does not support --init, --privileged, --cap-add, or --security-opt - if (mergedConfig.init && params.cliVariant !== CLIVariant.Wslc) { - featureArgs.push('--init'); - } - if (mergedConfig.privileged && params.cliVariant !== CLIVariant.Wslc) { - featureArgs.push('--privileged'); - } if (params.cliVariant !== CLIVariant.Wslc) { + if (mergedConfig.init) { + featureArgs.push('--init'); + } + if (mergedConfig.privileged) { + featureArgs.push('--privileged'); + } for (const cap of mergedConfig.capAdd || []) { featureArgs.push('--cap-add', cap); } diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index d1815fe60..0531f6b87 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -198,7 +198,11 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP }); }); } - await Promise.race([removedSeenP || delay(1000), delay(1000)]); + if (removedSeenP) { + await Promise.race([removedSeenP, delay(1000)]); + } else { + await delay(1000); + } } } } finally { From 61663573a317f498cf7664ed11ff59221b97ed77 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 24 Jun 2026 11:20:48 +0200 Subject: [PATCH 06/37] 0.88.0 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66792dd19..134e0266e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notable changes. +## June 2026 + +### [0.88.0] +- Add WSLc support (https://github.com/devcontainers/cli/pull/1249) + ## May 2026 ### [0.87.0] diff --git a/package.json b/package.json index b62f6402e..e9e4330b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.87.0", + "version": "0.88.0", "bin": { "devcontainer": "devcontainer.js" }, From 6e878c17dde705e6b915e6ba8210252925a691b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:42:01 +0200 Subject: [PATCH 07/37] Bump shell-quote from 1.8.3 to 1.8.4 (#1250) Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4. - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4) --- updated-dependencies: - dependency-name: shell-quote dependency-version: 1.8.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index c70431cf8..88060f07e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2688,9 +2688,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.6.1, shell-quote@^1.8.3: - version "1.8.3" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" - integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + version "1.8.4" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190" + integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ== side-channel-list@^1.0.0: version "1.0.0" From 496c2840c9929d4426044928d757873fc2c67dbc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:32:55 +0200 Subject: [PATCH 08/37] Bump esbuild from 0.27.3 to 0.28.1 (#1251) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.3 to 0.28.1. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.3...v0.28.1) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 318 +++++++++++++++++++++++++-------------------------- 2 files changed, 160 insertions(+), 160 deletions(-) diff --git a/package.json b/package.json index e9e4330b7..4ca76180b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@typescript-eslint/parser": "^8.56.1", "chai": "^4.5.0", "copyfiles": "^2.4.1", - "esbuild": "^0.27.3", + "esbuild": "^0.28.1", "eslint": "^10.0.2", "event-stream": "^4.0.1", "minimatch": "^10.2.4", diff --git a/yarn.lock b/yarn.lock index 88060f07e..c01ad5146 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,135 +9,135 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@esbuild/aix-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" - integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== - -"@esbuild/android-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" - integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== - -"@esbuild/android-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" - integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== - -"@esbuild/android-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" - integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== - -"@esbuild/darwin-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" - integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== - -"@esbuild/darwin-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" - integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== - -"@esbuild/freebsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" - integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== - -"@esbuild/freebsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" - integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== - -"@esbuild/linux-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" - integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== - -"@esbuild/linux-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" - integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== - -"@esbuild/linux-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" - integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== - -"@esbuild/linux-loong64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" - integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== - -"@esbuild/linux-mips64el@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" - integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== - -"@esbuild/linux-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" - integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== - -"@esbuild/linux-riscv64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" - integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== - -"@esbuild/linux-s390x@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" - integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== - -"@esbuild/linux-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" - integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== - -"@esbuild/netbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" - integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== - -"@esbuild/netbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" - integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== - -"@esbuild/openbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" - integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== - -"@esbuild/openbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" - integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== - -"@esbuild/openharmony-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" - integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== - -"@esbuild/sunos-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" - integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== - -"@esbuild/win32-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" - integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== - -"@esbuild/win32-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" - integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== - -"@esbuild/win32-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" - integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -1117,37 +1117,37 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild@^0.27.3: - version "0.27.3" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" - integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== +esbuild@^0.28.1: + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.3" - "@esbuild/android-arm" "0.27.3" - "@esbuild/android-arm64" "0.27.3" - "@esbuild/android-x64" "0.27.3" - "@esbuild/darwin-arm64" "0.27.3" - "@esbuild/darwin-x64" "0.27.3" - "@esbuild/freebsd-arm64" "0.27.3" - "@esbuild/freebsd-x64" "0.27.3" - "@esbuild/linux-arm" "0.27.3" - "@esbuild/linux-arm64" "0.27.3" - "@esbuild/linux-ia32" "0.27.3" - "@esbuild/linux-loong64" "0.27.3" - "@esbuild/linux-mips64el" "0.27.3" - "@esbuild/linux-ppc64" "0.27.3" - "@esbuild/linux-riscv64" "0.27.3" - "@esbuild/linux-s390x" "0.27.3" - "@esbuild/linux-x64" "0.27.3" - "@esbuild/netbsd-arm64" "0.27.3" - "@esbuild/netbsd-x64" "0.27.3" - "@esbuild/openbsd-arm64" "0.27.3" - "@esbuild/openbsd-x64" "0.27.3" - "@esbuild/openharmony-arm64" "0.27.3" - "@esbuild/sunos-x64" "0.27.3" - "@esbuild/win32-arm64" "0.27.3" - "@esbuild/win32-ia32" "0.27.3" - "@esbuild/win32-x64" "0.27.3" + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" escalade@^3.1.1: version "3.2.0" From f683c29f64a20109b4453e5149807e390ff65133 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:12:32 +0200 Subject: [PATCH 09/37] Bump tar from 7.5.11 to 7.5.16 (#1253) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.11 to 7.5.16. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.11...v7.5.16) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.16 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index c01ad5146..ff3c2fad1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2988,9 +2988,9 @@ supports-preserve-symlinks-flag@^1.0.0: integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== tar@^7.5.10: - version "7.5.11" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" - integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== + version "7.5.16" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.16.tgz#f11e063afed4554f758049d082909e37d6b53ced" + integrity sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" From 58be9705761d276b5076525438bbe73642f521d5 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:01:19 -0700 Subject: [PATCH 10/37] Save uncommitted changes (#1270) --- .github/workflows/publish-dev-containers.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-dev-containers.yml b/.github/workflows/publish-dev-containers.yml index 7dae6ca8d..bb5e275bc 100644 --- a/.github/workflows/publish-dev-containers.yml +++ b/.github/workflows/publish-dev-containers.yml @@ -8,6 +8,7 @@ on: permissions: contents: read actions: read + id-token: write jobs: main: @@ -18,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: '20.x' + node-version: '24.x' registry-url: 'https://registry.npmjs.org' scope: '@devcontainers' - name: Verify Versions @@ -46,5 +47,3 @@ jobs: path: . - name: Publish TGZ run: npm publish ${TGZ} --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 657e33ae2387799eec64b599ffd2f0939c1cd146 Mon Sep 17 00:00:00 2001 From: Kaniska Date: Tue, 11 Aug 2026 09:02:32 +0000 Subject: [PATCH 11/37] Fix flaky tests. --- .../container-features/containerFeaturesOrder.test.ts | 9 ++++++++- src/test/container-features/e2e.test.ts | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/test/container-features/containerFeaturesOrder.test.ts b/src/test/container-features/containerFeaturesOrder.test.ts index 8426a0946..d4c880809 100644 --- a/src/test/container-features/containerFeaturesOrder.test.ts +++ b/src/test/container-features/containerFeaturesOrder.test.ts @@ -674,7 +674,8 @@ describe('Feature Dependencies', function () { } }); - assert.deepStrictEqual(actual.length, 3); + // The deprecated terraform shorthand resolves to terraform:1, which has github-cli as a hard dependency. + assert.deepStrictEqual(actual.length, 4); assert.deepStrictEqual(actual, [ { @@ -687,6 +688,12 @@ describe('Feature Dependencies', function () { greeting: 'howdy' } }, + { + id: 'ghcr.io/devcontainers/features/github-cli', + options: { + version: 'latest' + } + }, { id: 'ghcr.io/devcontainers/features/terraform', options: 'latest' diff --git a/src/test/container-features/e2e.test.ts b/src/test/container-features/e2e.test.ts index 7976b0c95..eb1b8791e 100644 --- a/src/test/container-features/e2e.test.ts +++ b/src/test/container-features/e2e.test.ts @@ -88,7 +88,8 @@ describe('Dev Container Features E2E (remote)', function () { const response = JSON.parse(res.stdout); console.log(res.stderr); - assert.strictEqual(response.featuresConfiguration?.featureSets.length, 3); + // The deprecated terraform shorthand resolves to terraform:1, which has github-cli as a hard dependency. + assert.strictEqual(response.featuresConfiguration?.featureSets.length, 4); const dind = response?.featuresConfiguration.featureSets.find((f: FeatureSet) => f?.features[0]?.id === 'docker-in-docker'); assert.exists(dind); From 05249c915306ca3e14a1ca0cc5498bbf478ab710 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 11 Aug 2026 17:04:39 +0200 Subject: [PATCH 12/37] 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 13/37] 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 14/37] 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 15/37] 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 16/37] 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 17/37] 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 18/37] 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 19/37] 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 20/37] 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 21/37] 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 22/37] 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`); }); From 0c9f4ca69385384020aabb256cf5665210fe341a Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Sat, 15 Aug 2026 23:15:47 +0200 Subject: [PATCH 23/37] Handle missing Podman user storage config Create the rootless user override from the system storage config when the hosted runner has not materialized a user config file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/test/cli.podman.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/test/cli.podman.test.ts b/src/test/cli.podman.test.ts index 59da2f95a..fa0e9e061 100644 --- a/src/test/cli.podman.test.ts +++ b/src/test/cli.podman.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as path from 'path'; -import { readFile, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { shellExec } from './testUtils'; const pkg = require('../../package.json'); @@ -20,8 +20,16 @@ describe('Dev Containers CLI using Podman', function () { 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 userStorageConfig = path.join(process.env.HOME!, '.config', 'containers', 'storage.conf'); + let storageConfigContent: string; + try { + storageConfigContent = await readFile(userStorageConfig, 'utf8'); + } catch (err) { + if (err?.code !== 'ENOENT') { + throw err; + } + storageConfigContent = await readFile('/etc/containers/storage.conf', 'utf8'); + } const updatedStorageConfigContent = storageConfigContent.replace( /^(\s*ignore_chown_errors\s*=\s*)"true"/m, '$1"false"' @@ -30,7 +38,8 @@ describe('Dev Containers CLI using Podman', function () { // 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 mkdir(path.dirname(userStorageConfig), { recursive: true }); + await writeFile(userStorageConfig, updatedStorageConfigContent); } } await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); From 3d2bed9ab8072dc9f66d0551c2c6909b27eee13d Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 17 Aug 2026 09:17:11 +0200 Subject: [PATCH 24/37] Revert "Handle missing Podman user storage config" This reverts commit 0c9f4ca69385384020aabb256cf5665210fe341a. --- src/test/cli.podman.test.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/test/cli.podman.test.ts b/src/test/cli.podman.test.ts index fa0e9e061..59da2f95a 100644 --- a/src/test/cli.podman.test.ts +++ b/src/test/cli.podman.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as path from 'path'; -import { mkdir, readFile, writeFile } from 'fs/promises'; +import { readFile, writeFile } from 'fs/promises'; import { shellExec } from './testUtils'; const pkg = require('../../package.json'); @@ -20,16 +20,8 @@ describe('Dev Containers CLI using Podman', function () { await shellExec(`rm -rf ${tmp}/node_modules`); await shellExec(`mkdir -p ${tmp}`); if (process.env.GITHUB_ACTIONS === 'true') { - const userStorageConfig = path.join(process.env.HOME!, '.config', 'containers', 'storage.conf'); - let storageConfigContent: string; - try { - storageConfigContent = await readFile(userStorageConfig, 'utf8'); - } catch (err) { - if (err?.code !== 'ENOENT') { - throw err; - } - storageConfigContent = await readFile('/etc/containers/storage.conf', 'utf8'); - } + 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"' @@ -38,8 +30,7 @@ describe('Dev Containers CLI using Podman', function () { // 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 mkdir(path.dirname(userStorageConfig), { recursive: true }); - await writeFile(userStorageConfig, updatedStorageConfigContent); + await writeFile(storageConfig, updatedStorageConfigContent); } } await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); From 5f4967044a7e2190f3526c5d721985eebc2723c1 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 17 Aug 2026 09:17:12 +0200 Subject: [PATCH 25/37] Revert "Fix rootless Podman tests on hosted runners" This reverts commit 98e182ebbcdc3628bae71779e905305c36873cbd. --- src/test/cli.podman.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/test/cli.podman.test.ts b/src/test/cli.podman.test.ts index 59da2f95a..932d9a358 100644 --- a/src/test/cli.podman.test.ts +++ b/src/test/cli.podman.test.ts @@ -5,7 +5,6 @@ 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'); @@ -19,20 +18,6 @@ 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`); }); From 98c52653513a2116e3e578bad7de50e2bbf85b92 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 20 Aug 2026 15:31:38 +0200 Subject: [PATCH 26/37] Use Podman 5.5.2 for hosted integration tests (#1280) Pin the external static-build recipe to an immutable commit while keeping the existing rootless Podman Feature workloads unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/dev-containers.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 930d77d31..98586f34c 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -89,6 +89,19 @@ jobs: fi cat "$DAEMON_JSON" sudo systemctl restart docker + - name: Install Podman 5.5.2 + if: matrix.mocha-args == 'src/test/cli.podman.test.ts' + run: | + docker build \ + --build-arg PODMAN_VERSION=v5.5.2 \ + --target podman \ + --tag podman-5.5.2-builder \ + 'https://github.com/mgoltzsche/podman-static.git#9ccf992536ecf0a4a3b82ac27fc708f00ab141ab' + container_id=$(docker create podman-5.5.2-builder) + docker cp "${container_id}:/usr/local/bin/podman" podman + docker rm "${container_id}" + sudo install -m 0755 podman /usr/local/bin/podman + podman --version - name: Tools Info run: | docker info From 33073dbaba2545c51b4f8396e179c18231e80124 Mon Sep 17 00:00:00 2001 From: Kaniska Date: Thu, 20 Aug 2026 20:08:52 +0530 Subject: [PATCH 27/37] fix: set default base image in Dockerfiles and improve syntax handling (#1271) * fix: set default base image in Dockerfiles and improve syntax handling * Retrigger test * Addressing review comments. * Retrigger tests * Fixing failing tests due to change in terraform feature to add hard dependency of github-cli feature. * Removing unrelated test fixes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Abdurrahmaan Iqbal Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer-lock.json | 2 +- scripts/updateUID.Dockerfile | 2 +- .../containerFeaturesConfiguration.ts | 2 ++ src/spec-node/containerFeatures.ts | 4 +-- .../generateFeaturesConfig.test.ts | 31 ++++++++++++++++--- src/test/testUtils.ts | 30 ++++++++++++++++++ 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 53c5a53c2..55c2bfcef 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -6,4 +6,4 @@ "integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd" } } -} +} \ No newline at end of file diff --git a/scripts/updateUID.Dockerfile b/scripts/updateUID.Dockerfile index 9f6c9a854..3cd1c33fe 100644 --- a/scripts/updateUID.Dockerfile +++ b/scripts/updateUID.Dockerfile @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -ARG BASE_IMAGE +ARG BASE_IMAGE=placeholder FROM $BASE_IMAGE USER root diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 5957d0896..5baf8fbce 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -203,6 +203,8 @@ export function getContainerFeaturesBaseDockerFile(contentSourceRootPath: string #{nonBuildKitFeatureContentFallback} +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch + FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root COPY --from=dev_containers_feature_content_source ${path.posix.join(contentSourceRootPath, 'devcontainer-features.builtin.env')} /tmp/build-features/ diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index b3d2dff88..eebb793f2 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -206,7 +206,7 @@ ${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, `, overrideTarget: 'dev_containers_target_stage', dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''} - ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + ARG _DEV_CONTAINERS_BASE_IMAGE=scratch `, buildArgs: { _DEV_CONTAINERS_BASE_IMAGE: baseName, @@ -274,7 +274,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont skipDefaultSyntax ? (syntax ? `# syntax=${syntax}` : '') : useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' : syntax ? `# syntax=${syntax}` : ''} -ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch `; // Build devcontainer-features.env and devcontainer-features-install.sh file(s) for each features source folder diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 915c3e2da..f1bcf4582 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -1,23 +1,23 @@ import { assert } from 'chai'; -import { generateFeaturesConfig, getFeatureLayers, FeatureSet } from '../../spec-configuration/containerFeaturesConfiguration'; +import { generateFeaturesConfig, getFeatureLayers, getContainerFeaturesBaseDockerFile, FeatureSet } from '../../spec-configuration/containerFeaturesConfiguration'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; import * as path from 'path'; import * as process from 'process'; import * as os from 'os'; import * as crypto from 'crypto'; -import { mkdirpLocal } from '../../spec-utils/pfs'; +import { mkdirpLocal, readLocalFile } 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 { findFromArgsWithoutDefault, shellExec } from '../testUtils'; import { getEntPasswdShellCommand } from '../../spec-common/commonUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); -// Test fetching/generating the devcontainer-features.json config +// Testing fetching/generating the devcontainer-features.json config describe('validate generateFeaturesConfig()', function () { - // Setup + // Setup for tests const env = { 'SOME_KEY': 'SOME_VAL' }; const platform = process.platform; const cacheFolder = path.join(os.tmpdir(), `devcontainercli-test-${crypto.randomUUID()}`); @@ -135,4 +135,25 @@ RUN chmod -R 0755 /tmp/dev-container-features/hello_1 \\ const javaSettings = java?.features[0]?.customizations?.vscode?.settings; assert.isObject(javaSettings); }); +}); + +describe('validate generated Dockerfiles avoid InvalidDefaultArgInFrom', function () { + + it('feature base Dockerfile declares _DEV_CONTAINERS_BASE_IMAGE with a default before FROM', function () { + const dockerfile = getContainerFeaturesBaseDockerFile('/tmp/build-features'); + assert.match(dockerfile, /ARG _DEV_CONTAINERS_BASE_IMAGE=\S+/, 'ARG should have a default value'); + assert.match(dockerfile, /FROM \$_DEV_CONTAINERS_BASE_IMAGE\b/, 'FROM should reference the ARG directly'); + assert.notMatch(dockerfile, /FROM \$\{_DEV_CONTAINERS_BASE_IMAGE:-/, 'should not rely on ${VAR:-default} shell fallback'); + assert.deepStrictEqual(findFromArgsWithoutDefault(dockerfile), []); + }); + + it('validate that updateUID.Dockerfile declares BASE_IMAGE with a default before FROM', async function () { + const content = (await readLocalFile('scripts/updateUID.Dockerfile')).toString(); + assert.match(content, /ARG BASE_IMAGE=\S+/, 'BASE_IMAGE ARG should have a default value'); + assert.deepStrictEqual( + findFromArgsWithoutDefault(content), + [], + 'updateUID.Dockerfile FROM references an ARG without a default' + ); + }); }); \ No newline at end of file diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 22597dce2..9ef38537e 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -126,6 +126,36 @@ export async function pathExists(cli: string, workspaceFolder: string, location: return false; } } + +export function findFromArgsWithoutDefault(dockerfile: string): string[] { + const preambleArgsWithDefault = new Set(); + const offenders: string[] = []; + let beforeFirstFrom = true; + + for (const rawLine of dockerfile.split('\n')) { + const line = rawLine.trim(); + + const argWithDefault = /^ARG\s+([A-Za-z0-9_]+)\s*=\s*\S+/.exec(line); + if (argWithDefault) { + if (beforeFirstFrom) { + preambleArgsWithDefault.add(argWithDefault[1]); + } + continue; + } + + const fromMatch = /^FROM(?:\s+--platform=\S+)?\s+\$\{?([A-Za-z0-9_]+)/i.exec(line); + if (fromMatch && !preambleArgsWithDefault.has(fromMatch[1])) { + offenders.push(fromMatch[1]); + } + + if (/^FROM\b/i.test(line)) { + beforeFirstFrom = false; + } + } + + return offenders; +} + export async function commandMarkerTests(cli: string, workspaceFolder: string, expected: { postCreate: boolean; postStart: boolean; postAttach: boolean }, message: string) { const actual = { postCreate: await pathExists(cli, workspaceFolder, '/tmp/postCreateCommand.testmarker'), From 2a6de23e6881b964cc9ad764f7df1963f1782956 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Aug 2026 17:14:06 +0200 Subject: [PATCH 28/37] Honor HTTPS for localhost OCI auth realms Use a generated, trusted localhost certificate in the refresh-token integration test without changing production CA handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + src/spec-utils/httpRequest.ts | 6 +- src/test/httpOCIRegistry.test.ts | 87 +++++++++++++------ src/test/httpOCIRegistryRefreshTokenClient.ts | 40 +++++++++ 4 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 src/test/httpOCIRegistryRefreshTokenClient.ts diff --git a/.gitignore b/.gitignore index aac8d0b78..1231bb8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ src/test/container-features/configs/temp_lifecycle-hooks-alternative-order test-secrets-temp.json src/test/container-*/**/src/**/README.md !src/test/container-features/assets/*.tgz +src/test/fixtures/localhost-cert.pem +src/test/fixtures/localhost-key.pem diff --git a/src/spec-utils/httpRequest.ts b/src/spec-utils/httpRequest.ts index b5097aae0..64cdc1daa 100644 --- a/src/spec-utils/httpRequest.ts +++ b/src/spec-utils/httpRequest.ts @@ -25,7 +25,7 @@ export async function request(options: { type: string; url: string; headers: Rec secureContext, }; - const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; + const plainHTTP = parsed.protocol === 'http:'; if (plainHTTP) { output.write('Sending as plain HTTP request', LogLevel.Warning); } @@ -64,7 +64,7 @@ export async function headRequest(options: { url: string; headers: Record { describe('isAllowedTokenServiceRealm', () => { const cases = [ @@ -240,7 +278,7 @@ describe('OCI registry authentication', () => { const refreshToken = 'registry-refresh-token'; const bearerScheme = 'Bearer'; let tokenRequests = 0; - const tokenServer = http.createServer(async (request, response) => { + const tokenServer = https.createServer(await getLocalhostCertificate(), async (request, response) => { tokenRequests++; try { const chunks: Buffer[] = []; @@ -281,42 +319,35 @@ describe('OCI registry authentication', () => { }, }, })); - const previousDockerConfig = process.env.DOCKER_CONFIG; - process.env.DOCKER_CONFIG = dockerConfig; try { - const ociRef: OCICollectionRef = { - registry, - path: 'test/features', - resource: `${registry}/test/features`, - tag: 'latest', - version: 'latest', - }; - - const result = await requestEnsureAuthenticated({ - ...createTestCommonParams(nullLog, {}), - allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], - ociAuthHardening: true, - }, { - type: 'GET', - url: `http://${registry}/v2/test/features/manifests/latest`, - headers: {}, - }, ociRef); + const { stdout } = await execFileAsync(process.execPath, [ + '-r', + 'ts-node/register', + join(__dirname, 'httpOCIRegistryRefreshTokenClient.ts'), + ], { + cwd: join(__dirname, '..', '..'), + encoding: 'utf8', + env: { + ...process.env, + DOCKER_CONFIG: dockerConfig, + NODE_EXTRA_CA_CERTS: certificatePath, + TEST_REGISTRY: registry, + TEST_TOKEN_PORT: `${tokenPort}`, + TS_NODE_PROJECT: join(__dirname, 'tsconfig.json'), + }, + }); + const result = JSON.parse(stdout); - assert.equal(result?.statusCode, 200); + assert.equal(result.statusCode, 200); assert.equal(registryRequests, 2); assert.equal(tokenRequests, 1); - assert.deepEqual(result?.ociAuthDiagnostics, { + assert.deepEqual(result.ociAuthDiagnostics, { authLookupWouldBeBlocked: false, registryRedirectWouldPreventCredentialForwarding: false, authServerRedirect: false, }); } 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)]); } diff --git a/src/test/httpOCIRegistryRefreshTokenClient.ts b/src/test/httpOCIRegistryRefreshTokenClient.ts new file mode 100644 index 000000000..592b9ce13 --- /dev/null +++ b/src/test/httpOCIRegistryRefreshTokenClient.ts @@ -0,0 +1,40 @@ +import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; +import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { nullLog } from '../spec-utils/log'; +import { createTestCommonParams } from './testUtils'; + +function requiredEnvironmentVariable(name: string) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name}.`); + } + return value; +} + +const registry = requiredEnvironmentVariable('TEST_REGISTRY'); +const tokenPort = requiredEnvironmentVariable('TEST_TOKEN_PORT'); +const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', +}; + +requestEnsureAuthenticated({ + ...createTestCommonParams(nullLog, {}), + allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], + ociAuthHardening: true, +}, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, +}, ociRef).then(result => { + process.stdout.write(JSON.stringify({ + statusCode: result?.statusCode, + ociAuthDiagnostics: result?.ociAuthDiagnostics, + })); +}, error => { + console.error(error); + process.exitCode = 1; +}); From 1e47c10909a3ddf6dc04d02d54077241e7039e97 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Aug 2026 17:50:02 +0200 Subject: [PATCH 29/37] Preserve localhost OCI registry transport Store the HTTP or HTTPS scheme on parsed OCI references so registry endpoints do not rely on implicit transport downgrades. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../containerCollectionsOCI.ts | 12 ++++++++++-- .../containerCollectionsOCIPush.ts | 8 ++++---- src/spec-configuration/containerFeaturesOCI.ts | 2 +- src/spec-configuration/containerTemplatesOCI.ts | 2 +- src/spec-node/utils.ts | 7 ++++--- .../containerFeaturesOCI.test.ts | 16 ++++++++++++++++ .../container-features/featureHelpers.test.ts | 3 +++ src/test/httpOCIRegistry.test.ts | 5 +++++ src/test/httpOCIRegistryRefreshTokenClient.ts | 1 + 9 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 12f26d2bd..89b8cfc7b 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -29,6 +29,7 @@ export interface CommonParams { // eg: ghcr.io/devcontainers/features/go@sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3 // Constructed by 'getRef()' export interface OCIRef { + scheme: 'http' | 'https'; registry: string; // 'ghcr.io' owner: string; // 'devcontainers' namespace: string; // 'devcontainers/features' @@ -45,6 +46,7 @@ export interface OCIRef { // eg: ghcr.io/devcontainers/features:latest // Constructed by 'getCollectionRef()' export interface OCICollectionRef { + scheme: 'http' | 'https'; registry: string; // 'ghcr.io' path: string; // 'devcontainers/features' resource: string; // 'ghcr.io/devcontainers/features' @@ -120,6 +122,10 @@ const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)* // MUST be at most 128 characters in length and MUST match the following regular expression: const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; +function getRegistryScheme(registry: string): OCIRef['scheme'] { + return new URL(`https://${registry}`).hostname.toLowerCase() === 'localhost' ? 'http' : 'https'; +} + // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH { @@ -240,6 +246,7 @@ export function getRef(output: Log, input: string): OCIRef | undefined { output.write(`> digest?: ${digest}`, LogLevel.Trace); return { + scheme: getRegistryScheme(registry), id, owner, namespace, @@ -270,6 +277,7 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin } return { + scheme: getRegistryScheme(registry), registry, path, resource, @@ -295,7 +303,7 @@ export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef if (manifestDigest) { reference = manifestDigest; } - const manifestUrl = `https://${ref.registry}/v2/${ref.path}/manifests/${reference}`; + const manifestUrl = `${ref.scheme}://${ref.registry}/v2/${ref.path}/manifests/${reference}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); const expectedDigest = manifestDigest || ('digest' in ref ? ref.digest : undefined); const manifestContainer = await getManifest(params, manifestUrl, ref, undefined, expectedDigest); @@ -471,7 +479,7 @@ export async function getVersionsStrictSorted(params: CommonParams, ref: OCIRef) export async function getPublishedTags(params: CommonParams, ref: OCIRef): Promise { const { output } = params; try { - const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; + const url = `${ref.scheme}://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; const headers = { 'Accept': 'application/json', diff --git a/src/spec-configuration/containerCollectionsOCIPush.ts b/src/spec-configuration/containerCollectionsOCIPush.ts index 24f811663..51e1664fe 100644 --- a/src/spec-configuration/containerCollectionsOCIPush.ts +++ b/src/spec-configuration/containerCollectionsOCIPush.ts @@ -167,7 +167,7 @@ async function putManifestWithTags(params: CommonParams, manifest: ManifestConta const { manifestBuffer, contentDigest } = manifest; for await (const tag of tags) { - const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; output.write(`PUT -> '${url}'`, LogLevel.Trace); const httpOptions = { @@ -232,7 +232,7 @@ async function putBlob(params: CommonParams, blobPutLocationUriPath: string, oci if (blobPutLocationUriPath.startsWith('https://') || blobPutLocationUriPath.startsWith('http://')) { url = blobPutLocationUriPath; } else { - url = `https://${ociRef.registry}${blobPutLocationUriPath}`; + url = `${ociRef.scheme}://${ociRef.registry}${blobPutLocationUriPath}`; } // The MAY contain critical query parameters. @@ -332,7 +332,7 @@ export async function calculateDataLayer(output: Log, data: Buffer, basename: st export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | OCICollectionRef, digest: string): Promise { const { output } = params; - const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; const res = await requestEnsureAuthenticated(params, { type: 'HEAD', url, headers: {} }, ociRef); if (!res) { output.write('Request failed', LogLevel.Error); @@ -349,7 +349,7 @@ export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | O async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise { const { output } = params; - const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace); const res = await requestEnsureAuthenticated(params, { type: 'POST', url, headers: {} }, ociRef); diff --git a/src/spec-configuration/containerFeaturesOCI.ts b/src/spec-configuration/containerFeaturesOCI.ts index 84fb869cf..c4a0d3cae 100644 --- a/src/spec-configuration/containerFeaturesOCI.ts +++ b/src/spec-configuration/containerFeaturesOCI.ts @@ -61,7 +61,7 @@ export async function fetchOCIFeature(params: CommonParams, featureSet: FeatureS const { featureRef } = featureSet.sourceInformation; const layerDigest = featureSet.sourceInformation.manifest?.layers[0].digest; - const blobUrl = `https://${featureSet.sourceInformation.featureRef.registry}/v2/${featureSet.sourceInformation.featureRef.path}/blobs/${layerDigest}`; + const blobUrl = `${featureRef.scheme}://${featureRef.registry}/v2/${featureRef.path}/blobs/${layerDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const blobResult = await getBlob(params, blobUrl, ociCacheDir, featCachePath, featureRef, layerDigest, undefined, metadataFile); diff --git a/src/spec-configuration/containerTemplatesOCI.ts b/src/spec-configuration/containerTemplatesOCI.ts index 4c5c27755..18cab0442 100644 --- a/src/spec-configuration/containerTemplatesOCI.ts +++ b/src/spec-configuration/containerTemplatesOCI.ts @@ -43,7 +43,7 @@ export async function fetchTemplate(params: CommonParams, selectedTemplate: Sele return; } - const blobUrl = `https://${templateRef.registry}/v2/${templateRef.path}/blobs/${blobDigest}`; + const blobUrl = `${templateRef.scheme}://${templateRef.registry}/v2/${templateRef.path}/blobs/${blobDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const tmpDir = userProvidedTmpDir || path.join(os.tmpdir(), 'vsch-template-temp', `${Date.now()}`); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index ebbe51887..9a62da347 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -330,7 +330,8 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform } const registryServer = ref.registry === 'docker.io' ? 'registry-1.docker.io' : ref.registry; - const manifestUrl = `https://${registryServer}/v2/${ref.path}/manifests/${ref.version}`; + const registryOrigin = `${ref.scheme}://${registryServer}`; + const manifestUrl = `${registryOrigin}/v2/${ref.path}/manifests/${ref.version}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); let targetDigest: string | undefined = undefined; @@ -342,7 +343,7 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform // Spec: https://github.com/opencontainers/image-spec/blob/main/image-index.md const imageIndexEntry = await getImageIndexEntryForPlatform(params, manifestUrl, ref, platformInfo); if (imageIndexEntry) { - const manifestUrl = `https://${registryServer}/v2/${ref.path}/manifests/${imageIndexEntry.digest}`; + const manifestUrl = `${registryOrigin}/v2/${ref.path}/manifests/${imageIndexEntry.digest}`; const a = await getManifest(params, manifestUrl, ref); if (a) { targetDigest = a.manifestObj.config.digest; @@ -354,7 +355,7 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform throw new Error(`No manifest found for ${resourceAndVersion}.`); } - const blobUrl = `https://${registryServer}/v2/${ref.path}/blobs/${targetDigest}`; + const blobUrl = `${registryOrigin}/v2/${ref.path}/blobs/${targetDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const httpOptions = { diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index 529b07099..982606cc0 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -5,6 +5,22 @@ import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); +describe('registry scheme', () => { + const cases = [ + { identifier: 'localhost/owner/features/test', expected: 'http' }, + { identifier: 'localhost:5000/owner/features/test', expected: 'http' }, + { identifier: 'LOCALHOST:5000/owner/features/test', expected: 'http' }, + { identifier: '127.0.0.1:5000/owner/features/test', expected: 'https' }, + { identifier: 'registry.example:5000/owner/features/test', expected: 'https' }, + ]; + + for (const { identifier, expected } of cases) { + it(`uses '${expected}' for '${identifier}'`, () => { + assert.equal(getRef(output, identifier)?.scheme, expected); + }); + } +}); + describe('getCollectionRef()', async function () { this.timeout('240s'); diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 3ff6f3c81..49d040a75 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -206,6 +206,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'codspace', namespace: 'codspace/features', @@ -242,6 +243,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'devcontainers', namespace: 'devcontainers/features', @@ -278,6 +280,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'codspace', namespace: 'codspace/features', diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 98dc04af7..d046a74e3 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -133,6 +133,7 @@ describe('OCI registry authentication', () => { try { const registry = `127.0.0.1:${registryPort}`; const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, @@ -206,6 +207,7 @@ describe('OCI registry authentication', () => { try { const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, @@ -252,6 +254,7 @@ describe('OCI registry authentication', () => { try { const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, @@ -381,6 +384,7 @@ describe('OCI registry authentication', () => { try { const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, @@ -441,6 +445,7 @@ describe('OCI registry authentication', () => { try { const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, diff --git a/src/test/httpOCIRegistryRefreshTokenClient.ts b/src/test/httpOCIRegistryRefreshTokenClient.ts index 592b9ce13..8beafaac1 100644 --- a/src/test/httpOCIRegistryRefreshTokenClient.ts +++ b/src/test/httpOCIRegistryRefreshTokenClient.ts @@ -14,6 +14,7 @@ function requiredEnvironmentVariable(name: string) { const registry = requiredEnvironmentVariable('TEST_REGISTRY'); const tokenPort = requiredEnvironmentVariable('TEST_TOKEN_PORT'); const ociRef: OCICollectionRef = { + scheme: 'http', registry, path: 'test/features', resource: `${registry}/test/features`, From aee6df1a3e3662df44d3f86b449121824314c9e4 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Aug 2026 17:52:58 +0200 Subject: [PATCH 30/37] Add schemes to OCI test references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/test/container-features/featureHelpers.test.ts | 3 +++ src/test/container-features/generateLockfile.test.ts | 1 + src/test/imageMetadata.test.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 49d040a75..cbc6a92a0 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -634,6 +634,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', @@ -718,6 +719,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', @@ -805,6 +807,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', diff --git a/src/test/container-features/generateLockfile.test.ts b/src/test/container-features/generateLockfile.test.ts index 4737a59a4..4919c64e4 100644 --- a/src/test/container-features/generateLockfile.test.ts +++ b/src/test/container-features/generateLockfile.test.ts @@ -22,6 +22,7 @@ function makeOciFeatureSet(userFeatureId: string, version: string, digest: strin manifestDigest: digest, manifest: {} as any, featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'devcontainers', namespace: 'devcontainers/features', diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index fb78df0f5..b45087d58 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -568,6 +568,7 @@ function getFeaturesConfig(features: Feature[]): FeaturesConfig { userFeatureId: `ghcr.io/my-org/my-repo/${feature.id}:1`, userFeatureIdWithoutVersion: `ghcr.io/my-org/my-repo/${feature.id}`, featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', From 011f72fc32316894d5eb38543a1c9f67bf331715 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:18:11 +0200 Subject: [PATCH 31/37] Bump tar from 7.5.16 to 7.5.21 (#1292) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.21. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.21) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.21 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ff3c2fad1..58b2bc879 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2988,9 +2988,9 @@ supports-preserve-symlinks-flag@^1.0.0: integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== tar@^7.5.10: - version "7.5.16" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.16.tgz#f11e063afed4554f758049d082909e37d6b53ced" - integrity sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w== + version "7.5.21" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.21.tgz#b3405af2eb493523ce4379f531e9ebda0601bc59" + integrity sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" From 9b5e010a51d554736b48dfac80b69202568a01e7 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 28 Aug 2026 13:07:27 +0200 Subject: [PATCH 32/37] Bump brace-expansion to patched versions (#1294) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- yarn.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 58b2bc879..6cae38c45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -653,24 +653,24 @@ bl@^5.0.0: readable-stream "^3.4.0" brace-expansion@^1.1.7: - version "1.1.13" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" - integrity "sha1-03h1wB3J7/mI3UnREqV8tntU7+Y= sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==" + version "1.1.18" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab" + integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.3.tgz#0493338bdd58e319b1039c67cf7ee439892c01d9" - integrity "sha1-BJMzi91Y4xmxA5xnz37kOYksAdk= sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==" + version "2.1.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.4.tgz#589dab11c0018d0366be64cd8bf12c8dbecc8326" + integrity sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg== dependencies: balanced-match "^1.0.0" brace-expansion@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity "sha1-3MOjcRa3nz4bRtuZTO1dVw6TD9s= sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==" + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== dependencies: balanced-match "^4.0.2" From 53a7b99cb32b0951aba54b24ff64686ffd3b0e15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:12:52 +0200 Subject: [PATCH 33/37] Bump ip-address from 10.2.0 to 10.5.0 (#1295) Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.5.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.5.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.5.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6cae38c45..330d1918f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1681,9 +1681,9 @@ internal-slot@^1.1.0: side-channel "^1.1.0" ip-address@^10.0.1: - version "10.2.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" - integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + version "10.5.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b" + integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g== is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: version "3.0.5" From 5d6f8317a6d9d05d8e73a13ab5b1f0137aeb706f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:55:13 +0200 Subject: [PATCH 34/37] Bump js-yaml from 4.1.1 to 4.3.1 (#1296) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.3.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 330d1918f..b30708027 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1928,9 +1928,9 @@ jackspeak@^3.1.2: "@pkgjs/parseargs" "^0.11.0" js-yaml@^4.1.0, js-yaml@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" - integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + version "4.3.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848" + integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ== dependencies: argparse "^2.0.1" From 6083827d450a381e4bf7ad1cd588c558d0fbb03a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:12:40 +0200 Subject: [PATCH 35/37] Bump shell-quote from 1.8.4 to 1.9.0 (#1297) Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.4 to 1.9.0. - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.9.0) --- updated-dependencies: - dependency-name: shell-quote dependency-version: 1.9.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b30708027..903f50a91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2688,9 +2688,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.6.1, shell-quote@^1.8.3: - version "1.8.4" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190" - integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ== + version "1.9.0" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.9.0.tgz#e108b1a136586d5964edb3300016d4bedba0fe57" + integrity sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA== side-channel-list@^1.0.0: version "1.0.0" From 6360fbeb752ed4262973962e80e4ca7cc7da6828 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 28 Aug 2026 16:44:07 +0200 Subject: [PATCH 36/37] Bind OCI credentials to registry origins Prevent cross-origin upload continuations from receiving or caching credentials belonging to the original registry while retaining known Docker Hub aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 44 ++++- src/test/httpOCIRegistry.test.ts | 200 +++++++++++++++++++++- 2 files changed, 235 insertions(+), 9 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 5cf907db5..88ec2986d 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -44,6 +44,13 @@ const builtInCrossOriginAuthHosts = [ 'registry.gitlab.com=gitlab.com', ]; +const dockerHubRegistryHosts = new Set([ + 'registry-1.docker.io', + 'registry.docker.io', + 'docker.io', + 'index.docker.io', +]); + function normalizeHttpsAuthority(authority: string): string { let parsed: URL; try { @@ -94,6 +101,18 @@ function isAllowedTokenServiceRealmForPolicy(realmUrl: URL, registryUrl: URL, cr && isConfiguredCrossOriginAuthHost(registryUrl, realmUrl, crossOriginAuthHosts); } +export function isOCIRegistryOrigin(url: URL, ociRef: OCIRef | OCICollectionRef) { + const registryUrl = new URL(`${ociRef.scheme}://${ociRef.registry}`); + if (url.origin.toLowerCase() === registryUrl.origin.toLowerCase()) { + return true; + } + // Docker Hub references and distribution requests use several equivalent authorities. + return url.protocol === 'https:' + && registryUrl.protocol === 'https:' + && dockerHubRegistryHosts.has(url.host.toLowerCase()) + && dockerHubRegistryHosts.has(registryUrl.host.toLowerCase()); +} + // 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 { try { @@ -142,17 +161,22 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio // -- Update headers httpOptions.headers['user-agent'] = 'devcontainer'; + const requestedRegistryUrl = new URL(httpOptions.url); + const requestCanUseRegistryCredentials = isOCIRegistryOrigin(requestedRegistryUrl, ociRef); + if (params.ociAuthHardening && !requestCanUseRegistryCredentials) { + delete httpOptions.headers.authorization; + } // If the user has a cached auth token, attempt to use that first. - const maybeCachedAuthHeader = cachedAuthHeader[ociRef.registry]; + const maybeCachedAuthHeader = !params.ociAuthHardening || requestCanUseRegistryCredentials + ? cachedAuthHeader[ociRef.registry] + : undefined; if (maybeCachedAuthHeader) { output.write(`[httpOci] Applying cachedAuthHeader for registry ${ociRef.registry}...`, LogLevel.Trace); httpOptions.headers.authorization = maybeCachedAuthHeader; } - 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(); + const challengeFromRequestedRegistry = requestedRegistryUrl.origin.toLowerCase() === registryUrl.origin.toLowerCase(); // For anything except a 401 (invalid/no token) or 403 (insufficient scope) // response simply return the original response to the caller. @@ -162,8 +186,8 @@ 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.`); + if (!requestCanUseRegistryCredentials || !challengeFromRequestedRegistry) { + recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Request to '${requestedRegistryUrl.host}' with authentication challenge from '${registryUrl.host}' would prevent forwarding registry '${ociRef.registry}' credentials with OCI auth hardening.`); } // Attempt to authenticate via WWW-Authenticate Header. @@ -180,6 +204,10 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); + if (params.ociAuthHardening && (!requestCanUseRegistryCredentials || !challengeFromRequestedRegistry)) { + output.write(`[httpOci] ERR: Refusing to send registry '${ociRef.registry}' credentials to '${requestedRegistryUrl.host}'.`, LogLevel.Error); + return; + } const credential = await getCredential(params, ociRef); const basicAuthCredential = credential?.base64EncodedCredential; if (!basicAuthCredential) { @@ -230,7 +258,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio scope: scopeGroup ? scopeGroup[1] : '', }; - const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || challengeFromRequestedRegistry; + const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromRequestedRegistry); const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); @@ -250,7 +278,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] ${reattemptRes.statusCode} on reattempt after auth: ${httpOptions.url}`, LogLevel.Trace); // Cache the auth header if the request did not result in an unauthorized response. - if (reattemptRes.statusCode !== 401) { + if (reattemptRes.statusCode !== 401 && (!params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromRequestedRegistry))) { params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; } diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index d046a74e3..5d1d34921 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -10,7 +10,7 @@ import { promisify } from 'util'; import { assert } from 'chai'; import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; -import { isAllowedTokenServiceRealm, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { isAllowedTokenServiceRealm, isOCIRegistryOrigin, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; import { nullLog } from '../spec-utils/log'; import { createTestCommonParams } from './testUtils'; @@ -113,6 +113,138 @@ describe('OCI registry authentication', () => { } }); + describe('isOCIRegistryOrigin', () => { + const ociRef: OCICollectionRef = { + scheme: 'https', + registry: 'registry.example', + path: 'test/features', + resource: 'registry.example/test/features', + tag: 'latest', + version: 'latest', + }; + + it('accepts the reference registry origin', () => { + assert.isTrue(isOCIRegistryOrigin(new URL('https://registry.example/v2/'), ociRef)); + }); + + it('rejects a different origin', () => { + assert.isFalse(isOCIRegistryOrigin(new URL('https://uploads.example/v2/'), ociRef)); + }); + + it('rejects a protocol downgrade on the reference authority', () => { + assert.isFalse(isOCIRegistryOrigin(new URL('http://registry.example/v2/'), ociRef)); + }); + + it('accepts Docker Hub distribution aliases', () => { + assert.isTrue(isOCIRegistryOrigin(new URL('https://registry-1.docker.io/v2/'), { + ...ociRef, + registry: 'docker.io', + })); + }); + }); + + it('does not forward registry credentials to a cross-origin upload URL', async () => { + const registry = 'registry.example'; + const originalAuthorization = 'Bearer original-registry-token'; + const uploadToken = 'upload-token'; + let uploadRequests = 0; + let tokenRequests = 0; + const uploadServer = http.createServer((request, response) => { + if (request.url?.startsWith('/token')) { + tokenRequests++; + assert.equal(request.method, 'GET'); + assert.isUndefined(request.headers.authorization); + response.end(JSON.stringify({ token: uploadToken })); + return; + } + + uploadRequests++; + if (request.headers.authorization === `Bearer ${uploadToken}`) { + response.writeHead(201); + response.end(); + return; + } + assert.isUndefined(request.headers.authorization); + const uploadPort = (uploadServer.address() as AddressInfo).port; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${uploadPort}/token",service="uploads.example",scope="repository:test:push"`, + }); + response.end(); + }); + const uploadPort = await listen(uploadServer); + const cachedAuthHeader = { [registry]: originalAuthorization }; + const params = { + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|secret` }), + cachedAuthHeader, + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'https', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://localhost:${uploadPort}/upload`, + headers: { authorization: originalAuthorization }, + data: Buffer.from('blob'), + }, ociRef); + + assert.equal(result?.statusCode, 201); + assert.equal(uploadRequests, 2); + assert.equal(tokenRequests, 1); + assert.equal(cachedAuthHeader[registry], originalAuthorization); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await close(uploadServer); + } + }); + + it('does not answer a cross-origin Basic challenge with registry credentials', async () => { + const registry = 'registry.example'; + let uploadRequests = 0; + const uploadServer = http.createServer((request, response) => { + uploadRequests++; + assert.isUndefined(request.headers.authorization); + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="uploads.example"', + }); + response.end(); + }); + const uploadPort = await listen(uploadServer); + const params = { + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|secret` }), + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'https', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://localhost:${uploadPort}/upload`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(uploadRequests, 1); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await close(uploadServer); + } + }); + it('does not request a rejected bearer token realm', async () => { let registryRequests = 0; let tokenRequests = 0; @@ -237,6 +369,72 @@ describe('OCI registry authentication', () => { } }); + it('does not cache a token from a redirected authentication challenge under the original registry', async () => { + const token = 'redirect-target-token'; + let challengeServerRequests = 0; + let uploadRequests = 0; + const challengeServer = http.createServer((request, response) => { + challengeServerRequests++; + const challengePort = (challengeServer.address() as AddressInfo).port; + if (request.url?.startsWith('/token')) { + response.end(JSON.stringify({ token })); + return; + } + uploadRequests++; + if (uploadRequests === 2) { + response.writeHead(200); + response.end(); + return; + } + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${challengePort}/token",service="uploads.example",scope="repository:test:push"`, + }); + response.end(); + }); + const challengePort = await listen(challengeServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + response.writeHead(307, { + location: `http://localhost:${challengePort}${request.url}`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + const cachedAuthHeader: Record = {}; + const params = { + ...createTestCommonParams(nullLog, {}), + cachedAuthHeader, + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://${registry}/upload`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(challengeServerRequests, 3); + assert.notProperty(cachedAuthHeader, registry); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await Promise.all([close(registryServer), close(challengeServer)]); + } + }); + it('ignores cross-origin redirects that do not produce an auth challenge', async () => { const contentServer = http.createServer((_request, response) => { response.writeHead(200); From 5c5e0c48083f231c474c981d19bf41de882732f0 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 28 Aug 2026 17:52:36 +0200 Subject: [PATCH 37/37] Honor OCI auth options in feature tests Propagate OCI authentication hardening through feature test launches and preserve credentials across trusted Docker Hub registry aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/spec-configuration/httpOCIRegistry.ts | 12 ++++++------ src/spec-node/featuresCLI/test.ts | 10 ++++++++-- src/spec-node/featuresCLI/testCommandImpl.ts | 6 +++++- src/test/httpOCIRegistry.test.ts | 8 +++++--- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 88ec2986d..57b8dc6c7 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -176,7 +176,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio } const initialAttemptRes = await requestResolveHeaders(httpOptions, output); const registryUrl = new URL(initialAttemptRes.responseUrl); - const challengeFromRequestedRegistry = requestedRegistryUrl.origin.toLowerCase() === registryUrl.origin.toLowerCase(); + const challengeFromOCIRegistry = isOCIRegistryOrigin(registryUrl, ociRef); // For anything except a 401 (invalid/no token) or 403 (insufficient scope) // response simply return the original response to the caller. @@ -186,7 +186,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio } // -- 'responseAttempt' status code was 401 or 403 at this point. - if (!requestCanUseRegistryCredentials || !challengeFromRequestedRegistry) { + if (!requestCanUseRegistryCredentials || !challengeFromOCIRegistry) { recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Request to '${requestedRegistryUrl.host}' with authentication challenge from '${registryUrl.host}' would prevent forwarding registry '${ociRef.registry}' credentials with OCI auth hardening.`); } @@ -204,8 +204,8 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); - if (params.ociAuthHardening && (!requestCanUseRegistryCredentials || !challengeFromRequestedRegistry)) { - output.write(`[httpOci] ERR: Refusing to send registry '${ociRef.registry}' credentials to '${requestedRegistryUrl.host}'.`, LogLevel.Error); + if (params.ociAuthHardening && (!requestCanUseRegistryCredentials || !challengeFromOCIRegistry)) { + output.write(`[httpOci] ERR: Refusing to send registry '${ociRef.registry}' credentials to '${registryUrl.host}'.`, LogLevel.Error); return; } const credential = await getCredential(params, ociRef); @@ -258,7 +258,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio scope: scopeGroup ? scopeGroup[1] : '', }; - const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromRequestedRegistry); + const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromOCIRegistry); const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); @@ -278,7 +278,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] ${reattemptRes.statusCode} on reattempt after auth: ${httpOptions.url}`, LogLevel.Trace); // Cache the auth header if the request did not result in an unauthorized response. - if (reattemptRes.statusCode !== 401 && (!params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromRequestedRegistry))) { + if (reattemptRes.statusCode !== 401 && (!params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromOCIRegistry))) { params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; } diff --git a/src/spec-node/featuresCLI/test.ts b/src/spec-node/featuresCLI/test.ts index 0fc40999f..e0d41cd99 100644 --- a/src/spec-node/featuresCLI/test.ts +++ b/src/spec-node/featuresCLI/test.ts @@ -3,7 +3,7 @@ import { CLIHost, getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig, PackageConfiguration } from '../../spec-utils/product'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { doFeaturesTestCommand } from './testCommandImpl'; import { runAsyncHandler } from '../utils'; @@ -47,7 +47,7 @@ export function featuresTestOptions(y: Argv) { }); } -export type FeaturesTestArgs = UnpackArgv>; +export type FeaturesTestArgs = UnpackArgv> & OciAuthArgs; export interface FeaturesTestCommandInput { cliHost: CLIHost; pkg: PackageConfiguration; @@ -64,6 +64,8 @@ export interface FeaturesTestCommandInput { logLevel: LogLevel; preserveTestContainers: boolean; quiet: boolean; + allowedCrossOriginAuthHosts: string[]; + ociAuthHardening: boolean; disposables: (() => Promise | undefined)[]; } @@ -86,6 +88,8 @@ async function featuresTest({ 'log-level': inputLogLevel, 'preserve-test-containers': preserveTestContainers, quiet, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts = [], + 'oci-auth-hardening': ociAuthHardening = false, }: FeaturesTestArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -117,6 +121,8 @@ async function featuresTest({ permitRandomization, remoteUser, preserveTestContainers, + allowedCrossOriginAuthHosts, + ociAuthHardening, disposables }; diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index d119885a8..ecf251b66 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -559,6 +559,8 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder: skipFeatureAutoMapping: common.skipFeatureAutoMapping, skipPersistingCustomizationsFromFeatures: common.skipPersistingCustomizationsFromFeatures, omitConfigRemotEnvFromMetadata: common.omitConfigRemotEnvFromMetadata, + allowedCrossOriginAuthHosts: common.allowedCrossOriginAuthHosts, + ociAuthHardening: common.ociAuthHardening, log: text => quiet ? null : process.stderr.write(text), dotfiles: {} }; @@ -625,7 +627,7 @@ async function exec(cmd: string, args: string[], workspaceFolder: string, inject } async function generateDockerParams(workspaceFolder: string, args: FeaturesTestCommandInput): Promise { - const { logLevel, quiet, disposables } = args; + const { logLevel, quiet, allowedCrossOriginAuthHosts, ociAuthHardening, disposables } = args; return await createDockerParams({ workspaceFolder, additionalLabels: [], @@ -662,6 +664,8 @@ async function generateDockerParams(workspaceFolder: string, args: FeaturesTestC skipFeatureAutoMapping: false, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, + allowedCrossOriginAuthHosts, + ociAuthHardening, dotfiles: {} }, disposables); } diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts index 5d1d34921..7e28d14b4 100644 --- a/src/test/httpOCIRegistry.test.ts +++ b/src/test/httpOCIRegistry.test.ts @@ -135,11 +135,13 @@ describe('OCI registry authentication', () => { assert.isFalse(isOCIRegistryOrigin(new URL('http://registry.example/v2/'), ociRef)); }); - it('accepts Docker Hub distribution aliases', () => { - assert.isTrue(isOCIRegistryOrigin(new URL('https://registry-1.docker.io/v2/'), { + it('accepts both origins of a Docker Hub distribution alias redirect', () => { + const dockerHubRef = { ...ociRef, registry: 'docker.io', - })); + }; + assert.isTrue(isOCIRegistryOrigin(new URL('https://docker.io/v2/'), dockerHubRef)); + assert.isTrue(isOCIRegistryOrigin(new URL('https://registry-1.docker.io/v2/'), dockerHubRef)); }); });