registry auth survives an http to https token realm redirect behind a TLS proxy
This commit is contained in:
10 files changed
+154
-14
No files matched your search
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { parseTag, compareParts } from './tag-parser';
|
||||
import type { ReleaseSource } from './release-source';
|
||||
import { isSafeNotificationUrl } from '../url-safety';
|
||||
|
||||
export interface ReleaseNote {
|
||||
/** The requested version tag this note is for, e.g. `16.4-alpine`. */
|
||||
@@ -74,12 +75,39 @@ const MAX_PAGES = 3; // 300 recent releases is plenty to cover a handful of skip
|
||||
* query param differs (`per_page` vs `limit`). Walks pages until every wanted
|
||||
* version is matched or the page budget is spent. Never throws.
|
||||
*/
|
||||
export interface ReleaseNotesResult {
|
||||
notes: ReleaseNote[];
|
||||
/** True when GitHub refused the request because the (usually unauthenticated)
|
||||
* rate limit is exhausted - the UI can then suggest setting a token. Only ever
|
||||
* set for the github.com source; a self-hosted forge doesn't have this limit. */
|
||||
rateLimited: boolean;
|
||||
}
|
||||
|
||||
/** True for a GitHub response that means "you're rate limited": a 403/429 whose
|
||||
* X-RateLimit-Remaining header is 0 (the primary-limit signal). */
|
||||
function isRateLimited(res: { status: number; headers: { get(name: string): string | null } }): boolean {
|
||||
if (res.status !== 403 && res.status !== 429) return false;
|
||||
const remaining = res.headers.get('x-ratelimit-remaining');
|
||||
// If the header is present it must be 0; if absent (429 without it), treat as limited.
|
||||
return remaining === null || remaining.trim() === '0';
|
||||
}
|
||||
|
||||
export async function fetchReleaseNotes(
|
||||
src: ReleaseSource,
|
||||
wanted: string[],
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ReleaseNote[]> {
|
||||
if (wanted.length === 0) return [];
|
||||
): Promise<ReleaseNotesResult> {
|
||||
if (wanted.length === 0) return { notes: [], rateLimited: false };
|
||||
|
||||
// SSRF guard: for a Gitea/Forgejo forge, apiBase's host comes from the
|
||||
// container's org.opencontainers.image.source LABEL (user-controllable), so a
|
||||
// crafted label could point the fetch at loopback/metadata. Block those but
|
||||
// allow LAN forges (a self-hosted Gitea legitimately lives on 192.168.x). The
|
||||
// github.com branch is hardcoded to api.github.com and needs no guard.
|
||||
if (src.kind !== 'github') {
|
||||
const safety = isSafeNotificationUrl(src.apiBase);
|
||||
if (!safety.ok) return { notes: [], rateLimited: false };
|
||||
}
|
||||
|
||||
const pageSizeParam = src.kind === 'github' ? 'per_page' : 'limit';
|
||||
// Unauthenticated GitHub is 60 req/h per IP; a token raises it to 5000/h. Optional -
|
||||
@@ -96,11 +124,19 @@ export async function fetchReleaseNotes(
|
||||
if (ghToken) headers.Authorization = `Bearer ${ghToken}`;
|
||||
|
||||
const collected: GithubRelease[] = [];
|
||||
// Only flag rate limiting for github.com with no token configured - that's the
|
||||
// case a token would actually fix.
|
||||
const canBeRateLimited = src.kind === 'github' && !ghToken;
|
||||
let rateLimited = false;
|
||||
try {
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const url = `${src.apiBase}?${pageSizeParam}=${PER_PAGE}&page=${page}`;
|
||||
const res = await fetchImpl(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const res = await fetchImpl(url, { headers, redirect: 'manual' });
|
||||
if (res.status >= 300 && res.status < 400) break; // refuse to follow a redirect to a possibly-private host
|
||||
if (!res.ok) {
|
||||
if (canBeRateLimited && isRateLimited(res)) rateLimited = true;
|
||||
break;
|
||||
}
|
||||
const batch = (await res.json()) as GithubRelease[];
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
collected.push(...batch.filter((r) => !r.draft));
|
||||
@@ -111,8 +147,8 @@ export async function fetchReleaseNotes(
|
||||
if (batch.length < PER_PAGE) break;
|
||||
}
|
||||
} catch {
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
return { notes: matchReleasesToVersions(collected, wanted), rateLimited };
|
||||
}
|
||||
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
return { notes: matchReleasesToVersions(collected, wanted), rateLimited };
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* works anonymously for public repos.
|
||||
*/
|
||||
import { getRegistryAuthHeader } from '../../docker';
|
||||
import { isSafeRegistryHost } from '../../registry-auth';
|
||||
|
||||
/** Stop after this many pages — a guard against pathological repos, not a real limit. */
|
||||
const MAX_PAGES = 50;
|
||||
@@ -26,6 +27,10 @@ export async function fetchGenericV2Tags(
|
||||
repo: string,
|
||||
credentials?: { username: string; password: string } | null
|
||||
): Promise<string[]> {
|
||||
// SSRF guard: `registry` comes from a user-controlled image reference. Block
|
||||
// loopback/metadata/reserved but allow LAN registries (self-hosted Harbor).
|
||||
if (!isSafeRegistryHost(registry).ok) return [];
|
||||
|
||||
const base = `https://${registry}`;
|
||||
const authHeader = await getRegistryAuthHeader(base, `repository:${repo}:pull`, credentials ?? undefined);
|
||||
const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': 'Dockhand/1.0' };
|
||||
@@ -35,7 +40,8 @@ export async function fetchGenericV2Tags(
|
||||
let path: string | null = `/v2/${repo}/tags/list?n=${PAGE_SIZE}`;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES && path; page++) {
|
||||
const response = await fetch(`${base}${path}`, { headers });
|
||||
const response = await fetch(`${base}${path}`, { headers, redirect: 'manual' });
|
||||
if (response.status >= 300 && response.status < 400) break; // don't follow a redirect off the guarded host
|
||||
if (!response.ok) break; // 4xx/5xx: stop with whatever we have.
|
||||
|
||||
const data = (await response.json()) as TagsListResponse;
|
||||
|
||||
@@ -95,7 +95,9 @@ export function ipCategory(host: string): IpCategory | null {
|
||||
// IPv6 loopback / unspecified, then link-local / unique-local (private).
|
||||
if (h === '::1') return 'loopback';
|
||||
if (h === '::' || h === '::0') return 'reserved'; // unspecified / all-interfaces
|
||||
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return 'private';
|
||||
// Require a colon so real IPv6 unique-local/link-local literals match but public
|
||||
// DNS names starting "fc"/"fd" (fcm.googleapis.com, fdroid.link) do not.
|
||||
if (h.startsWith('fe80:') || /^f[cd][0-9a-f]{0,2}:/.test(h)) return 'private';
|
||||
// IPv4-mapped/compatible IPv6 — judge the embedded v4 (see embeddedV4).
|
||||
const v4 = embeddedV4(h) ?? h;
|
||||
const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
|
||||
Reference in new issue
Block a user