diff --git a/src/lib/server/registry-auth.ts b/src/lib/server/registry-auth.ts index 757591f..16328d6 100644 --- a/src/lib/server/registry-auth.ts +++ b/src/lib/server/registry-auth.ts @@ -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:///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; redirect?: 'manual' }) => Promise; +/** + * 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 { let url = tokenUrl; + assertSafeRegistryUrl(url); for (let hop = 0; hop < 4; hop++) { const headers: Record = { '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; diff --git a/src/lib/server/semver/release-notes.ts b/src/lib/server/semver/release-notes.ts index 29b22f7..c0d4179 100644 --- a/src/lib/server/semver/release-notes.ts +++ b/src/lib/server/semver/release-notes.ts @@ -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 { - if (wanted.length === 0) return []; +): Promise { + 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 }; } diff --git a/src/lib/server/semver/strategies/generic-v2.ts b/src/lib/server/semver/strategies/generic-v2.ts index c251930..9e3e90b 100644 --- a/src/lib/server/semver/strategies/generic-v2.ts +++ b/src/lib/server/semver/strategies/generic-v2.ts @@ -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 { + // 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 = { 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; diff --git a/src/lib/server/url-safety.ts b/src/lib/server/url-safety.ts index 2ed4bec..6b8efe0 100644 --- a/src/lib/server/url-safety.ts +++ b/src/lib/server/url-safety.ts @@ -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})$/); diff --git a/src/routes/api/containers/[id]/version-notes/+server.ts b/src/routes/api/containers/[id]/version-notes/+server.ts index 9c29c5f..274ffc0 100644 --- a/src/routes/api/containers/[id]/version-notes/+server.ts +++ b/src/routes/api/containers/[id]/version-notes/+server.ts @@ -15,7 +15,7 @@ import { fetchReleaseNotes } from '$lib/server/semver/release-notes'; * path: id:string! Container ID or name (from GET /api/containers) * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) * query: versions:string! Comma-separated version tags to fetch notes for (target + skipped), e.g. "16.3-alpine,16.4-alpine" - * resp-200: {changelogUrl:string, source:string, notes:array<{version:string!, name:string, githubTag:string, body:string, publishedAt:string, url:string!}>!} + * resp-200: {changelogUrl:string, source:string, rateLimited:boolean, notes:array<{version:string!, name:string, githubTag:string, body:string, publishedAt:string, url:string!}>!} * resp-200-example: {"changelogUrl":"https://github.com/go-gitea/gitea/releases","source":"go-gitea/gitea","notes":[{"version":"1.22.0","name":"v1.22.0","githubTag":"v1.22.0","body":"## Changes...","publishedAt":"2024-05-01T00:00:00Z","url":"https://github.com/go-gitea/gitea/releases/tag/v1.22.0"}]} * resp-403: Permission denied * resp-500: Failed to resolve release notes @@ -45,12 +45,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const src = resolveReleaseSource(imageName, labels); // Prefer the label-based changelog URL; fall back to the forge's releases page. - const changelogUrl = resolveChangelogUrl(imageName, labels) ?? src?.releasesUrl ?? null; + // `versions` is ordered oldest->newest (the semver path), so the LAST entry is + // the target tag - feed it to the label so a `{{version}}` template resolves to + // the version being offered. + const targetVersion = versions.length ? versions[versions.length - 1] : null; + const changelogUrl = resolveChangelogUrl(imageName, labels, targetVersion) ?? src?.releasesUrl ?? null; // No recognizable forge -> no per-version notes, just the changelog link (if any). - const notes = src ? await fetchReleaseNotes(src, versions) : []; + const result = src ? await fetchReleaseNotes(src, versions) : { notes: [], rateLimited: false }; - return json({ changelogUrl, source: src?.slug ?? null, notes }); + return json({ changelogUrl, source: src?.slug ?? null, notes: result.notes, rateLimited: result.rateLimited }); } catch (error) { console.error('Failed to resolve version notes:', error); return json({ error: 'Failed to resolve release notes' }, { status: 500 }); diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts index 2205122..d407200 100644 --- a/src/routes/api/environments/test/+server.ts +++ b/src/routes/api/environments/test/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import { unixSocketRequest, httpsAgentRequest } from '$lib/server/docker'; import type { DockerClientConfig } from '$lib/server/docker'; +import { isSafeNotificationUrl } from '$lib/server/url-safety'; import type { RequestHandler } from './$types'; interface TestConnectionRequest { @@ -79,6 +80,15 @@ export const POST: RequestHandler = async ({ request }) => { return json({ success: false, error: 'Host is required' }, { status: 400 }); } + // SSRF guard: host/port come straight from the request body. A remote + // Docker/Hawser daemon legitimately lives on a LAN, so allow private + // ranges but block loopback + cloud metadata (169.254.169.254) + reserved + // so this tester can't be used to probe the control plane or metadata IMDS. + const hostSafety = isSafeNotificationUrl(`${protocol}://${host}:${port}`); + if (!hostSafety.ok) { + return json({ success: false, error: `Host not allowed: ${hostSafety.reason}` }, { status: 200 }); + } + const headers: Record = { 'Content-Type': 'application/json' }; diff --git a/src/routes/api/registries/test/+server.ts b/src/routes/api/registries/test/+server.ts index 2a34faf..1bafb16 100644 --- a/src/routes/api/registries/test/+server.ts +++ b/src/routes/api/registries/test/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { parseRegistryUrl, getRegistryAuthHeader, DOCKER_HUB_HOSTS } from '$lib/server/docker'; import { getRegistry } from '$lib/server/db'; +import { isSafeNotificationUrl } from '$lib/server/url-safety'; /** * Test registry connectivity and credentials. @@ -50,6 +51,18 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'URL is required' }, { status: 400 }); } + // SSRF guard on the inline-url path only: a saved registryId is admin-configured, + // but an inline url is caller-supplied. A registry legitimately lives on a LAN + // (self-hosted Harbor/registry:5000), so allow private ranges but block loopback + // + cloud metadata + reserved so this tester can't probe the control plane / IMDS. + if (!data.registryId) { + const withScheme = /^https?:\/\//i.test(url) ? url : `https://${url}`; + const safety = isSafeNotificationUrl(withScheme); + if (!safety.ok) { + return json({ success: false, connectivity: false, message: `Registry host not allowed: ${safety.reason}` }); + } + } + const parsed = parseRegistryUrl(url); const apiBase = `${parsed.protocol}://${parsed.host}`; diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts index 8fed1bb..87c4979 100644 --- a/src/routes/api/templates/+server.ts +++ b/src/routes/api/templates/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; +import { isSafeWebhookUrl } from '$lib/server/url-safety'; import { getTemplateSources, type TemplateSource } from '$lib/server/templates'; export interface TemplateItem { @@ -156,12 +157,27 @@ async function fetchSource(source: TemplateSource): Promise { return cached.data; } + // The source URL is user-configured, so guard against SSRF: a template source + // always lives on a public host, so block loopback/private/link-local/metadata + // (strict policy) and refuse redirects that could bounce to a private host. + const safety = isSafeWebhookUrl(source.url); + if (!safety.ok) { + console.error(`[Templates] Refusing to fetch ${source.name}: ${safety.reason}`); + return cached?.data || []; + } + try { const response = await fetch(source.url, { headers: { 'Accept': 'application/json' }, + redirect: 'manual', signal: AbortSignal.timeout(15000), }); + if (response.status >= 300 && response.status < 400) { + console.error(`[Templates] Refusing to fetch ${source.name}: source redirected the request`); + return cached?.data || []; + } + if (!response.ok) { console.error(`[Templates] Failed to fetch ${source.name}: ${response.status}`); return cached?.data || []; diff --git a/src/routes/api/templates/compose/+server.ts b/src/routes/api/templates/compose/+server.ts index 5a5ad2c..a08edbb 100644 --- a/src/routes/api/templates/compose/+server.ts +++ b/src/routes/api/templates/compose/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; +import { isSafeWebhookUrl } from '$lib/server/url-safety'; import type { TemplateItem } from '../+server'; function generateContainerCompose(template: TemplateItem): string { @@ -72,7 +73,21 @@ async function fetchStackCompose(repository: { url: string; stackfile: string }) : `${repository.url}/${repository.stackfile}`; } - const response = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) }); + // The fallback URL is user-controlled, so guard against SSRF: the server must + // not be coerced into fetching loopback/private/link-local/metadata hosts and + // returning the body. A compose template always lives on a public host, so the + // strict webhook policy (block all private ranges) fits. + const safety = isSafeWebhookUrl(rawUrl); + if (!safety.ok) { + throw new Error(`Refusing to fetch compose file: ${safety.reason}`); + } + + // redirect:'manual' - a public URL that 3xx-redirects to a private/metadata + // host would bypass the literal-host check above, so refuse to follow it. + const response = await fetch(rawUrl, { redirect: 'manual', signal: AbortSignal.timeout(10000) }); + if (response.status >= 300 && response.status < 400) { + throw new Error('Refusing to fetch compose file: server tried to redirect the request'); + } if (!response.ok) { throw new Error(`Failed to fetch compose file: ${response.status}`); } diff --git a/src/routes/api/templates/sources/+server.ts b/src/routes/api/templates/sources/+server.ts index 84f74e6..d0ab204 100644 --- a/src/routes/api/templates/sources/+server.ts +++ b/src/routes/api/templates/sources/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from '@sveltejs/kit'; import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; +import { isSafeWebhookUrl } from '$lib/server/url-safety'; import { getTemplateSources, updateTemplateSource, addTemplateSource, deleteTemplateSource } from '$lib/server/templates'; /** @@ -47,7 +48,11 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { const updates: any = {}; if (enabled !== undefined) updates.enabled = enabled; if (name !== undefined) updates.name = name; - if (url !== undefined) updates.url = url; + if (url !== undefined) { + const safety = isSafeWebhookUrl(url); + if (!safety.ok) return json({ error: `Invalid source URL: ${safety.reason}` }, { status: 400 }); + updates.url = url; + } await updateTemplateSource(id, updates); return json({ ok: true }); @@ -76,6 +81,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (!name?.trim() || !url?.trim()) { return json({ error: 'Name and URL are required' }, { status: 400 }); } + const safety = isSafeWebhookUrl(url.trim()); + if (!safety.ok) return json({ error: `Invalid source URL: ${safety.reason}` }, { status: 400 }); const source = await addTemplateSource({ name: name.trim(), url: url.trim() }); return json(source);