From bc91e71075c9c5e57cf4dd58a484546a531eca4a Mon Sep 17 00:00:00 2001 From: jarek Date: Wed, 19 Aug 2026 14:26:45 +0200 Subject: [PATCH] registry auth no longer fails when the token realm redirects http to https behind a TLS-terminating proxy --- src/lib/server/docker.ts | 57 +++++++++++++++++---------------- src/lib/server/registry-auth.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index e0ea1fb..15707b6 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -24,7 +24,7 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r import { parseImageReference } from './registry/image-ref'; export { parseImageReference } from './registry/image-ref'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; -import { encodeRegistryAuth } from './registry-auth'; +import { encodeRegistryAuth, fetchRegistryToken } from './registry-auth'; import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; import { deepDiff } from '../utils/diff.js'; import { getInstanceId } from './backups/identity'; @@ -3198,21 +3198,25 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise = { 'User-Agent': 'Dockhand/1.0' }; - - // Add Basic auth header if we have credentials - if (credentials) { - const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); - tokenHeaders['Authorization'] = `Basic ${basicAuth}`; - } - - const tokenResponse = await fetch(tokenUrl.toString(), { - headers: tokenHeaders - }); + const authHeader = credentials + ? `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}` + : null; + const tokenResponse = await fetchRegistryToken(tokenUrl.toString(), authHeader); if (!tokenResponse.ok) { - await tokenResponse.text(); // Consume body to release socket - console.error(`Token request failed: ${tokenResponse.status}`); + // Surface enough to diagnose without leaking the secret: the response + // body (truncated), the final URL, and the identity (username only). + const errBody = (await tokenResponse.text()).slice(0, 300).replace(/\s+/g, ' ').trim(); + const finalNote = tokenResponse.url && tokenResponse.url !== tokenUrl.toString() + ? ` (final ${new URL(tokenResponse.url).origin})` + : ''; + const identity = credentials + ? ` as ${credentials.username.slice(0, 4)}...(len=${credentials.username.length})` + : ' anonymously'; + console.error( + `[Registry] Token request failed: ${tokenResponse.status} at ${tokenUrl.origin}${finalNote}, sent${identity}` + + (errBody ? ` - response: ${errBody}` : '') + ); return null; } @@ -3319,21 +3323,20 @@ export async function getRegistryAuthHeader( if (service) tokenUrl.searchParams.set('service', service); if (scope) tokenUrl.searchParams.set('scope', scope); - const tokenHeaders: Record = { 'User-Agent': 'Dockhand/1.0' }; - - // Add Basic auth header if we have credentials - if (credentials) { - const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); - tokenHeaders['Authorization'] = `Basic ${basicAuth}`; - } - - const tokenResponse = await fetch(tokenUrl.toString(), { - headers: tokenHeaders - }); + const authHeader = credentials + ? `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}` + : null; + const tokenResponse = await fetchRegistryToken(tokenUrl.toString(), authHeader); if (!tokenResponse.ok) { - const errorBody = await tokenResponse.text().catch(() => ''); - console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`); + const errorBody = (await tokenResponse.text().catch(() => '')).slice(0, 300).replace(/\s+/g, ' ').trim(); + const finalNote = tokenResponse.url && tokenResponse.url !== tokenUrl.toString() + ? ` (final ${new URL(tokenResponse.url).origin})` + : ''; + const identity = credentials + ? ` as ${credentials.username.slice(0, 4)}...(len=${credentials.username.length})` + : ' anonymously'; + console.error(`[Registry] Token request failed: ${tokenResponse.status} at ${tokenUrl.origin}${finalNote}, sent${identity}${errorBody ? ` - response: ${errorBody}` : ''}`); return null; } diff --git a/src/lib/server/registry-auth.ts b/src/lib/server/registry-auth.ts index ec77148..757591f 100644 --- a/src/lib/server/registry-auth.ts +++ b/src/lib/server/registry-auth.ts @@ -13,3 +13,54 @@ export function encodeRegistryAuth(authConfig: object): string { const unpadded = Buffer.from(JSON.stringify(authConfig)).toString('base64url'); return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4); } + +/** + * Whether Basic credentials may be re-attached when a registry token request + * redirects. True only when the host is unchanged. A Harbor behind a + * TLS-terminating proxy advertises an `http://` realm that 301s to `https://` + * on the SAME host; undici strips Authorization across that origin change, + * turning a valid login into an anonymous 401. Re-attaching on a same-host + * scheme/port change matches docker/curl. A host CHANGE must never re-send + * creds (leak risk), so those are refused. (#1428) + */ +export function canReattachAuthOnRedirect(from: string, to: string): boolean { + try { + return new URL(from).hostname === new URL(to).hostname; + } catch { + return false; + } +} + +/** Injectable fetch, so the redirect-follow loop is unit-testable without a network. */ +export type FetchLike = (url: string, init?: { headers?: Record; redirect?: 'manual' }) => Promise; + +/** + * 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. + */ +export async function fetchRegistryToken( + tokenUrl: string, + authHeader: string | null, + fetchImpl: FetchLike = fetch as unknown as FetchLike +): Promise { + let url = tokenUrl; + for (let hop = 0; hop < 4; hop++) { + const headers: Record = { 'User-Agent': 'Dockhand/1.0' }; + if (authHeader) headers['Authorization'] = authHeader; + const res = await fetchImpl(url, { headers, redirect: 'manual' }); + const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null; + if (!location) return res; + await res.body?.cancel().catch(() => {}); + const next = new URL(location, url).toString(); + // Drop credentials if the redirect leaves the original host. + if (!canReattachAuthOnRedirect(url, next)) authHeader = null; + url = next; + } + // Too many hops: one final attempt so the caller sees a real status. + const headers: Record = { 'User-Agent': 'Dockhand/1.0' }; + if (authHeader) headers['Authorization'] = authHeader; + return fetchImpl(url, { headers }); +}