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
+1 -3
View File
@@ -4,11 +4,9 @@
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit WebP-Sources
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
*/
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import type { FullwidthBannerEntry } from "$lib/cms";
marked.setOptions({ gfm: true });
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
fallback: string;
@@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
@@ -29,8 +29,6 @@
}
}
marked.setOptions({ gfm: true });
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const descriptionHtml = $derived(
block.description && typeof block.description === "string"
@@ -1,11 +1,9 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { MarkdownBlockData } from "$lib/block-types";
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
marked.setOptions({ gfm: true });
const html = $derived(
block.resolvedContent ??
(block.content && typeof block.content === "string"
@@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
@@ -10,8 +10,6 @@
import RustyImage from "$lib/components/RustyImage.svelte";
import Button from "$lib/ui/Button.svelte";
marked.setOptions({ gfm: true });
function organisationLogoField(o: OrganisationEntry) {
return extractCmsImageField(o.logo);
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import PostCard from "../PostCard.svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { PostOverviewBlockData } from "$lib/block-types";
@@ -11,7 +11,6 @@
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
const t = useTranslate();
marked.setOptions({ gfm: true });
const textHtml = $derived(
block.text && typeof block.text === "string"
? (marked.parse(block.text) as string)
@@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
SearchableTextBlockData,
@@ -28,8 +28,6 @@
}
const helpTooltipText = $derived(t(T.searchable_text_help));
marked.setOptions({ gfm: true });
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const fragments = $derived.by(() => {
@@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
@@ -71,8 +71,6 @@
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg` : null;
}
marked.setOptions({ gfm: true });
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const descriptionHtml = $derived(
block.description && typeof block.description === "string"