From a2ca0db9ca9acc617ee3005f23baba466dd331b2 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Mon, 4 May 2026 16:09:25 +0200 Subject: [PATCH] feat(security+obs): SSRF allowlist, HTML sanitization, timing-safe tokens, fetch timeouts, structured logger 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 --- src/hooks.server.ts | 31 +++++- src/lib/auth-token.server.test.ts | 32 +++++++ src/lib/auth-token.server.ts | 16 ++++ src/lib/cms-source.server.test.ts | 63 ++++++++++++ src/lib/cms-source.server.ts | 45 +++++++++ src/lib/cms.ts | 62 +++++++----- src/lib/components/TopBanner.svelte | 4 +- .../blocks/ImageGalleryBlock.svelte | 4 +- .../components/blocks/MarkdownBlock.svelte | 4 +- .../blocks/OrganisationsBlock.svelte | 4 +- .../blocks/PostOverviewBlock.svelte | 3 +- .../blocks/SearchableTextBlock.svelte | 4 +- .../blocks/YoutubeVideoGalleryBlock.svelte | 4 +- src/lib/log.server.ts | 44 +++++++++ src/lib/markdown-safe.ts | 37 ++++++++ src/lib/rusty-image.ts | 3 +- src/lib/sanitize-blocks.server.test.ts | 70 ++++++++++++++ src/lib/sanitize-blocks.server.ts | 95 +++++++++++++++++++ src/routes/+layout.server.ts | 25 ++--- src/routes/+page.server.ts | 11 ++- src/routes/[...slug]/+page.server.ts | 2 + src/routes/api/cache/clear/+server.ts | 3 +- src/routes/api/revalidate/+server.ts | 3 +- src/routes/blog/+page.server.ts | 6 -- src/routes/blog/page/[page]/+page.server.ts | 6 -- src/routes/blog/tag/[tag]/+page.server.ts | 6 -- src/routes/cms-files/+server.ts | 22 +---- src/routes/cms-images/+server.ts | 27 +----- src/routes/p/[slug]/+page.server.ts | 7 -- src/routes/post/[slug]/+page.server.ts | 6 +- src/routes/sitemap.xml/+server.ts | 9 +- 31 files changed, 524 insertions(+), 134 deletions(-) create mode 100644 src/lib/auth-token.server.test.ts create mode 100644 src/lib/auth-token.server.ts create mode 100644 src/lib/cms-source.server.test.ts create mode 100644 src/lib/cms-source.server.ts create mode 100644 src/lib/log.server.ts create mode 100644 src/lib/markdown-safe.ts create mode 100644 src/lib/sanitize-blocks.server.test.ts create mode 100644 src/lib/sanitize-blocks.server.ts delete mode 100644 src/routes/blog/+page.server.ts delete mode 100644 src/routes/blog/page/[page]/+page.server.ts delete mode 100644 src/routes/blog/tag/[tag]/+page.server.ts delete mode 100644 src/routes/p/[slug]/+page.server.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 81a181e..754f105 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,8 @@ import { getTranslationBundleBySlug } from '$lib/cms'; import { isCmsAvailable } from '$lib/cms-health.server'; import { maintenanceResponse } from '$lib/maintenance.server'; import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants'; +import { env as envPublic } from '$env/dynamic/public'; +import { logWarn } from '$lib/log.server'; import type { Handle } from '@sveltejs/kit'; /** @@ -37,6 +39,22 @@ function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boo return false; } +/** + * Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen. + * Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom + * Browser/CDN gecacht, daher kein Render-Aufruf nötig. + */ +function legacyRedirectTarget(pathname: string): string | null { + if (pathname === '/blog' || pathname === '/blog/') return '/posts/'; + const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/); + if (m1) return `/posts/page/${m1[1]}/`; + const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/); + if (m2) return `/posts/tag/${m2[1]}/`; + const m3 = pathname.match(/^\/p\/([^/]+)\/?$/); + if (m3) return `/post/${m3[1]}/`; + return null; +} + export const handle: Handle = async ({ event, resolve }) => { // Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne // CMS abzuschalten. /maintenance oder ?cms_down=1. @@ -44,6 +62,14 @@ export const handle: Handle = async ({ event, resolve }) => { return maintenanceResponse(200); } + // Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil + // SvelteKit-Routing/CMS-Calls dafür unnötig. + const redirectTo = legacyRedirectTarget(event.url.pathname); + if (redirectTo) { + const dest = `${redirectTo}${event.url.search}`; + return new Response(null, { status: 301, headers: { location: dest } }); + } + // Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet. // Cached, daher kein Roundtrip pro Request. if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) { @@ -69,8 +95,9 @@ export const handle: Handle = async ({ event, resolve }) => { } else { event.locals.translations = {}; } - } catch { + } catch (err) { event.locals.translations = {}; + logWarn('hooks.translations', err); } const response = await resolve(event, { @@ -116,7 +143,7 @@ export const handle: Handle = async ({ event, resolve }) => { // headers alone so production hardening (X-Frame-Options / CSP from Caddy) // stays intact. if (isPreviewDraft) { - const parent = (process.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim(); + const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim(); response.headers.set( 'content-security-policy', `frame-ancestors 'self' ${parent || '*'}`, diff --git a/src/lib/auth-token.server.test.ts b/src/lib/auth-token.server.test.ts new file mode 100644 index 0000000..f766f66 --- /dev/null +++ b/src/lib/auth-token.server.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { constantTimeEqual } from './auth-token.server'; + +describe('constantTimeEqual', () => { + it('matches identical strings', () => { + expect(constantTimeEqual('abc123', 'abc123')).toBe(true); + }); + + it('rejects different strings of same length', () => { + expect(constantTimeEqual('abc123', 'abc124')).toBe(false); + }); + + it('rejects different lengths', () => { + expect(constantTimeEqual('abc', 'abcd')).toBe(false); + }); + + it('rejects null/undefined input', () => { + expect(constantTimeEqual(null, 'x')).toBe(false); + expect(constantTimeEqual('x', undefined)).toBe(false); + expect(constantTimeEqual(null, null)).toBe(false); + }); + + it('handles empty strings', () => { + expect(constantTimeEqual('', '')).toBe(true); + expect(constantTimeEqual('', 'x')).toBe(false); + }); + + it('handles unicode', () => { + expect(constantTimeEqual('für', 'für')).toBe(true); + expect(constantTimeEqual('für', 'fur')).toBe(false); + }); +}); diff --git a/src/lib/auth-token.server.ts b/src/lib/auth-token.server.ts new file mode 100644 index 0000000..bb88449 --- /dev/null +++ b/src/lib/auth-token.server.ts @@ -0,0 +1,16 @@ +/** + * Konstantzeitvergleich für Auth-Tokens (Webhook-Secrets, API-Keys). + * + * `a !== b` leakt timing-Information; `crypto.timingSafeEqual` vergleicht in + * O(n) immer alle Bytes. Längen-Mismatch wird vorab geprüft, weil + * `timingSafeEqual` bei ungleicher Länge wirft. + */ +import { timingSafeEqual } from 'node:crypto'; + +export function constantTimeEqual(a: string | null | undefined, b: string | null | undefined): boolean { + if (a == null || b == null) return false; + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} diff --git a/src/lib/cms-source.server.test.ts b/src/lib/cms-source.server.test.ts new file mode 100644 index 0000000..83129b2 --- /dev/null +++ b/src/lib/cms-source.server.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ORIG_ENV = { ...process.env }; + +beforeEach(() => { + vi.resetModules(); + process.env.PUBLIC_CMS_URL = 'https://cms.example.com'; +}); + +afterEach(() => { + process.env = { ...ORIG_ENV }; +}); + +async function loadResolver() { + const mod = await import('./cms-source.server'); + return mod.resolveCmsSource; +} + +describe('resolveCmsSource', () => { + it('accepts asset filename', async () => { + const resolve = await loadResolver(); + expect(resolve('photo.jpg')).toBe('https://cms.example.com/api/assets/photo.jpg'); + }); + + it('accepts absolute path on CMS host', async () => { + const resolve = await loadResolver(); + expect(resolve('/api/assets/foo.png')).toBe('https://cms.example.com/api/assets/foo.png'); + }); + + it('accepts full URL on CMS host', async () => { + const resolve = await loadResolver(); + expect(resolve('https://cms.example.com/some/path')).toBe('https://cms.example.com/some/path'); + }); + + it('rejects foreign host', async () => { + const resolve = await loadResolver(); + expect(resolve('https://attacker.com/exploit.jpg')).toBeNull(); + }); + + it('rejects http when CMS uses https', async () => { + const resolve = await loadResolver(); + expect(resolve('http://cms.example.com/foo')).toBeNull(); + }); + + it('rejects protocol-relative URLs', async () => { + const resolve = await loadResolver(); + expect(resolve('//attacker.com/x.jpg')).toBeNull(); + }); + + it('rejects path traversal in filename', async () => { + const resolve = await loadResolver(); + expect(resolve('../etc/passwd')).toBeNull(); + expect(resolve('./hidden')).toBeNull(); + }); + + it('encodes "javascript:" payloads as harmless asset filename', async () => { + const resolve = await loadResolver(); + // Kein Schema-Marker (kein "://") → wird als Filename behandelt und + // url-encoded an /api/assets angehängt. Server liefert 404 zurück, + // keine Code-Ausführung möglich. + expect(resolve('javascript:alert(1)')).toContain('/api/assets/javascript%3Aalert'); + }); +}); diff --git a/src/lib/cms-source.server.ts b/src/lib/cms-source.server.ts new file mode 100644 index 0000000..60aa1a3 --- /dev/null +++ b/src/lib/cms-source.server.ts @@ -0,0 +1,45 @@ +/** + * Allowlist-Resolver für `?src=...`-Query-Parameter in den Image- und File-Proxy- + * Routen. Verhindert SSRF: Angreifer könnten sonst beliebige URLs an die + * CMS-API durchreichen lassen (interne Hosts, Cloud-Metadata-Endpunkte, + * Cache-Pollution). + * + * Erlaubt: + * - Asset-Dateiname ("foo.jpg") → {CMS}/api/assets/foo.jpg + * - Absoluter Pfad ("/api/assets/...") → {CMS}/api/assets/... + * - Vollständige URL exakt unter CMS-Host → unverändert + * + * Abgelehnt: + * - Fremde Hosts, andere Schemata + * - Protokoll-relative URLs ("//host/...") + * - Pfad-Traversal ("..", führender Punkt) + */ +import { env } from '$env/dynamic/public'; + +export function getCmsBaseUrl(): string { + return ( + env.PUBLIC_CMS_URL || + import.meta.env.PUBLIC_CMS_URL || + 'http://localhost:3000' + ).replace(/\/$/, ''); +} + +export function resolveCmsSource(src: string): string | null { + if (src.startsWith('//')) return null; + if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`; + if (src.startsWith('http://') || src.startsWith('https://')) { + let candidate: URL; + let cmsHost: URL; + try { + candidate = new URL(src); + cmsHost = new URL(getCmsBaseUrl()); + } catch { + return null; + } + if (candidate.host !== cmsHost.host) return null; + if (candidate.protocol !== cmsHost.protocol) return null; + return src; + } + if (src.includes('://') || src.includes('..') || src.startsWith('.')) return null; + return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`; +} diff --git a/src/lib/cms.ts b/src/lib/cms.ts index b3745ab..100796b 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -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 { + 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 { 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 { export async function getPages(): Promise { 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 }; export async function batchFetch(requests: BatchRequest[]): Promise { 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 { 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 }; return data.counts ?? {}; diff --git a/src/lib/components/TopBanner.svelte b/src/lib/components/TopBanner.svelte index 5637dfe..b4ade7f 100644 --- a/src/lib/components/TopBanner.svelte +++ b/src/lib/components/TopBanner.svelte @@ -4,11 +4,9 @@ * und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `` mit WebP-Sources * und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `` (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; diff --git a/src/lib/components/blocks/ImageGalleryBlock.svelte b/src/lib/components/blocks/ImageGalleryBlock.svelte index d8c4923..bf832c2 100644 --- a/src/lib/components/blocks/ImageGalleryBlock.svelte +++ b/src/lib/components/blocks/ImageGalleryBlock.svelte @@ -1,5 +1,5 @@ '); + expect(out).not.toContain('script'); + expect(out).toContain('ok'); + }); + + it('strips on* event handlers', () => { + const out = sanitizeUntrustedHtml('x'); + expect(out).not.toContain('onclick'); + }); + + it('strips javascript: URLs', () => { + const out = sanitizeUntrustedHtml('x'); + expect(out).not.toContain('javascript'); + }); + + it('keeps allowed tags + attributes', () => { + const out = sanitizeUntrustedHtml('

