registry auth survives an http to https token realm redirect behind a TLS proxy

This commit is contained in:
jarek committed 2026-08-20 08:28:26 +02:00
1 parent 3d208e6f22
commit f50c3e52f3
10 files changed
+154 -14

No files matched your search

+31
View File
@@ -1,3 +1,5 @@
import { isSafeNotificationUrl } from './url-safety';
/**
* Encode the AuthConfig JSON as base64url **with `=` padding** for the
* Docker X-Registry-Auth header. The Docker daemon decodes the header with
@@ -14,6 +16,19 @@ export function encodeRegistryAuth(authConfig: object): string {
return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
}
/**
* SSRF guard for a registry HOST derived from a user-controlled image reference
* (e.g. `169.254.169.254:80/x/y:latest` in a container image or compose stack).
* The update-check path fetches `https://<registry>/v2/...`, so a crafted image
* name could point the server at loopback/metadata. A registry legitimately lives
* on a LAN (self-hosted Harbor / `registry:5000`), so allow private ranges but
* block loopback + cloud metadata (169.254.169.254) + reserved. Returns { ok } or
* { ok:false, reason }. `registry` is a bare host[:port], no scheme.
*/
export function isSafeRegistryHost(registry: string): { ok: boolean; reason?: string } {
return isSafeNotificationUrl(`https://${registry}`);
}
/**
* Whether Basic credentials may be re-attached when a registry token request
* redirects. True only when the host is unchanged. A Harbor behind a
@@ -34,12 +49,26 @@ export function canReattachAuthOnRedirect(from: string, to: string): boolean {
/** Injectable fetch, so the redirect-follow loop is unit-testable without a network. */
export type FetchLike = (url: string, init?: { headers?: Record<string, string>; redirect?: 'manual' }) => Promise<Response>;
/**
* Host-safety check for a registry token URL, applied to the initial URL and every
* redirect hop (the realm and any redirect target are host-controlled, so each hop
* is re-checked, not just the outer registry host). Registries live on public or
* LAN hosts, so this uses the permissive policy (block loopback/metadata/reserved,
* allow private ranges). Throws on a blocked host; callers degrade a thrown token
* fetch to anonymous/null.
*/
function assertSafeRegistryUrl(u: string): void {
const r = isSafeNotificationUrl(u);
if (!r.ok) throw new Error(`registry token URL blocked: ${r.reason}`);
}
/**
* Fetch a registry token URL, manually following up to a few same-host redirects
* with the Authorization header re-attached. `authHeader` is the full header value
* (e.g. `Basic abc...`) or null for anonymous. Cross-host redirects drop the header
* (see canReattachAuthOnRedirect). Manual redirect handling is what fixes #1428:
* undici's automatic follow strips Authorization across an http->https origin change.
* Every hop (incl. the initial URL) passes assertSafeRegistryUrl.
*/
export async function fetchRegistryToken(
tokenUrl: string,
@@ -47,6 +76,7 @@ export async function fetchRegistryToken(
fetchImpl: FetchLike = fetch as unknown as FetchLike
): Promise<Response> {
let url = tokenUrl;
assertSafeRegistryUrl(url);
for (let hop = 0; hop < 4; hop++) {
const headers: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
if (authHeader) headers['Authorization'] = authHeader;
@@ -55,6 +85,7 @@ export async function fetchRegistryToken(
if (!location) return res;
await res.body?.cancel().catch(() => {});
const next = new URL(location, url).toString();
assertSafeRegistryUrl(next);
// Drop credentials if the redirect leaves the original host.
if (!canReattachAuthOnRedirect(url, next)) authHeader = null;
url = next;