Skip to content

Commit 5dc7533

Browse files
authored
Merge pull request #1278 from devcontainers/copilot/secure-oci-auth
Harden OCI registry authentication
2 parents 6083827 + 5c5e0c4 commit 5dc7533

43 files changed

Lines changed: 1276 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/dev-containers.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ jobs:
6161
"src/test/cli.podman.test.ts",
6262
"src/test/cli.test.ts",
6363
"src/test/cli.up.test.ts",
64+
"src/test/httpOCIRegistry.test.ts",
6465
"src/test/imageMetadata.test.ts",
6566
"src/test/container-features/containerFeaturesOCIPush.test.ts",
6667
# Run all except the above:
67-
"--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'",
68+
"--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'",
6869
]
6970
steps:
7071
- name: Checkout

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ src/test/container-features/configs/temp_lifecycle-hooks-alternative-order
1616
test-secrets-temp.json
1717
src/test/container-*/**/src/**/README.md
1818
!src/test/container-features/assets/*.tgz
19+
src/test/fixtures/localhost-cert.pem
20+
src/test/fixtures/localhost-key.pem

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Notable changes.
44

5+
## August 2026
6+
7+
### [0.89.0]
8+
- 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)
9+
510
## June 2026
611

712
### [0.88.0]

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@devcontainers/cli",
33
"description": "Dev Containers CLI",
4-
"version": "0.88.0",
4+
"version": "0.89.0",
55
"bin": {
66
"devcontainer": "devcontainer.js"
77
},

src/spec-common/injectHeadless.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { launch, ShellServer } from './shellServer';
1313
import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils';
1414
import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event';
1515
import { PackageConfiguration } from '../spec-utils/product';
16+
import { OCIAuthDiagnostics } from './ociAuth';
1617
import { URI } from 'vscode-uri';
1718
import { containerSubstitute } from './variableSubstitution';
1819
import { delay } from './async';
@@ -69,6 +70,9 @@ export interface ResolverParameters {
6970
omitConfigRemotEnvFromMetadata?: boolean;
7071
secretsP?: Promise<Record<string, string>>;
7172
omitSyntaxDirective?: boolean;
73+
allowedCrossOriginAuthHosts?: string[];
74+
ociAuthHardening?: boolean;
75+
ociAuthDiagnostics: OCIAuthDiagnostics;
7276
}
7377

7478
export interface LifecycleHook {

src/spec-common/ociAuth.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
export interface OCIAuthDiagnostics {
7+
authLookupWouldBeBlocked: boolean;
8+
registryRedirectWouldPreventCredentialForwarding: boolean;
9+
authServerRedirect: boolean;
10+
}
11+
12+
export function createOCIAuthDiagnostics(): OCIAuthDiagnostics {
13+
return {
14+
authLookupWouldBeBlocked: false,
15+
registryRedirectWouldPreventCredentialForwarding: false,
16+
authServerRedirect: false,
17+
};
18+
}

src/spec-configuration/containerCollectionsOCI.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Log, LogLevel } from '../spec-utils/log';
88
import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs';
99
import { requestEnsureAuthenticated } from './httpOCIRegistry';
1010
import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils';
11+
import { OCIAuthDiagnostics } from '../spec-common/ociAuth';
1112

1213
export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers';
1314
export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar';
@@ -18,13 +19,17 @@ export interface CommonParams {
1819
env: NodeJS.ProcessEnv;
1920
output: Log;
2021
cachedAuthHeader?: Record<string, string>; // <registry, authHeader>
22+
allowedCrossOriginAuthHosts?: string[];
23+
ociAuthHardening?: boolean;
24+
ociAuthDiagnostics: OCIAuthDiagnostics;
2125
}
2226

2327
// Represents the unique OCI identifier for a Feature or Template.
2428
// eg: ghcr.io/devcontainers/features/go:1.0.0
2529
// eg: ghcr.io/devcontainers/features/go@sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3
2630
// Constructed by 'getRef()'
2731
export interface OCIRef {
32+
scheme: 'http' | 'https';
2833
registry: string; // 'ghcr.io'
2934
owner: string; // 'devcontainers'
3035
namespace: string; // 'devcontainers/features'
@@ -41,6 +46,7 @@ export interface OCIRef {
4146
// eg: ghcr.io/devcontainers/features:latest
4247
// Constructed by 'getCollectionRef()'
4348
export interface OCICollectionRef {
49+
scheme: 'http' | 'https';
4450
registry: string; // 'ghcr.io'
4551
path: string; // 'devcontainers/features'
4652
resource: string; // 'ghcr.io/devcontainers/features'
@@ -116,6 +122,10 @@ const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*
116122
// MUST be at most 128 characters in length and MUST match the following regular expression:
117123
const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/;
118124

125+
function getRegistryScheme(registry: string): OCIRef['scheme'] {
126+
return new URL(`https://${registry}`).hostname.toLowerCase() === 'localhost' ? 'http' : 'https';
127+
}
128+
119129
// https://go.dev/doc/install/source#environment
120130
// Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions
121131
export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH {
@@ -236,6 +246,7 @@ export function getRef(output: Log, input: string): OCIRef | undefined {
236246
output.write(`> digest?: ${digest}`, LogLevel.Trace);
237247

238248
return {
249+
scheme: getRegistryScheme(registry),
239250
id,
240251
owner,
241252
namespace,
@@ -266,6 +277,7 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin
266277
}
267278

268279
return {
280+
scheme: getRegistryScheme(registry),
269281
registry,
270282
path,
271283
resource,
@@ -291,7 +303,7 @@ export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef
291303
if (manifestDigest) {
292304
reference = manifestDigest;
293305
}
294-
const manifestUrl = `https://${ref.registry}/v2/${ref.path}/manifests/${reference}`;
306+
const manifestUrl = `${ref.scheme}://${ref.registry}/v2/${ref.path}/manifests/${reference}`;
295307
output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace);
296308
const expectedDigest = manifestDigest || ('digest' in ref ? ref.digest : undefined);
297309
const manifestContainer = await getManifest(params, manifestUrl, ref, undefined, expectedDigest);
@@ -467,7 +479,7 @@ export async function getVersionsStrictSorted(params: CommonParams, ref: OCIRef)
467479
export async function getPublishedTags(params: CommonParams, ref: OCIRef): Promise<string[] | undefined> {
468480
const { output } = params;
469481
try {
470-
const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`;
482+
const url = `${ref.scheme}://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`;
471483

472484
const headers = {
473485
'Accept': 'application/json',

src/spec-configuration/containerCollectionsOCIPush.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async function putManifestWithTags(params: CommonParams, manifest: ManifestConta
167167
const { manifestBuffer, contentDigest } = manifest;
168168

169169
for await (const tag of tags) {
170-
const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`;
170+
const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`;
171171
output.write(`PUT -> '${url}'`, LogLevel.Trace);
172172

173173
const httpOptions = {
@@ -232,7 +232,7 @@ async function putBlob(params: CommonParams, blobPutLocationUriPath: string, oci
232232
if (blobPutLocationUriPath.startsWith('https://') || blobPutLocationUriPath.startsWith('http://')) {
233233
url = blobPutLocationUriPath;
234234
} else {
235-
url = `https://${ociRef.registry}${blobPutLocationUriPath}`;
235+
url = `${ociRef.scheme}://${ociRef.registry}${blobPutLocationUriPath}`;
236236
}
237237

238238
// The <location> MAY contain critical query parameters.
@@ -332,7 +332,7 @@ export async function calculateDataLayer(output: Log, data: Buffer, basename: st
332332
export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | OCICollectionRef, digest: string): Promise<boolean> {
333333
const { output } = params;
334334

335-
const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`;
335+
const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`;
336336
const res = await requestEnsureAuthenticated(params, { type: 'HEAD', url, headers: {} }, ociRef);
337337
if (!res) {
338338
output.write('Request failed', LogLevel.Error);
@@ -349,7 +349,7 @@ export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | O
349349
async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise<string | undefined> {
350350
const { output } = params;
351351

352-
const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`;
352+
const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`;
353353
output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace);
354354
const res = await requestEnsureAuthenticated(params, { type: 'POST', url, headers: {} }, ociRef);
355355

src/spec-configuration/containerFeaturesConfiguration.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { request } from '../spec-utils/httpRequest';
1919
import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI';
2020
import { uriToFsPath } from './configurationCommonUtils';
2121
import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI';
22+
import { OCIAuthDiagnostics } from '../spec-common/ociAuth';
2223
import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile';
2324
import { computeDependsOnInstallationOrder } from './containerFeaturesOrder';
2425
import { logFeatureAdvisories } from './featureAdvisories';
@@ -195,6 +196,9 @@ export interface ContainerFeatureInternalParams {
195196
platform: NodeJS.Platform;
196197
noLockfile?: boolean;
197198
frozenLockfile?: boolean;
199+
allowedCrossOriginAuthHosts?: string[];
200+
ociAuthHardening?: boolean;
201+
ociAuthDiagnostics: OCIAuthDiagnostics;
198202
}
199203

200204
// TODO: Move to node layer.
@@ -391,7 +395,7 @@ const cleanupIterationFetchAndMerge = async (tempTarballPath: string, output: Lo
391395
}
392396
};
393397

394-
function getRequestHeaders(params: CommonParams, sourceInformation: SourceInformation) {
398+
function getRequestHeaders(params: { env: NodeJS.ProcessEnv; output: Log }, sourceInformation: SourceInformation) {
395399
const { env, output } = params;
396400
let headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string } = {
397401
'user-agent': 'devcontainer'
@@ -957,7 +961,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath:
957961
// throw new Error(`Unsupported feature source type: ${type}`);
958962
}
959963

960-
async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) {
964+
async function fetchFeatures(params: ContainerFeatureInternalParams, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) {
961965
const featureSets = featuresConfig.featureSets;
962966
for (let idx = 0; idx < featureSets.length; idx++) { // Index represents the previously computed installation order.
963967
const featureSet = featureSets[idx];

src/spec-configuration/containerFeaturesOCI.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function fetchOCIFeature(params: CommonParams, featureSet: FeatureS
6161
const { featureRef } = featureSet.sourceInformation;
6262

6363
const layerDigest = featureSet.sourceInformation.manifest?.layers[0].digest;
64-
const blobUrl = `https://${featureSet.sourceInformation.featureRef.registry}/v2/${featureSet.sourceInformation.featureRef.path}/blobs/${layerDigest}`;
64+
const blobUrl = `${featureRef.scheme}://${featureRef.registry}/v2/${featureRef.path}/blobs/${layerDigest}`;
6565
output.write(`blob url: ${blobUrl}`, LogLevel.Trace);
6666

6767
const blobResult = await getBlob(params, blobUrl, ociCacheDir, featCachePath, featureRef, layerDigest, undefined, metadataFile);

0 commit comments

Comments
 (0)