bold

'); + expect(out).toContain('

'); + expect(out).toContain('bold'); + }); + + it('allows iframes only from approved hosts', () => { + const ok = sanitizeUntrustedHtml(''); + expect(ok).toContain('youtube.com'); + const bad = sanitizeUntrustedHtml(''); + expect(bad).not.toContain('evil'); + }); + + it('adds rel=noopener on target=_blank', () => { + const out = sanitizeUntrustedHtml('x'); + expect(out).toContain('rel="noopener noreferrer"'); + }); +}); + +describe('sanitizeBlocks (walker)', () => { + it('mutates html-block fields in place', () => { + const tree = { + row1Content: [ + { _type: 'html', html: '

safe

' }, + { _type: 'markdown', content: 'untouched' }, + ], + }; + sanitizeBlocks(tree); + expect(tree.row1Content[0].html).not.toContain('script'); + expect(tree.row1Content[0].html).toContain('safe'); + expect((tree.row1Content[1] as { content: string }).content).toBe('untouched'); + }); + + it('walks nested arrays', () => { + const tree = { + sections: [ + { items: [{ _type: 'html', html: '' }] }, + ], + }; + sanitizeBlocks(tree); + const html = (tree.sections[0].items[0] as { html: string }).html; + expect(html).not.toContain('onerror'); + }); + + it('handles non-object inputs gracefully', () => { + expect(() => sanitizeBlocks(null)).not.toThrow(); + expect(() => sanitizeBlocks('string')).not.toThrow(); + expect(() => sanitizeBlocks([1, 2, 3])).not.toThrow(); + }); +}); diff --git a/src/lib/sanitize-blocks.server.ts b/src/lib/sanitize-blocks.server.ts new file mode 100644 index 0000000..7c9ee06 --- /dev/null +++ b/src/lib/sanitize-blocks.server.ts @@ -0,0 +1,95 @@ +/** + * Walkt einen vom CMS gelieferten Page-/Post-Content-Tree und sanitisiert + * Felder, die später roh über `{@html}` gerendert werden — primär das + * `html`-Feld von HtmlBlocks. + * + * Wird in den Server-Loads aufgerufen (`+layout.server.ts`, `+page.server.ts`), + * **nachdem** `resolveContentImages` etc. den Tree fertig aufgelöst haben. + * Die Daten landen anschließend als sanitiertes HTML in der Page-Data, sodass + * Svelte-Komponenten beim Render (Server + Client) keine zusätzliche Filter- + * Logik brauchen. + * + * Server-only (`sanitize-html` braucht Node-APIs). Komponenten dürfen das + * Modul daher nicht importieren — der `.server.ts`-Suffix erzwingt das. + */ +import sanitizeHtml from 'sanitize-html'; + +const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = { + // Allowlist: bewusst restriktiv. Alles, was nicht in `allowedTags` steht, + // wird entfernt. `allowedSchemes` blockiert javascript:, data: usw. + allowedTags: [ + 'p', 'br', 'hr', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup', 'mark', + 'a', 'blockquote', 'cite', 'q', + 'ul', 'ol', 'li', + 'pre', 'code', 'kbd', 'samp', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', + 'div', 'span', 'section', 'article', 'aside', 'figure', 'figcaption', + 'img', 'picture', 'source', + 'iframe', + ], + allowedAttributes: { + a: ['href', 'name', 'target', 'rel', 'title'], + img: ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'], + source: ['src', 'srcset', 'sizes', 'type', 'media'], + iframe: [ + 'src', 'width', 'height', 'allow', 'allowfullscreen', + 'referrerpolicy', 'loading', 'title', 'frameborder', + ], + '*': ['class', 'id', 'style', 'data-*', 'aria-*'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowedSchemesByTag: { + img: ['http', 'https', 'data'], + }, + allowedIframeHostnames: [ + 'www.youtube.com', + 'youtube.com', + 'www.youtube-nocookie.com', + 'player.vimeo.com', + 'open.spotify.com', + ], + allowProtocolRelative: false, + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { + ...attribs, + rel: attribs.target === '_blank' ? 'noopener noreferrer' : (attribs.rel ?? ''), + }, + }), + }, +}; + +export function sanitizeUntrustedHtml(html: string): string { + return sanitizeHtml(html, HTML_BLOCK_OPTIONS); +} + +type Block = { _type?: string; html?: unknown } & Record; + +function isBlock(value: unknown): value is Block { + return typeof value === 'object' && value !== null; +} + +function visit(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + if (!isBlock(node)) return; + + if (node._type === 'html' && typeof node.html === 'string') { + node.html = sanitizeUntrustedHtml(node.html); + } + + for (const key of Object.keys(node)) { + if (key === '_type' || key === 'html') continue; + visit(node[key]); + } +} + +/** Mutiert den Tree in-place: alle HtmlBlocks bekommen sanitisiertes `html`. */ +export function sanitizeBlocks(root: unknown): void { + visit(root); +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index e6da1dd..b779e2a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -24,6 +24,7 @@ import { SOCIAL_IMAGE_TRANSFORM, } from '$lib/constants'; import { env } from '$env/dynamic/public'; +import { logWarn } from '$lib/log.server'; interface NavLink { href: string; @@ -94,8 +95,9 @@ export const load: LayoutServerLoad = async ({ locals }) => { bootstrapNavSocial = batchData(batch.results.navSocial); bootstrapFooter = batchData(batch.results.footer); bootstrapConfig = batchData(batch.results.config); - } catch { + } catch (err) { /* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */ + logWarn('layout.bootstrap', err); } // Fallback für config: Slug "default" probieren (legacy), wenn die kanonische @@ -112,8 +114,8 @@ export const load: LayoutServerLoad = async ({ locals }) => { }, ]); bootstrapConfig = batchData(fallback.results.config); - } catch { - /* optional */ + } catch (err) { + logWarn('layout.config-fallback', err); } } @@ -191,8 +193,8 @@ export const load: LayoutServerLoad = async ({ locals }) => { const href = postHref ?? pageHref; headerLinks.push({ href, label }); } - } catch { - // CMS nicht erreichbar + } catch (err) { + logWarn('layout.headerLinks', err); } // Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts @@ -220,8 +222,8 @@ export const load: LayoutServerLoad = async ({ locals }) => { const label = asObj.linkName ?? asObj.name ?? ''; socialLinks.push({ href, icon, label }); } - } catch { - /* Social-Links optional */ + } catch (err) { + logWarn('layout.socialLinks', err); } // Footer + page_config kommen direkt aus dem Batch (siehe oben). @@ -257,7 +259,7 @@ export const load: LayoutServerLoad = async ({ locals }) => { const text = (await res.text()).trim(); if (text.includes(' { }); } } - } catch { - /* Logo optional */ + } catch (err) { + logWarn('layout.logo', err); } const siteName = @@ -282,8 +284,9 @@ export const load: LayoutServerLoad = async ({ locals }) => { logoSocialImage = transformed.startsWith('/') ? `${siteBaseUrl}${transformed}` : transformed; - } catch { + } catch (err) { logoSocialImage = null; + logWarn('layout.socialImage', err); } return { diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 697e6d4..c86e9b7 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -13,6 +13,8 @@ import { resolveDeadlineBannerBlocks, } from '$lib/blog-utils'; import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants'; +import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; +import { logWarn } from '$lib/log.server'; const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920]; const BANNER_AR = '16/9'; @@ -27,8 +29,8 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { let homeSlug = DEFAULT_HOME_PAGE_SLUG; try { homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG; - } catch { - // fallback + } catch (err) { + logWarn('home.slug', err); } let page = null; @@ -47,9 +49,10 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { await resolveSearchableTextBlocks(page, tagsMap); await resolveCalendarBlocks(page); await resolveDeadlineBannerBlocks(page); + sanitizeBlocks(page); } - } catch { - // CMS nicht erreichbar + } catch (err) { + logWarn('home.page', err, { homeSlug }); } const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME; diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index d838b60..ac451d5 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -14,6 +14,7 @@ import { } from '$lib/blog-utils'; import { PAGE_RESOLVE } from '$lib/constants'; import { error } from '@sveltejs/kit'; +import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920]; const BANNER_AR = '16/9'; @@ -55,6 +56,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead await resolveSearchableTextBlocks(page, tagsMap); await resolveCalendarBlocks(page); await resolveDeadlineBannerBlocks(page); + sanitizeBlocks(page); // Resolve top banner if present let topBannerResolvedImages: string[] = []; diff --git a/src/routes/api/cache/clear/+server.ts b/src/routes/api/cache/clear/+server.ts index 1048d17..9c2445b 100644 --- a/src/routes/api/cache/clear/+server.ts +++ b/src/routes/api/cache/clear/+server.ts @@ -4,6 +4,7 @@ import { env } from '$env/dynamic/private'; import { env as envPublic } from '$env/dynamic/public'; import { invalidateAll } from '$lib/cms'; import { clearImageCache } from '$lib/image-cache.server'; +import { constantTimeEqual } from '$lib/auth-token.server'; /** * Manual full-cache purge: @@ -21,7 +22,7 @@ export const POST: RequestHandler = async ({ request, url }) => { request.headers.get('x-revalidate-token') ?? request.headers.get('x-webhook-secret') ?? url.searchParams.get('token'); - if (provided !== token) throw error(401, 'invalid token'); + if (!constantTimeEqual(provided, token)) throw error(401, 'invalid token'); invalidateAll(); const img = await clearImageCache(); diff --git a/src/routes/api/revalidate/+server.ts b/src/routes/api/revalidate/+server.ts index 0386162..d466afb 100644 --- a/src/routes/api/revalidate/+server.ts +++ b/src/routes/api/revalidate/+server.ts @@ -3,6 +3,7 @@ import { json, error } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { invalidateAll, invalidateCollection } from '$lib/cms'; import { clearImageCache } from '$lib/image-cache.server'; +import { constantTimeEqual } from '$lib/auth-token.server'; /** * RustyCMS Webhook Receiver. @@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, url }) => { request.headers.get('x-revalidate-token') ?? request.headers.get('x-webhook-secret') ?? url.searchParams.get('token'); - if (provided !== token) { + if (!constantTimeEqual(provided, token)) { throw error(401, 'invalid token'); } diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts deleted file mode 100644 index bf391bf..0000000 --- a/src/routes/blog/+page.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; - -export const load: PageServerLoad = async () => { - redirect(301, '/posts'); -}; diff --git a/src/routes/blog/page/[page]/+page.server.ts b/src/routes/blog/page/[page]/+page.server.ts deleted file mode 100644 index d69652d..0000000 --- a/src/routes/blog/page/[page]/+page.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; - -export const load: PageServerLoad = async ({ params }) => { - redirect(301, `/posts/page/${encodeURIComponent(params.page)}/`); -}; diff --git a/src/routes/blog/tag/[tag]/+page.server.ts b/src/routes/blog/tag/[tag]/+page.server.ts deleted file mode 100644 index 578b8c1..0000000 --- a/src/routes/blog/tag/[tag]/+page.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; - -export const load: PageServerLoad = async ({ params }) => { - redirect(301, `/posts/tag/${encodeURIComponent(params.tag)}/`); -}; diff --git a/src/routes/cms-files/+server.ts b/src/routes/cms-files/+server.ts index ebe477b..81f9f20 100644 --- a/src/routes/cms-files/+server.ts +++ b/src/routes/cms-files/+server.ts @@ -1,5 +1,4 @@ import type { RequestHandler } from './$types'; -import { env } from '$env/dynamic/public'; import { buildFileCacheKey, cacheFile, @@ -7,26 +6,12 @@ import { extFromSrc, getCachedFile, } from '$lib/file-cache.server'; +import { resolveCmsSource } from '$lib/cms-source.server'; const IMMUTABLE_HEADERS = { 'cache-control': 'public, max-age=31536000, immutable', } as const; -function getCmsBaseUrl(): string { - return ( - env.PUBLIC_CMS_URL || - import.meta.env.PUBLIC_CMS_URL || - 'http://localhost:3000' - ).replace(/\/$/, ''); -} - -function resolveSourceUrl(src: string): string { - if (src.startsWith('http://') || src.startsWith('https://')) return src; - if (src.startsWith('//')) return `https:${src}`; - if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`; - return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`; -} - function buildResponseHeaders( contentType: string, download: string | null, @@ -73,7 +58,10 @@ export const GET: RequestHandler = async ({ url }) => { }); } - const sourceUrl = resolveSourceUrl(src); + const sourceUrl = resolveCmsSource(src); + if (!sourceUrl) { + return new Response('Source not allowed', { status: 400 }); + } let res: Response; try { res = await fetch(sourceUrl); diff --git a/src/routes/cms-images/+server.ts b/src/routes/cms-images/+server.ts index f3752b2..826c6f0 100644 --- a/src/routes/cms-images/+server.ts +++ b/src/routes/cms-images/+server.ts @@ -1,36 +1,16 @@ import type { RequestHandler } from './$types'; -import { env } from '$env/dynamic/public'; import { buildCacheKey, getCachedImage, cacheImage, type ImageTransformParams, } from '$lib/image-cache.server'; +import { getCmsBaseUrl, resolveCmsSource } from '$lib/cms-source.server'; const IMMUTABLE_HEADERS = { 'cache-control': 'public, max-age=31536000, immutable', } as const; -function getCmsBaseUrl(): string { - return ( - env.PUBLIC_CMS_URL || - import.meta.env.PUBLIC_CMS_URL || - 'http://localhost:3000' - ).replace(/\/$/, ''); -} - -/** - * Wenn src kein vollständiger URL ist, wird er als CMS-Asset-Dateiname behandelt - * und zu {CMS_URL}/api/assets/{src} aufgelöst. - * Absolute Pfade (z.B. /api/assets/logo/logo3.svg) werden direkt an die CMS-URL angehängt. - */ -function resolveSourceUrl(src: string): string { - if (src.startsWith('http://') || src.startsWith('https://')) return src; - if (src.startsWith('//')) return `https:${src}`; - if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`; - return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`; -} - /** * Pick output format based on client Accept header. * Priority: avif > webp > jpeg. Used when `format=auto` is requested so the @@ -102,7 +82,10 @@ export const GET: RequestHandler = async ({ url, request }) => { }); } - const sourceUrl = resolveSourceUrl(src); + const sourceUrl = resolveCmsSource(src); + if (!sourceUrl) { + return new Response('Source not allowed', { status: 400 }); + } const cmsBase = getCmsBaseUrl(); const transformParams = new URLSearchParams(); transformParams.set('url', sourceUrl); diff --git a/src/routes/p/[slug]/+page.server.ts b/src/routes/p/[slug]/+page.server.ts deleted file mode 100644 index dcefea8..0000000 --- a/src/routes/p/[slug]/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; - -export const load: PageServerLoad = async ({ params }) => { - const { slug } = params; - redirect(301, `/post/${encodeURIComponent(slug)}`); -}; diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index 0b07693..d1a55d0 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -16,9 +16,8 @@ import { import { POST_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM, SITE_NAME } from '$lib/constants'; import { env } from '$env/dynamic/public'; import { error } from '@sveltejs/kit'; -import { marked } from 'marked'; - -marked.setOptions({ gfm: true }); +import { marked } from '$lib/markdown-safe'; +import { sanitizeBlocks } from '$lib/sanitize-blocks.server'; export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) => { const { slug } = params; @@ -51,6 +50,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) await resolveSearchableTextBlocks(post, tagsMap); await resolveDeadlineBannerBlocks(post); resolvePostTagsInPost(post, tagsMap); + sanitizeBlocks(post); const postImageField = getPostImageField(post.postImage); const postImageUrl = postImageField diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index f2f9e9c..d124654 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/public'; import { getPages, getPosts } from '$lib/cms'; import { filterHiddenPosts, postHref } from '$lib/blog-utils'; +import { logWarn } from '$lib/log.server'; function normalizeSlug(s: string | undefined): string { return (s ?? '').replace(/^\//, '').trim(); @@ -64,8 +65,8 @@ export const GET: RequestHandler = async ({ url }) => { priority: '0.6', }); } - } catch { - /* CMS down */ + } catch (err) { + logWarn('sitemap.pages', err); } try { @@ -85,8 +86,8 @@ export const GET: RequestHandler = async ({ url }) => { priority: '0.5', }); } - } catch { - /* CMS down */ + } catch (err) { + logWarn('sitemap.posts', err); } const body = `