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/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" }, diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index e7770d8f0..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'; @@ -69,6 +70,9 @@ export interface ResolverParameters { omitConfigRemotEnvFromMetadata?: boolean; secretsP?: Promise>; 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 a2e4ad55f..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'; @@ -18,6 +19,9 @@ export interface CommonParams { env: NodeJS.ProcessEnv; output: Log; 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 5957d0896..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'; @@ -195,6 +196,9 @@ export interface ContainerFeatureInternalParams { platform: NodeJS.Platform; noLockfile?: boolean; frozenLockfile?: boolean; + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } // TODO: Move to node layer. @@ -389,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' @@ -955,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 2bebba82e..5cf907db5 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -3,10 +3,11 @@ 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'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export type HEADERS = { 'authorization'?: string; 'user-agent'?: string; 'content-type'?: string; 'Accept'?: string; 'content-length'?: string }; @@ -35,6 +36,102 @@ const realmRegex = /realm="([^"]+)"/; const serviceRegex = /service="([^"]+)"/; const scopeRegex = /scope="([^"]+)"/; +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 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 isAllowedTokenServiceRealmForPolicy(realmUrl: URL, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { + 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 { + try { + return isAllowedTokenServiceRealmForPolicy( + new URL(realm), + new URL(registryUrl), + parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) + ); + } catch { + return false; + } +} + +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. @@ -53,15 +150,21 @@ 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(); // 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. + 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']; @@ -100,14 +203,35 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); return; } + let realmUrl: URL; + try { + realmUrl = new URL(realmGroup[1]); + 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.` + : ''; + 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 wwwAuthenticateData = { - realm: realmGroup[1], + realm: realmUrl, service: serviceGroup[1], scope: scopeGroup ? scopeGroup[1] : '', }; - const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); + 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); return; @@ -130,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. @@ -331,30 +455,42 @@ 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, challengeCanUseRequestedRegistryCredentials: boolean, wwwAuthenticateData: { realm: URL; 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 = challengeCanUseRequestedRegistryCredentials ? await getCredential(params, ociRef) : undefined; const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; 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, + }; + }; // 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. @@ -366,51 +502,56 @@ 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; + const url = realm.toString(); 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 + ? `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); - } + const requestToken = params.ociAuthHardening ? requestResolveHeadersNoRedirects : requestResolveHeaders; + 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(); + 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 requestToken(httpOptions, output); + recordAuthServerRedirect(params, httpOptions.url, res); + } + } 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/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 6ceed1951..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'; @@ -74,6 +75,8 @@ export interface ProvisionOptions { omitSyntaxDirective?: boolean; includeConfig?: boolean; includeMergedConfig?: boolean; + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; } export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { @@ -92,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); @@ -162,6 +166,9 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPath: options.dotfiles.targetPath || '~/dotfiles', }, omitSyntaxDirective: options.omitSyntaxDirective, + allowedCrossOriginAuthHosts: options.allowedCrossOriginAuthHosts, + ociAuthHardening: options.ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const dockerPath = options.dockerPath || 'docker'; @@ -210,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 }); @@ -222,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 832e9603f..782232e11 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -44,7 +44,9 @@ 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'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -66,6 +68,28 @@ 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, + nargs: 1, + global: true, + description: 'Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.', + }) + .check(args => { + 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(); y.wrap(Math.min(120, y.terminalWidth())); y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); @@ -96,6 +120,14 @@ 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[]; + 'oci-auth-hardening'?: boolean; +}; + +export function getAllowedCrossOriginAuthHosts(args: OciAuthArgs) { + return args['allow-cross-origin-auth-host'] || []; +} function provisionOptions(y: Argv) { return y.options({ @@ -176,7 +208,7 @@ function provisionOptions(y: Argv) { }); } -type ProvisionArgs = UnpackArgv>; +type ProvisionArgs = UnpackArgv> & OciAuthArgs; function provisionHandler(args: ProvisionArgs) { runAsyncHandler(provision.bind(null, args)); @@ -229,6 +261,8 @@ async function provision({ 'omit-syntax-directive': omitSyntaxDirective, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: ProvisionArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); @@ -303,6 +337,8 @@ async function provision({ omitSyntaxDirective, includeConfig, includeMergedConfig, + allowedCrossOriginAuthHosts, + ociAuthHardening, }; const result = await doProvision(options, providedIdLabels); @@ -383,7 +419,7 @@ function setUpOptions(y: Argv) { }); } -type SetUpArgs = UnpackArgv>; +type SetUpArgs = UnpackArgv> & OciAuthArgs; function setUpHandler(args: SetUpArgs) { runAsyncHandler(setUp.bind(null, args)); @@ -420,6 +456,8 @@ async function doSetUp({ 'container-session-data-folder': containerSessionDataFolder, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: SetUpArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -470,6 +508,8 @@ async function doSetUp({ installCommand: dotfilesInstallCommand, targetPath: dotfilesTargetPath, }, + allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common } = params; @@ -561,7 +601,7 @@ function buildOptions(y: Argv) { }); } -type BuildArgs = UnpackArgv>; +type BuildArgs = UnpackArgv> & OciAuthArgs; function buildHandler(args: BuildArgs) { runAsyncHandler(build.bind(null, args)); @@ -602,6 +642,8 @@ async function doBuild({ 'no-lockfile': noLockfile, '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; @@ -655,6 +697,8 @@ async function doBuild({ noLockfile, frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, + allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common, dockerComposeCLI } = params; @@ -676,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` @@ -763,6 +807,7 @@ async function doBuild({ return { outcome: 'success' as 'success', imageName: imageNameResult, + ociAuthDiagnostics: params.common.ociAuthDiagnostics, dispose, }; } catch (originalError) { @@ -830,7 +875,7 @@ function runUserCommandsOptions(y: Argv) { }); } -type RunUserCommandsArgs = UnpackArgv>; +type RunUserCommandsArgs = UnpackArgv> & OciAuthArgs; function runUserCommandsHandler(args: RunUserCommandsArgs) { runAsyncHandler(runUserCommands.bind(null, args)); @@ -1023,7 +1068,7 @@ function readConfigurationOptions(y: Argv) { }); } -type ReadConfigurationArgs = UnpackArgv>; +type ReadConfigurationArgs = UnpackArgv> & OciAuthArgs; function readConfigurationHandler(args: ReadConfigurationArgs) { runAsyncHandler(readConfiguration.bind(null, args)); @@ -1048,6 +1093,8 @@ async function readConfiguration({ 'include-merged-configuration': includeMergedConfig, '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 () => { @@ -1104,7 +1151,10 @@ async function readConfiguration({ env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo: buildPlatformInfo + targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1134,6 +1184,7 @@ async function readConfiguration({ workspace: configs?.workspaceConfig, featuresConfiguration, mergedConfiguration: mergedConfig, + ociAuthDiagnostics: params.ociAuthDiagnostics, }) + '\n', err => err ? reject(err) : resolve()); }); } catch (err) { @@ -1162,7 +1213,7 @@ function outdatedOptions(y: Argv) { }); } -type OutdatedArgs = UnpackArgv>; +type OutdatedArgs = UnpackArgv> & OciAuthArgs; function outdatedHandler(args: OutdatedArgs) { runAsyncHandler(outdated.bind(null, args)); @@ -1177,6 +1228,8 @@ async function outdated({ 'log-format': logFormat, 'terminal-rows': terminalRows, 'terminal-columns': terminalColumns, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: OutdatedArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1213,6 +1266,9 @@ async function outdated({ env: cliHost.env, skipFeatureAutoMapping: false, 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 a36205295..f52ca0be7 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -9,5 +9,17 @@ 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, + 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 9d721b651..2ee958726 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -1,13 +1,14 @@ 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 { 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'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function featuresInfoOptions(y: Argv) { return y @@ -19,7 +20,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 +37,8 @@ async function featuresInfo({ 'feature': featureId, 'log-level': inputLogLevel, 'output-format': outputFormat, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesInfoArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -51,7 +54,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, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; const jsonOutput: InfoJsonOutput = {}; @@ -129,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); @@ -144,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 42259a057..46f38ea14 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'; @@ -15,13 +15,14 @@ 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) { 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 +32,9 @@ async function featuresPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -49,7 +52,7 @@ async function featuresPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + 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 3c24c3788..1c183ba30 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'; @@ -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 { @@ -34,7 +35,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 +44,8 @@ export function featuresResolveDependenciesHandler(args: featuresResolveDependen 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 () => { @@ -73,6 +76,9 @@ async function featuresResolveDependencies({ const params = { output, 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 0fb25c932..507c74f97 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -3,10 +3,11 @@ 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'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateApplyOptions(y: Argv) { return y @@ -24,7 +25,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 +39,8 @@ async function templateApply({ 'log-level': inputLogLevel, 'tmp-dir': userProvidedTmpDir, 'omit-paths': omitPathsArg, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplateApplyArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -87,7 +90,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, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }, selectedTemplate, workspaceFolder, userProvidedTmpDir); if (!files) { output.write(`Failed to fetch template '${id}'.`, LogLevel.Error); process.exit(1); @@ -152,4 +155,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..3fea84f89 100644 --- a/src/spec-node/templatesCLI/metadata.ts +++ b/src/spec-node/templatesCLI/metadata.ts @@ -4,8 +4,9 @@ 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'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateMetadataOptions(y: Argv) { return y @@ -15,7 +16,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 +25,8 @@ export function templateMetadataHandler(args: TemplateMetadataArgs) { 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 () => { @@ -39,7 +42,7 @@ async function templateMetadata({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + 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 cff9bb1f0..1e6ad3c9e 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'; @@ -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'; @@ -22,7 +23,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 +33,9 @@ async function templatesPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplatesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -50,7 +53,7 @@ async function templatesPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + 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 8773fd5de..dbb123cf6 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'; @@ -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 @@ -47,7 +48,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 +63,8 @@ async function featuresUpgrade({ 'dry-run': dryRun, feature: feature, 'target-version': targetVersion, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesUpgradeArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -90,6 +93,7 @@ async function featuresUpgrade({ os: mapNodeOSToGOOS(cliHost.platform), arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; + const ociAuthDiagnostics = createOCIAuthDiagnostics(); const dockerParams: DockerCLIParameters = { cliHost, dockerCLI: dockerPath, @@ -98,6 +102,9 @@ async function featuresUpgrade({ output, buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics, }; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); @@ -112,6 +119,9 @@ async function featuresUpgrade({ env: cliHost.env, skipFeatureAutoMapping: false, platform: cliHost.platform, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics, }; if (feature && targetVersion) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index e6cf6980f..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'; @@ -285,7 +286,10 @@ 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; + const ociAuthHardening = 'cliHost' in params ? params.ociAuthHardening : params.common.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); } @@ -317,9 +321,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[], ociAuthHardening?: boolean, ociAuthDiagnostics: OCIAuthDiagnostics = createOCIAuthDiagnostics()): Promise { const resourceAndVersion = qualifyImageName(name); - const params = { output, env: process.env }; + 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 0531f6b87..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; @@ -54,6 +55,9 @@ export interface DockerCLIParameters { output: Log; buildPlatformInfo: PlatformInfo; 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 162c55cc5..b5097aae0 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; redirected: boolean }>((resolve, reject) => { const parsed = new url.URL(options.url); const reqOptions: RequestOptions & tls.CommonConnectionOptions & FollowOptions = { hostname: parsed.hostname, @@ -94,7 +110,11 @@ export async function requestResolveHeaders(options: { type: string; url: string headers: options.headers, agent: new ProxyAgent(), secureContext, + trackRedirects: true, }; + if (maxRedirects !== undefined) { + reqOptions.maxRedirects = maxRedirects; + } const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; if (plainHTTP) { @@ -111,7 +131,9 @@ 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, + redirected: res.redirects.length > 1, }); }); }); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index f409cb0fd..a33220716 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} --oci-auth-hardening --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/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 new file mode 100644 index 000000000..6ead623a6 --- /dev/null +++ b/src/test/httpOCIRegistry.test.ts @@ -0,0 +1,449 @@ +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'; + +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', () => { + 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('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 params = createTestCommonParams(nullLog, {}); + + const result = await requestEnsureAuthenticated({ ...params, cachedAuthHeader, ociAuthHardening: true }, { + 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); + assert.isTrue(params.ociAuthDiagnostics.authLookupWouldBeBlocked); + } finally { + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + + it('surfaces shadow diagnostics 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 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 = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated(createTestCommonParams(output, {}), { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + 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(challengeRegistryServer), close(tokenServer), close(redirectTargetServer)]); + } + }); + + 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'; + const bearerScheme = 'Bearer'; + let tokenRequests = 0; + const tokenServer = http.createServer(async (request, response) => { + tokenRequests++; + 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); + + 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}`; + 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 = { + 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); + + 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; + } else { + process.env.DOCKER_CONFIG = previousDockerConfig; + } + await rm(dockerConfig, { recursive: true }); + 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({ + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), + ociAuthHardening: true, + }, { + 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({ + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), + }, { + 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 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; }