feat(security+obs): SSRF allowlist, HTML sanitization, timing-safe tokens, fetch timeouts, structured logger
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m17s

Security:
  - cms-images / cms-files now share resolveCmsSource (cms-source.server.ts)
    which enforces an allowlist: asset filename, absolute path on the CMS
    host, or full URL exact-matching the configured CMS host+protocol.
    Foreign hosts, protocol-relative URLs and path traversal return 400.
  - markdown-safe.ts is the single configured marked entrypoint; it
    disables raw HTML rendering globally (renderer.html() returns ''),
    removing the inline script/iframe injection vector via markdown.
    All 8 marked imports across components and loaders now go through it.
  - sanitize-blocks.server.ts walks resolved page/post content and runs
    HtmlBlock html fields through sanitize-html with a tight allowlist
    (no script/style/on*, only youtube/vimeo/spotify iframes, mailto/tel
    plus http(s) schemes, target=_blank gets rel=noopener).
  - Webhook tokens (revalidate, cache/clear) compared via
    crypto.timingSafeEqual through auth-token.server.ts.
  - cms.ts wraps every fetch in cmsFetch(): 5 s default timeout via
    AbortSignal.timeout, override per call or globally via
    PUBLIC_CMS_FETCH_TIMEOUT_MS. Stops slow CMS responses from pinning
    SSR workers indefinitely.

Observability:
  - log.server.ts gives logWarn / logError / logInfo with scope + context;
    swallowed catch {} blocks in hooks, layout.server, +page.server,
    [...slug]/+page.server and sitemap now log instead of going silent.

Routing:
  - Legacy /blog, /blog/page/:n, /blog/tag/:t and /p/:slug stub routes
    deleted; their 301 redirects moved into hooks.server.ts so the
    request never enters the SvelteKit page pipeline.

Tests (vitest, server-side):
  - auth-token.server.test.ts (6 cases for constant-time compare)
  - cms-source.server.test.ts  (8 cases for SSRF allowlist)
  - sanitize-blocks.server.test.ts (9 cases for sanitizer + walker)
  All 23 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-04 16:09:25 +02:00
parent 615d9cdbd1
commit a2ca0db9ca
31 changed files with 524 additions and 134 deletions
+40 -22
View File
@@ -15,6 +15,24 @@ const getBaseUrl = (): string => {
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
};
/**
* Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei
* langsamem oder unerreichbarem CMS. Override per Call via `init.signal`
* oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`.
*/
const FETCH_TIMEOUT_MS =
Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000;
/**
* Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal`
* mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout`
* gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`.
*/
async function cmsFetch(input: string | URL, init?: RequestInit): Promise<Response> {
const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
return fetch(input, { ...init, signal });
}
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
export type PageEntry = components["schemas"]["page"];
@@ -125,7 +143,7 @@ export function cacheStats(): { size: number; keys: string[] } {
export async function fetchOpenApi(): Promise<OpenApiSpec> {
if (openApiCache && !import.meta.env.DEV) return openApiCache;
const base = getBaseUrl();
const res = await fetch(`${base}/api-docs/openapi.json`);
const res = await cmsFetch(`${base}/api-docs/openapi.json`);
if (!res.ok) {
throw new Error(
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
@@ -142,7 +160,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
export async function getPages(): Promise<PageEntry[]> {
return cached("page", "page:list:", async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/page`);
const res = await cmsFetch(`${base}/api/content/page`);
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
@@ -168,7 +186,7 @@ export async function getPageConfigs(options?: {
const url = new URL(`${base}/api/content/page_config`);
if (locale) url.searchParams.set("_locale", locale);
url.searchParams.set("_per_page", String(per));
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
@@ -195,7 +213,7 @@ export async function getPageConfigBySlug(
);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -279,7 +297,7 @@ export type BatchResponse = { results: Record<string, BatchResult> };
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
if (requests.length === 0) return { results: {} };
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/_batch`, {
const res = await cmsFetch(`${base}/api/content/_batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requests }),
@@ -328,7 +346,7 @@ export async function getPageBySlug(
u.searchParams.set("_fields", options.fields.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
return fetch(u.toString());
return cmsFetch(u.toString());
};
const normSlug = normalizePageSlug(slug);
let res = await trySlug(normSlug);
@@ -383,7 +401,7 @@ export async function getPageStubs(options?: { locale?: string }): Promise<PageS
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
url.searchParams.set("_per_page", "1000");
if (locale) url.searchParams.set("_locale", locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
const data = (await res.json()) as { items?: PageStub[] };
return data.items ?? [];
@@ -406,7 +424,7 @@ export async function getPostStubs(options?: { locale?: string }): Promise<PostS
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
url.searchParams.set("_per_page", "1000");
if (locale) url.searchParams.set("_locale", locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
const data = (await res.json()) as { items?: PostStub[] };
return data.items ?? [];
@@ -449,7 +467,7 @@ export async function getNavigations(options?: {
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
url.searchParams.set("_page", String(opts.page ?? 1));
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
@@ -509,7 +527,7 @@ export async function getNavigationBySlug(
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as NavigationEntry;
@@ -534,7 +552,7 @@ export async function getFooterBySlug(
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
url.searchParams.set("_resolve", options.resolve.join(","));
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -577,7 +595,7 @@ export async function getPostBySlug(
u.searchParams.set("_fields", options.fields.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
return fetch(u.toString());
return cmsFetch(u.toString());
};
const normSlug = normalizePostSlug(slug);
let res = await trySlug(normSlug);
@@ -645,7 +663,7 @@ export async function getPosts(
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
return data.items ?? [];
@@ -659,7 +677,7 @@ export type TagEntry = components["schemas"]["tag"];
export async function getTags(): Promise<TagEntry[]> {
return cached("tag", "tag:list:", async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/tag`);
const res = await cmsFetch(`${base}/api/content/tag`);
if (!res.ok) return [];
const data = (await res.json()) as { items?: TagEntry[] };
return data.items ?? [];
@@ -681,7 +699,7 @@ export async function getTextFragmentBySlug(
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -707,7 +725,7 @@ export async function getFullwidthBannerBySlug(
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -733,7 +751,7 @@ export async function getTranslationBySlug(
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as TranslationEntry;
@@ -761,7 +779,7 @@ export async function getTranslationBundleBySlug(
);
url.searchParams.set("_resolve", "all");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as TranslationBundleEntry;
@@ -787,7 +805,7 @@ export async function getCalendarBySlug(
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarEntry;
@@ -806,7 +824,7 @@ export async function getCalendarItemBySlug(
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarItemEntry;
@@ -824,7 +842,7 @@ export async function getCalendarItems(
url.searchParams.set("_per_page", "1000");
url.searchParams.set("_resolve", "all");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: CalendarItemEntry[] };
return data.items ?? [];
@@ -853,7 +871,7 @@ export async function getCommentCounts(
url.searchParams.set("_environment", options.environment);
}
try {
const res = await fetch(url.toString());
const res = await cmsFetch(url.toString());
if (!res.ok) return {};
const data = (await res.json()) as { counts?: Record<string, number> };
return data.counts ?? {};