From 531a4866d94ee3951897250c7253d96423b32d73 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:34:17 +0800 Subject: [PATCH] Support headers for fallback registries --- README.md | 20 +++ src/registry/http.ts | 173 ++++++++++++++++++-------- src/registry/registry.ts | 4 + test/index.test.ts | 257 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b468ea0..6c20475 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,26 @@ the target registry and setup the credentials. **Never put a registry password/token inside your wrangler config file, please always use `wrangler secrets put`** +#### Adding headers to fallback requests + +Use `headers` for non-sensitive values that should be sent with requests to the fallback registry. For sensitive +values, set `headers_env` to the name of a Worker secret containing a JSON object of header names and values: + +```jsonc +// wrangler.jsonc +"REGISTRIES_JSON": "[{ \"registry\": \"https://old-registry.example\", \"headers\": { \"X-Registry-Region\": \"legacy\" }, \"headers_env\": \"FALLBACK_REGISTRY_HEADERS\" }]" +``` + +```bash +echo '{"Cf-Access-Client-Id":"client-id","Cf-Access-Client-Secret":"client-secret"}' \ + | npx wrangler secret put FALLBACK_REGISTRY_HEADERS --env production +``` + +Headers from the secret override same-named entries in `headers`. These headers are sent to the configured registry +and to authentication endpoints on the same origin. They are stripped before requests to a token service or +redirect target on another origin. If the registry uses Basic or Bearer authentication, the generated +`Authorization` header overrides an `Authorization` value from this configuration. + You can also use docker.io with anonymous authentication: ```jsonc diff --git a/src/registry/http.ts b/src/registry/http.ts index 2b3dda8..2dd85c4 100644 --- a/src/registry/http.ts +++ b/src/registry/http.ts @@ -15,6 +15,7 @@ import { Registry, RegistryConfiguration, RegistryError, + registryHeaders, UploadId, UploadObject, } from "./registry"; @@ -42,6 +43,9 @@ type HTTPContext = { // If Basic based authentication, this is ':' encoded in base64 // If Bearer based authentication, this is the token that was returned by the Oauth/token endpoint accessToken: string; + // Headers configured for requests to the registry origin. These are not sent + // to an authentication realm on another origin. + headers: Headers; }; export const manifestTypes = [ @@ -142,7 +146,7 @@ function normalizeReferrersCursor(nextURL: URL, requestURL: URL): string | undef } function ctxIntoHeaders(ctx: HTTPContext): Headers { - const headers = new Headers(); + const headers = new Headers(ctx.headers); if (ctx.authContext.authType === "none") { console.warn( "Your registry", @@ -152,7 +156,7 @@ function ctxIntoHeaders(ctx: HTTPContext): Headers { return headers; } - headers.append("Authorization", (ctx.authContext.authType === "basic" ? "Basic" : "Bearer") + " " + ctx.accessToken); + headers.set("Authorization", (ctx.authContext.authType === "basic" ? "Basic" : "Bearer") + " " + ctx.accessToken); return headers; } @@ -163,11 +167,48 @@ function ctxIntoRequest(ctx: HTTPContext, url: URL, method: string, path: string return new Request(urlReq, { method, body, - redirect: "follow", + redirect: "manual", headers: ctxIntoHeaders(ctx), }); } +const redirectStatuses = new Set([301, 302, 303, 307, 308]); + +async function fetchRegistryRequest(request: Request): Promise { + const registryOrigin = new URL(request.url).origin; + let currentRequest = request; + + for (let redirects = 0; redirects < 10; redirects++) { + const response = await fetch(currentRequest); + if (!redirectStatuses.has(response.status)) { + return response; + } + + const location = response.headers.get("Location"); + if (location === null) { + return response; + } + + const redirectURL = new URL(location, currentRequest.url); + if (redirectURL.origin !== registryOrigin) { + return await fetch( + new Request(redirectURL, { + method: currentRequest.method, + redirect: "follow", + }), + ); + } + + currentRequest = new Request(redirectURL, { + method: currentRequest.method, + headers: currentRequest.headers, + redirect: "manual", + }); + } + + throw new Error(`too many redirects fetching ${request.url}`); +} + function authHeaderIntoAuthContext(urlObject: URL, authenticateHeader: string): AuthContext { const url = urlObject.toString(); const parts = authenticateHeader.split(" "); @@ -259,7 +300,47 @@ export class RegistryHTTPClient implements Registry { return (this.env as unknown as Record)[configuration.password_env] ?? ""; } + registryHeaders(): Headers { + const headers = new Headers(this.configuration.headers); + const headersEnv = this.configuration.headers_env; + if (headersEnv === undefined) { + return headers; + } + + const value = (this.env as unknown as Record)[headersEnv]; + if (value === undefined) { + throw new Error(`registry headers binding ${headersEnv} is not set`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (err) { + throw new Error(`registry headers binding ${headersEnv} must contain valid JSON`, { cause: err }); + } + + for (const [name, headerValue] of Object.entries(registryHeaders.parse(parsed))) { + headers.set(name, headerValue); + } + return headers; + } + + authenticationRealmHeaders(ctx: AuthContext, registryHeaders: Headers): Headers { + const headers = new Headers(); + if (new URL(ctx.realm).origin !== this.url.origin) { + return headers; + } + + for (const [name, value] of registryHeaders) { + if (name.toLowerCase() !== "authorization") { + headers.set(name, value); + } + } + return headers; + } + async authenticate(namespace: string): Promise { + const headers = this.registryHeaders(); const emptyAuthentication = { authContext: { authType: "none", @@ -269,14 +350,18 @@ export class RegistryHTTPClient implements Registry { }, repository: this.url.pathname, accessToken: "", + headers, } as const; - const res = await fetch(`${this.url.protocol}//${this.url.host}/v2/`, { - headers: { - "User-Agent": "Docker-Client/24.0.5 (linux)", - "Accept-Encoding": "gzip", - }, - }); + const authenticationHeaders = new Headers(headers); + authenticationHeaders.set("User-Agent", "Docker-Client/24.0.5 (linux)"); + authenticationHeaders.set("Accept-Encoding", "gzip"); + const res = await fetchRegistryRequest( + new Request(`${this.url.protocol}//${this.url.host}/v2/`, { + headers: authenticationHeaders, + redirect: "manual", + }), + ); if (res.ok) { return emptyAuthentication; @@ -296,9 +381,9 @@ export class RegistryHTTPClient implements Registry { if (!authCtx.scope) authCtx.scope = namespace; switch (authCtx.authType) { case "bearer": - return await this.authenticateBearer(authCtx); + return await this.authenticateBearer(authCtx, headers); case "basic": - return await this.authenticateBasic(authCtx); + return await this.authenticateBasic(authCtx, headers); default: throw new Error("unreachable"); } @@ -314,20 +399,22 @@ export class RegistryHTTPClient implements Registry { return false; } - async authenticateBearerSimple(ctx: AuthContext, params: URLSearchParams) { + async authenticateBearerSimple(ctx: AuthContext, params: URLSearchParams, registryHeaders: Headers) { params.delete("password"); console.log("sending authentication parameters:", ctx.realm + "?" + params.toString()); + const headers = this.authenticationRealmHeaders(ctx, registryHeaders); + headers.set("Accept", "application/json"); + headers.set("User-Agent", "Docker-Client/24.0.5 (linux)"); + if (this.configuration.username !== undefined) { + headers.set("Authorization", "Basic " + this.authBase64()); + } return await fetch(ctx.realm + "?" + params.toString(), { - headers: { - "Accept": "application/json", - "User-Agent": "Docker-Client/24.0.5 (linux)", - ...(this.configuration.username !== undefined ? { Authorization: "Basic " + this.authBase64() } : {}), - }, + headers, }); } - async authenticateBearer(ctx: AuthContext): Promise { + async authenticateBearer(ctx: AuthContext, headers: Headers): Promise { const params = new URLSearchParams({ service: ctx.service, // explicitely include that we don't want an offline_token. @@ -336,11 +423,11 @@ export class RegistryHTTPClient implements Registry { grant_type: this.configuration.username === undefined ? "none" : "password", password: this.configuration.username === undefined ? "" : this.password(), }); + const authenticationHeaders = this.authenticationRealmHeaders(ctx, headers); + authenticationHeaders.set("Content-Type", "application/x-www-form-urlencoded"); + authenticationHeaders.set("User-Agent", "Docker-Client/24.0.5 (linux)"); let response = await fetch(ctx.realm, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "Docker-Client/24.0.5 (linux)", - }, + headers: authenticationHeaders, method: "POST", body: params.toString(), }); @@ -349,7 +436,7 @@ export class RegistryHTTPClient implements Registry { this.url.toString(), "Oauth 404/401/405... Falling back to simple token authentication, see https://distribution.github.io/distribution/spec/auth/token", ); - const responseSimple = await this.authenticateBearerSimple(ctx, params); + const responseSimple = await this.authenticateBearerSimple(ctx, params, headers); if (responseSimple.ok) { response = responseSimple; } else { @@ -387,6 +474,7 @@ export class RegistryHTTPClient implements Registry { authContext: ctx, repository: response.repository ?? this.url.pathname, accessToken: response.access_token ?? response.token ?? this.authBase64(), + headers, }; } catch (err) { console.error( @@ -400,11 +488,11 @@ export class RegistryHTTPClient implements Registry { } } - async authenticateBasic(ctx: AuthContext): Promise { + async authenticateBasic(ctx: AuthContext, headers: Headers): Promise { + const authenticationHeaders = this.authenticationRealmHeaders(ctx, headers); + authenticationHeaders.set("Authorization", "Basic " + this.authBase64()); const res = await fetch(ctx.realm, { - headers: { - Authorization: "Basic " + this.authBase64(), - }, + headers: authenticationHeaders, }); if (!res.ok) { @@ -415,6 +503,7 @@ export class RegistryHTTPClient implements Registry { authContext: ctx, accessToken: this.authBase64(), repository: this.url.pathname.slice(1), + headers, }; } @@ -424,7 +513,7 @@ export class RegistryHTTPClient implements Registry { const ctx = await this.authenticate(namespace); const req = ctxIntoRequest(ctx, this.url, "HEAD", `${namespace}/manifests/${tag}`); req.headers.append("Accept", manifestTypes.join(", ")); - const res = await fetch(req); + const res = await fetchRegistryRequest(req); if (!res.ok && res.status !== 404) { console.warn(req.url, "->", res.status, "getting manifest:", await res.text()); return { @@ -453,7 +542,7 @@ export class RegistryHTTPClient implements Registry { const ctx = await this.authenticate(namespace); const req = ctxIntoRequest(ctx, this.url, "GET", `${namespace}/manifests/${digest}`); req.headers.append("Accept", manifestTypes.join(", ")); - const res = await fetch(req); + const res = await fetchRegistryRequest(req); console.log(req.method, res.status, res.url); if (!res.ok) { return { @@ -483,7 +572,7 @@ export class RegistryHTTPClient implements Registry { const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`; try { const ctx = await this.authenticate(namespace); - const res = await fetch(ctxIntoRequest(ctx, this.url, "HEAD", `${namespace}/blobs/${digest}`)); + const res = await fetchRegistryRequest(ctxIntoRequest(ctx, this.url, "HEAD", `${namespace}/blobs/${digest}`)); if (res.status === 404) { return { exists: false, @@ -524,25 +613,11 @@ export class RegistryHTTPClient implements Registry { try { const ctx = await this.authenticate(namespace); const req = ctxIntoRequest(ctx, this.url, "GET", `${namespace}/blobs/${digest}`); - let res = await fetch(req); + const res = await fetchRegistryRequest(req); if (!res.ok) { - // This means we got a redirect, so let's try again this URL but - // without any headers. Services like S3 reject authorization headers altogether - // if the authentication is included in the URL. - if (res.url !== req.url) { - const redirectResponse = await fetch(new Request(res.url)); - if (!redirectResponse.ok) { - return { - response: res, - }; - } - - res = redirectResponse; - } else { - return { - response: res, - }; - } + return { + response: res, + }; } if (res.body === null) { @@ -612,7 +687,7 @@ export class RegistryHTTPClient implements Registry { ); } - const res = await fetch(req); + const res = await fetchRegistryRequest(req); console.log(req.method, res.status, res.url); if (!res.ok) { return { diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 217ed7e..7393663 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -5,9 +5,13 @@ import z from "zod"; import { GarbageCollectionMode } from "./garbage-collector"; // Defines a registry and how it's configured +export const registryHeaders = z.record(z.string(), z.string()); + const registryConfiguration = z .object({ registry: z.url(), + headers: registryHeaders.optional(), + headers_env: z.string().optional(), }) .and( z diff --git a/test/index.test.ts b/test/index.test.ts index fffa61d..e75b98a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1373,6 +1373,22 @@ test("registries configuration", async () => { partialError: false, error: "", }, + { + configuration: `[{ + "registry": "https://hello.com/domain", + "headers": { "X-Registry-Region": "legacy" }, + "headers_env": "REGISTRY_HEADERS" + }]`, + expected: [ + { + registry: "https://hello.com/domain", + headers: { "X-Registry-Region": "legacy" }, + headers_env: "REGISTRY_HEADERS", + }, + ], + partialError: false, + error: "", + }, ] as const; const bindings = env as Env; @@ -1402,6 +1418,247 @@ describe("http client", () => { const bindings = env as Env; let envBindings = { ...bindings }; + test("sends configured headers with anonymous registry requests", async () => { + envBindings = { ...bindings }; + (envBindings as unknown as Record).REGISTRY_HEADERS = JSON.stringify({ + "Cf-Access-Client-Id": "secret-id", + "Cf-Access-Client-Secret": "secret-value", + }); + + let requestCount = 0; + using _fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + requestCount++; + expect(request.headers.get("X-Registry-Region")).toEqual("legacy"); + expect(request.headers.get("Cf-Access-Client-Id")).toEqual("secret-id"); + expect(request.headers.get("Cf-Access-Client-Secret")).toEqual("secret-value"); + + if (new URL(request.url).pathname === "/v2/") { + expect(request.headers.get("User-Agent")).toEqual("Docker-Client/24.0.5 (linux)"); + return new Response(null, { status: 200 }); + } + + expect(request.method).toEqual("HEAD"); + return new Response(null, { status: 404 }); + }); + + const client = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers: { + "Cf-Access-Client-Id": "public-id", + "X-Registry-Region": "legacy", + }, + headers_env: "REGISTRY_HEADERS", + }); + const result = await client.manifestExists("namespace/image", "latest"); + + expect(result).toMatchObject({ exists: false }); + expect(requestCount).toEqual(2); + }); + + test("removes registry headers from cross-origin redirects", async () => { + envBindings = { ...bindings }; + (envBindings as unknown as Record).REGISTRY_HEADERS = JSON.stringify({ + "Cf-Access-Client-Secret": "secret-value", + }); + + let requestCount = 0; + using _fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + const url = new URL(request.url); + requestCount++; + if (url.host === "storage.example") { + expect(request.headers.get("Cf-Access-Client-Secret")).toBeNull(); + expect(request.headers.get("X-Registry-Region")).toBeNull(); + expect(request.headers.get("Authorization")).toBeNull(); + return new Response("blob", { + status: 200, + headers: { "Content-Length": "4" }, + }); + } + + expect(request.headers.get("Cf-Access-Client-Secret")).toEqual("secret-value"); + expect(request.headers.get("X-Registry-Region")).toEqual("legacy"); + if (url.pathname === "/v2/") { + return new Response(null, { status: 200 }); + } + + return new Response(null, { + status: 302, + headers: { Location: "https://storage.example/blob" }, + }); + }); + + const client = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers: { "X-Registry-Region": "legacy" }, + headers_env: "REGISTRY_HEADERS", + }); + const result = await client.getLayer("namespace/image", numberedDigest(42)); + + if ("response" in result) { + throw new Error(`expected redirected layer request to succeed, got ${result.response.status}`); + } + expect(result.size).toEqual(4); + expect(await new Response(result.stream).text()).toEqual("blob"); + expect(requestCount).toEqual(3); + }); + + test("retains registry headers across same-origin redirects", async () => { + envBindings = { ...bindings }; + + let requestCount = 0; + using _fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + const url = new URL(request.url); + requestCount++; + expect(request.headers.get("Cf-Access-Client-Id")).toEqual("client-id"); + + if (url.pathname === "/v2/") { + return new Response(null, { status: 200 }); + } + + if (url.pathname !== "/redirected-manifest") { + return new Response(null, { + status: 307, + headers: { Location: "/redirected-manifest" }, + }); + } + + expect(request.method).toEqual("HEAD"); + return new Response(null, { status: 404 }); + }); + + const client = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers: { "Cf-Access-Client-Id": "client-id" }, + }); + const result = await client.manifestExists("namespace/image", "latest"); + + expect(result).toMatchObject({ exists: false }); + expect(requestCount).toEqual(3); + }); + + test("does not send registry headers to a cross-origin bearer token realm", async () => { + envBindings = { ...bindings }; + envBindings.PASSWORD = "registry-password"; + (envBindings as unknown as Record).REGISTRY_HEADERS = JSON.stringify({ + "Cf-Access-Client-Id": "secret-id", + "Cf-Access-Client-Secret": "secret-value", + }); + + let registryRequests = 0; + using _fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + const url = new URL(request.url); + if (url.host === "auth.example") { + expect(request.headers.get("Cf-Access-Client-Id")).toBeNull(); + expect(request.headers.get("Cf-Access-Client-Secret")).toBeNull(); + expect(request.headers.get("X-Registry-Region")).toBeNull(); + expect(request.headers.get("Authorization")).toBeNull(); + return Response.json({ token: "registry-token", expires_in: 300 }); + } + + registryRequests++; + expect(request.headers.get("Cf-Access-Client-Id")).toEqual("secret-id"); + expect(request.headers.get("Cf-Access-Client-Secret")).toEqual("secret-value"); + expect(request.headers.get("X-Registry-Region")).toEqual("legacy"); + if (url.pathname === "/v2/") { + expect(request.headers.get("Authorization")).toEqual("Configured value"); + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer realm="https://auth.example/token",service="registry.example"', + }, + }); + } + + expect(request.headers.get("Authorization")).toEqual("Bearer registry-token"); + return new Response(null, { status: 404 }); + }); + + const client = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + username: "registry-user", + password_env: "PASSWORD", + headers: { + "Authorization": "Configured value", + "X-Registry-Region": "legacy", + }, + headers_env: "REGISTRY_HEADERS", + }); + const result = await client.manifestExists("namespace/image", "latest"); + + expect(result).toMatchObject({ exists: false }); + expect(registryRequests).toEqual(2); + }); + + test("sends registry headers to a same-origin bearer token realm", async () => { + envBindings = { ...bindings }; + (envBindings as unknown as Record).REGISTRY_HEADERS = JSON.stringify({ + "Cf-Access-Client-Id": "secret-id", + "Cf-Access-Client-Secret": "secret-value", + }); + + let requestCount = 0; + using _fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + const url = new URL(request.url); + requestCount++; + expect(request.headers.get("Cf-Access-Client-Id")).toEqual("secret-id"); + expect(request.headers.get("Cf-Access-Client-Secret")).toEqual("secret-value"); + + if (url.pathname === "/v2/") { + expect(request.headers.get("Authorization")).toEqual("Configured value"); + return new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer realm="https://registry.example/token",service="registry.example"', + }, + }); + } + + if (url.pathname === "/token") { + expect(request.headers.get("Authorization")).toBeNull(); + return Response.json({ token: "registry-token", expires_in: 300 }); + } + + expect(request.headers.get("Authorization")).toEqual("Bearer registry-token"); + return new Response(null, { status: 404 }); + }); + + const client = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers: { Authorization: "Configured value" }, + headers_env: "REGISTRY_HEADERS", + }); + const result = await client.manifestExists("namespace/image", "latest"); + + expect(result).toMatchObject({ exists: false }); + expect(requestCount).toEqual(3); + }); + + test("rejects a missing or malformed registry headers binding", () => { + envBindings = { ...bindings }; + const missingClient = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers_env: "MISSING_HEADERS", + }); + expect(() => missingClient.registryHeaders()).toThrow("registry headers binding MISSING_HEADERS is not set"); + + (envBindings as unknown as Record).REGISTRY_HEADERS = "not json"; + const malformedClient = new RegistryHTTPClient(envBindings, { + registry: "https://registry.example", + headers_env: "REGISTRY_HEADERS", + }); + expect(() => malformedClient.registryHeaders()).toThrow( + "registry headers binding REGISTRY_HEADERS must contain valid JSON", + ); + + (envBindings as unknown as Record).REGISTRY_HEADERS = JSON.stringify({ header: 123 }); + expect(() => malformedClient.registryHeaders()).toThrow(); + }); + test("test manifest exists", async () => { envBindings = { ...bindings }; envBindings.JWT_REGISTRY_TOKENS_PUBLIC_KEY = "";