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
@@ -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 });
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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<TemplateItem[]> {
|
||||
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 || [];
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user