diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 930d77d31..81ffb8226 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -61,10 +61,11 @@ jobs: "src/test/cli.podman.test.ts", "src/test/cli.test.ts", "src/test/cli.up.test.ts", + "src/test/httpOCIRegistry.test.ts", "src/test/imageMetadata.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/httpOCIRegistry.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index a2e4ad55f..9bb58da17 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -3,6 +3,7 @@ import * as semver from 'semver'; import * as tar from 'tar'; import * as jsonc from 'jsonc-parser'; import * as crypto from 'crypto'; +import { isIP } from 'net'; import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; @@ -116,6 +117,55 @@ const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)* // MUST be at most 128 characters in length and MUST match the following regular expression: const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; +// Validate authority syntax only; local and private registries remain supported by policy. +function isValidRegistryAuthority(registry: string): boolean { + let hostname = registry; + let port: string | undefined; + + if (registry.startsWith('[')) { + // IPv6 literals must use bracketed URL-authority form so the port is unambiguous. + const match = /^\[([^\]]+)\](?::([0-9]+))?$/.exec(registry); + if (!match || isIP(match[1]) !== 6) { + return false; + } + hostname = match[1]; + port = match[2]; + } else { + const firstColon = registry.indexOf(':'); + if (firstColon !== -1) { + if (firstColon !== registry.lastIndexOf(':')) { + return false; + } + hostname = registry.slice(0, firstColon); + port = registry.slice(firstColon + 1); + } + + if (isIP(hostname) === 0) { + if (hostname.length === 0 || hostname.length > 253) { + return false; + } + const labels = hostname.split('.'); + if (!labels.every(label => label.length > 0 + && label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) { + return false; + } + } + } + + if (port !== undefined) { + if (!/^[0-9]+$/.test(port)) { + return false; + } + const portNumber = Number(port); + if (portNumber < 1 || portNumber > 65535) { + return false; + } + } + + return true; +} + // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH { @@ -214,6 +264,10 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const namespace = splitOnSlash.slice(1, -1).join('/'); const path = `${namespace}/${id}`; + if (!isValidRegistryAuthority(registry)) { + output.write(`Registry '${registry}' for input '${input}' failed validation.`, LogLevel.Error); + return; + } if (!regexForPath.exec(path)) { output.write(`Path '${path}' for input '${input}' failed validation. Expected path to match regex '${regexForPath}'.`, LogLevel.Error); @@ -252,6 +306,10 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin // Normalize input by downcasing entire string registry = registry.toLowerCase(); namespace = namespace.toLowerCase(); + if (!isValidRegistryAuthority(registry)) { + output.write(`Registry '${registry}' failed validation.`, LogLevel.Error); + return; + } const path = namespace; const resource = `${registry}/${path}`; @@ -279,9 +337,13 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef | OCICollectionRef, manifestDigest?: string): Promise { const { output } = params; - // Simple mechanism to avoid making a DNS request for - // something that is not a domain name. - if (ref.registry.indexOf('.') < 0 && !ref.registry.startsWith('localhost')) { + const registryHostname = ref.registry.startsWith('[') + ? ref.registry.slice(1, ref.registry.indexOf(']')) + : ref.registry.split(':', 1)[0]; + // Preserve legacy owner/repository/feature IDs while allowing explicit local and IP registries. + if (!registryHostname.includes('.') + && registryHostname !== 'localhost' + && isIP(registryHostname) === 0) { return; } diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 2bebba82e..2620ce019 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as jsonc from 'jsonc-parser'; import { runCommandNoPty, plainExec } from '../spec-common/commonUtils'; -import { requestResolveHeaders } from '../spec-utils/httpRequest'; +import { requestResolveHeaders, requestResolveHeadersNoRedirects } from '../spec-utils/httpRequest'; import { LogLevel } from '../spec-utils/log'; import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; @@ -35,6 +35,69 @@ const realmRegex = /realm="([^"]+)"/; const serviceRegex = /service="([^"]+)"/; const scopeRegex = /scope="([^"]+)"/; +type RegistryCredentialType = 'basic' | 'refreshToken'; + +// Endpoint admission and credential forwarding are separate policies: an allowed +// token service does not automatically receive credentials stored for the registry. +export function canForwardCredentialToTokenService(realm: string, registry: string, credentialType: RegistryCredentialType): boolean { + let realmUrl: URL; + try { + realmUrl = new URL(realm); + } catch { + return false; + } + + const normalizedRegistry = registry.toLowerCase(); + if (realmUrl.host.toLowerCase() === normalizedRegistry) { + // Preserve HTTP localhost registries used for local Feature development. + return realmUrl.protocol === 'https:' + || realmUrl.protocol === 'http:' && realmUrl.hostname.toLowerCase() === 'localhost'; + } + + // Docker Hub is the only supported cross-authority credential exchange. + return credentialType === 'basic' + && realmUrl.protocol === 'https:' + && !realmUrl.port + && realmUrl.hostname.toLowerCase() === 'auth.docker.io' + && (normalizedRegistry === 'docker.io' + || normalizedRegistry === 'registry.docker.io' + || normalizedRegistry === 'registry-1.docker.io'); +} + +// Pin registry-directed token requests to the registry authority or known OCI token services. +export function isAllowedTokenServiceRealm(realm: string, registry: string): boolean { + let realmUrl: URL; + try { + realmUrl = new URL(realm); + } catch { + return false; + } + + const sameAuthority = realmUrl.host.toLowerCase() === registry.toLowerCase(); + if (realmUrl.protocol !== 'https:') { + return realmUrl.protocol === 'http:' + && realmUrl.hostname.toLowerCase() === 'localhost' + && sameAuthority; + } + + if (sameAuthority) { + return true; + } + + if (realmUrl.port) { + return false; + } + + // Cross-authority services must use their standard HTTPS authority. + const hostname = realmUrl.hostname.toLowerCase(); + const azureRegistryLabels = hostname.endsWith('.azurecr.io') + ? hostname.slice(0, -'.azurecr.io'.length).split('.') + : []; + return hostname === 'auth.docker.io' + || hostname === 'ghcr.io' + || azureRegistryLabels.length > 0 && azureRegistryLabels.every(Boolean); +} + // https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { // If needed, Initialize the Authorization header cache. @@ -100,6 +163,12 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); return; } + // Reject the challenge before credential lookup or token-endpoint I/O. + if (!isAllowedTokenServiceRealm(realmGroup[1], ociRef.registry)) { + delete cachedAuthHeader[ociRef.registry]; + output.write(`[httpOci] ERR: Refusing bearer token realm '${realmGroup[1]}' for registry '${ociRef.registry}'.`, LogLevel.Error); + return; + } const wwwAuthenticateData = { realm: realmGroup[1], @@ -335,15 +404,6 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O 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. @@ -353,12 +413,42 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O const userCredential = await getCredential(params, ociRef); const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; + const canForwardBasicCredential = canForwardCredentialToTokenService(realm, ociRef.registry, 'basic'); + const canForwardRefreshToken = canForwardCredentialToTokenService(realm, ociRef.registry, '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, + }; + }; + + if (refreshToken && !canForwardRefreshToken) { + output.write(`[httpOci] Refusing to send refresh token to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); + } + if (basicAuthCredential && !canForwardBasicCredential) { + output.write(`[httpOci] Refusing to send Basic credential to bearer token realm '${realm}' for registry '${ociRef.registry}'.`, LogLevel.Warning); + } // There are several different ways registries expect to handle the oauth token exchange. // Depending on the type of credential available, use the most reasonable method. - if (refreshToken) { + if (refreshToken && canForwardRefreshToken) { const form_url_encoded = new URLSearchParams(); form_url_encoded.append('client_id', 'devcontainer'); form_url_encoded.append('grant_type', 'refresh_token'); @@ -366,51 +456,53 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O form_url_encoded.append('scope', scope); form_url_encoded.append('refresh_token', refreshToken); - headers['content-type'] = 'application/x-www-form-urlencoded'; - const url = realm; output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); httpOptions = { type: 'POST', url, - headers: headers, + headers: { + 'user-agent': 'devcontainer', + 'content-type': 'application/x-www-form-urlencoded', + }, data: Buffer.from(form_url_encoded.toString()) }; + sentCredentials = true; } else { - if (basicAuthCredential) { - headers['authorization'] = `Basic ${basicAuthCredential}`; - } - // realm="https://auth.docker.io/token" // service="registry.docker.io" // scope="repository:samalba/my-app:pull,push" // Example: // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push - const url = `${realm}?service=${service}&scope=${scope}`; - output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); - - httpOptions = { - type: 'GET', - url: url, - headers: headers, - }; + const authorization = basicAuthCredential && canForwardBasicCredential + ? `Basic ${basicAuthCredential}` + : undefined; + httpOptions = createGetHttpOptions(authorization); + sentCredentials = !!authorization; + output.write(`[httpOci] Attempting to fetch bearer token from: ${httpOptions.url}`, LogLevel.Trace); } - let res = await requestResolveHeaders(httpOptions, output); - if (res && res.statusCode === 401 || res.statusCode === 403) { - output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); - const body = res.resBody?.toString(); - if (body) { - output.write(`${res.resBody.toString()}.`, LogLevel.Info); - } + let res: Awaited>; + try { + res = await requestResolveHeadersNoRedirects(httpOptions, output); + if (sentCredentials && (res.statusCode === 401 || res.statusCode === 403)) { + output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); + const body = res.resBody?.toString(); + if (body) { + output.write(`${res.resBody.toString()}.`, LogLevel.Info); + } - // Try again without user credentials. If we're here, their creds are likely expired. - delete headers['authorization']; - res = await requestResolveHeaders(httpOptions, output); + // Build a fresh GET so neither an Authorization header nor a refresh-token POST body is reused. + httpOptions = createGetHttpOptions(); + res = await requestResolveHeadersNoRedirects(httpOptions, output); + } + } catch (err) { + output.write(`[httpOci] Failed to request bearer token for '${service}': ${err}`, LogLevel.Error); + return; } - if (!res || res.statusCode > 299 || !res.resBody) { + if (res.statusCode > 299 || !res.resBody) { output.write(`[httpOci] ${res.statusCode}: Failed to fetch bearer token for '${service}': ${res.resBody.toString()}`, LogLevel.Error); return; } diff --git a/src/spec-utils/httpRequest.ts b/src/spec-utils/httpRequest.ts index 162c55cc5..3ecc3864a 100644 --- a/src/spec-utils/httpRequest.ts +++ b/src/spec-utils/httpRequest.ts @@ -79,9 +79,25 @@ 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) => { const parsed = new url.URL(options.url); @@ -95,6 +111,9 @@ export async function requestResolveHeaders(options: { type: string; url: string agent: new ProxyAgent(), secureContext, }; + if (maxRedirects !== undefined) { + reqOptions.maxRedirects = maxRedirects; + } const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; if (plainHTTP) { diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index 9281a7498..cd3ef3341 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -1,5 +1,5 @@ import { assert } from 'chai'; -import { getRef, getManifest, getBlob, getCollectionRef } from '../../spec-configuration/containerCollectionsOCI'; +import { fetchOCIManifestIfExists, getRef, getManifest, getBlob, getCollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -46,6 +46,18 @@ describe('getCollectionRef()', async function () { assert.equal(collectionRef.tag, collectionRef.version); }); + it('valid getCollectionRef() with localhost and IP registries', async () => { + assert.equal(getCollectionRef(output, 'localhost:5000', 'devcontainers/templates')?.registry, 'localhost:5000'); + assert.equal(getCollectionRef(output, '127.0.0.1:5000', 'devcontainers/templates')?.registry, '127.0.0.1:5000'); + assert.equal(getCollectionRef(output, '[::1]:5000', 'devcontainers/templates')?.registry, '[::1]:5000'); + }); + + it('invalid getCollectionRef() with malformed registry authorities', async () => { + for (const registry of ['https://ghcr.io', 'user@ghcr.io', 'ghcr.io/path', '.ghcr.io', 'ghcr..io', 'ghcr_io', 'ghcr.io:', 'ghcr.io:0', 'ghcr.io:65536']) { + assert.isUndefined(getCollectionRef(output, registry, 'devcontainers/templates'), registry); + } + }); + it('invalid getCollectionRef() with an invalid character in path', async () => { const collectionRef = getCollectionRef(output, 'ghcr.io', 'devcont%ainers/templates'); assert.isUndefined(collectionRef); @@ -61,6 +73,14 @@ describe('getCollectionRef()', async function () { describe('getRef()', async function () { this.timeout('120s'); + it('does not fetch an OCI manifest for a legacy single-label Feature ID', async () => { + const featureRef = getRef(output, 'codspace/myfeatures/helloworld'); + assert.isDefined(featureRef); + + const manifest = await fetchOCIManifestIfExists({ env: {}, output }, featureRef!); + assert.isUndefined(manifest); + }); + it('valid getRef() with a tag', async () => { const feat = getRef(output, 'ghcr.io/devcontainers/templates/docker-from-docker:latest'); if (!feat) { @@ -212,6 +232,18 @@ describe('getRef()', async function () { assert.equal(feat.tag, feat.version); }); + it('valid getRef() with localhost and IP registries', async () => { + assert.equal(getRef(output, 'localhost:5000/a/b/c')?.registry, 'localhost:5000'); + assert.equal(getRef(output, '127.0.0.1:5000/a/b/c')?.registry, '127.0.0.1:5000'); + assert.equal(getRef(output, '[::1]:5000/a/b/c')?.registry, '[::1]:5000'); + }); + + it('invalid getRef() with malformed registry authorities', async () => { + for (const registry of ['user@ghcr.io', '.ghcr.io', 'ghcr..io', 'ghcr_io', 'ghcr.io:', 'ghcr.io:0', 'ghcr.io:65536']) { + assert.isUndefined(getRef(output, `${registry}/a/b/c`), registry); + } + }); + it('invalid getRef() with duplicate version tags', async () => { const feat = getRef(output, 'ghcr.io/devcontainers/templates/docker-from-docker:latest:latest'); assert.isUndefined(feat); diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts new file mode 100644 index 000000000..57c108f05 --- /dev/null +++ b/src/test/httpOCIRegistry.test.ts @@ -0,0 +1,242 @@ +import * as http from 'http'; +import { AddressInfo } from 'net'; + +import { assert } from 'chai'; + +import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; +import { canForwardCredentialToTokenService, isAllowedTokenServiceRealm, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { nullLog } from '../spec-utils/log'; + +describe('OCI registry authentication', () => { + describe('isAllowedTokenServiceRealm', () => { + const cases = [ + { realm: 'https://registry.example/token', registry: 'registry.example', expected: true }, + { realm: 'https://REGISTRY.EXAMPLE/token', registry: 'registry.example', expected: true }, + { realm: 'https://registry.example:8443/token', registry: 'registry.example:8443', expected: true }, + { realm: 'https://registry.example:8443/token', registry: 'registry.example', expected: false }, + { realm: 'https://registry.example/token', registry: 'registry.example:8443', expected: false }, + { realm: 'http://registry.example/token', registry: 'registry.example', expected: false }, + { realm: 'http://localhost:5000/token', registry: 'localhost:5000', expected: true }, + { realm: 'http://localhost:5001/token', registry: 'localhost:5000', expected: false }, + { realm: 'not-a-url', registry: 'registry.example', expected: false }, + { realm: '/token', registry: 'registry.example', expected: false }, + { realm: 'https://auth.docker.io/token', registry: 'registry-1.docker.io', expected: true }, + { realm: 'https://auth.docker.io/token', registry: 'docker.io', expected: true }, + { realm: 'https://auth.docker.io/token', registry: 'attacker.example', expected: true }, + { realm: 'http://auth.docker.io/token', registry: 'registry-1.docker.io', expected: false }, + { realm: 'https://ghcr.io/token', registry: 'ghcr.io', expected: true }, + { realm: 'https://ghcr.io/token', registry: 'containers.example', expected: true }, + { realm: 'http://ghcr.io/token', registry: 'containers.example', expected: false }, + { realm: 'https://registry.azurecr.io/oauth2/token', registry: 'registry.azurecr.io', expected: true }, + { realm: 'https://registry.azurecr.io/oauth2/token', registry: 'containers.example', expected: true }, + { realm: 'http://registry.azurecr.io/oauth2/token', registry: 'containers.example', expected: false }, + { realm: 'https://nested.registry.azurecr.io/oauth2/token', registry: 'nested.registry.azurecr.io', expected: true }, + { realm: 'https://nested.registry.azurecr.io/oauth2/token', registry: 'containers.example', expected: true }, + { realm: 'https://azurecr.io/oauth2/token', registry: 'containers.example', expected: false }, + { realm: 'https://.azurecr.io/oauth2/token', registry: 'containers.example', expected: false }, + { realm: 'https://registry.azurecr.io.attacker.example/token', registry: 'containers.example', expected: false }, + { realm: 'https://auth.docker.io.attacker.example/token', registry: 'containers.example', expected: false }, + { realm: 'https://auth.docker.io:8443/token', registry: 'containers.example', expected: false }, + { realm: 'http://127.0.0.1/token', registry: 'attacker.example', expected: false }, + { realm: 'http://169.254.169.254/token', registry: 'attacker.example', expected: false }, + ]; + + for (const { realm, registry, expected } of cases) { + it(`${expected ? 'allows' : 'rejects'} '${realm}' for '${registry}'`, () => { + assert.equal(isAllowedTokenServiceRealm(realm, registry), expected); + }); + } + }); + + describe('canForwardCredentialToTokenService', () => { + it('allows Basic and refresh credentials for exact HTTP localhost authority', () => { + const realm = 'http://localhost:5000/token'; + assert.isTrue(canForwardCredentialToTokenService(realm, 'localhost:5000', 'basic')); + assert.isTrue(canForwardCredentialToTokenService(realm, 'localhost:5000', 'refreshToken')); + }); + + it('rejects credentials over remote HTTP even for the same authority', () => { + const realm = 'http://registry.example/token'; + assert.isFalse(canForwardCredentialToTokenService(realm, 'registry.example', 'basic')); + assert.isFalse(canForwardCredentialToTokenService(realm, 'registry.example', 'refreshToken')); + }); + + it('allows only Basic credentials for the Docker Hub token service', () => { + const realm = 'https://auth.docker.io/token'; + assert.isTrue(canForwardCredentialToTokenService(realm, 'registry-1.docker.io', 'basic')); + assert.isFalse(canForwardCredentialToTokenService(realm, 'registry-1.docker.io', 'refreshToken')); + }); + + it('rejects credentials for token services owned by another registry', () => { + assert.isFalse(canForwardCredentialToTokenService('https://auth.docker.io/token', 'attacker.example', 'basic')); + assert.isFalse(canForwardCredentialToTokenService('https://ghcr.io/token', 'attacker.example', 'basic')); + assert.isFalse(canForwardCredentialToTokenService('https://registry.azurecr.io/token', 'attacker.example', 'refreshToken')); + }); + }); + + it('does not request a rejected bearer token realm', async () => { + let registryRequests = 0; + let tokenRequests = 0; + const tokenServer = http.createServer((_request, response) => { + tokenRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const tokenPort = await listen(tokenServer); + const registryServer = http.createServer((_request, response) => { + registryRequests++; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + + try { + const registry = `127.0.0.1:${registryPort}`; + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + const cachedAuthHeader: Record = {}; + + const result = await requestEnsureAuthenticated({ env: {}, output: nullLog, cachedAuthHeader }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 1); + assert.equal(tokenRequests, 0); + assert.notProperty(cachedAuthHeader, registry); + } finally { + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + + it('does not follow redirects from a bearer token realm', async () => { + let redirectTargetRequests = 0; + const redirectTargetServer = http.createServer((_request, response) => { + redirectTargetRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const redirectTargetPort = await listen(redirectTargetServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + if (request.url?.startsWith('/token')) { + response.writeHead(302, { location: `http://localhost:${redirectTargetPort}/token` }); + response.end(); + return; + } + + const registryPort = (registryServer.address() as AddressInfo).port; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token",service="localhost:${registryPort}",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + env: { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }, + output: nullLog, + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 2); + assert.equal(redirectTargetRequests, 0); + } finally { + await Promise.all([close(registryServer), close(redirectTargetServer)]); + } + }); + + it('encodes bearer token service and scope query values', async () => { + const service = 'registry.example&injected=service#fragment'; + const scope = 'repository:test:pull&injected=scope#fragment'; + const token = 'registry-token'; + let registryRequests = 0; + let tokenRequests = 0; + const registryServer = http.createServer((request, response) => { + const registryPort = (registryServer.address() as AddressInfo).port; + if (request.url?.startsWith('/token')) { + tokenRequests++; + const tokenUrl = new URL(request.url, `http://localhost:${registryPort}`); + assert.equal(tokenUrl.searchParams.get('existing'), 'value'); + assert.equal(tokenUrl.searchParams.get('service'), service); + assert.equal(tokenUrl.searchParams.get('scope'), scope); + assert.isFalse(tokenUrl.searchParams.has('injected')); + response.end(JSON.stringify({ token })); + return; + } + + registryRequests++; + if (request.headers.authorization === `Bearer ${token}`) { + response.writeHead(200); + response.end(); + return; + } + + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token?existing=value#realm-fragment",service="${service}",scope="${scope}"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ env: {}, output: nullLog }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + } finally { + await close(registryServer); + } + }); +}); + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +} \ No newline at end of file