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

+42 -6
View File
@@ -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;