registry auth no longer fails when the token realm redirects http to https behind a TLS-terminating proxy
This commit is contained in:
2 files changed
+81
-27
No files matched your search
+30
-27
@@ -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<s
|
||||
if (service) tokenUrl.searchParams.set('service', service);
|
||||
if (scope) tokenUrl.searchParams.set('scope', scope);
|
||||
|
||||
const tokenHeaders: Record<string, string> = { '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<string, string> = { '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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>; redirect?: 'manual' }) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* 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<Response> {
|
||||
let url = tokenUrl;
|
||||
for (let hop = 0; hop < 4; hop++) {
|
||||
const headers: Record<string, string> = { '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<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
return fetchImpl(url, { headers });
|
||||
}
|
||||
Reference in new issue
Block a user