Files
windwiderstand/src/hooks.server.ts
T
Peter Meier a2ca0db9ca
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m17s
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 <noreply@anthropic.com>
2026-05-04 16:09:25 +02:00

156 lines
5.6 KiB
TypeScript

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';
/**
* Page-Routen: kurze Edge-Cache + SWR. Webhook purged App-Cache,
* Edge läuft via stale-while-revalidate auf den warmen App-Cache.
*/
const PAGE_CACHE_CONTROL =
'public, max-age=0, s-maxage=30, stale-while-revalidate=300';
const ASSET_EXT_RE = /\.(ico|png|jpe?g|webp|gif|svg|css|js|mjs|map|woff2?|ttf|otf|txt|xml|json|pdf)$/i;
function isCacheablePath(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
if (pathname.startsWith('/cms-images/')) return false;
if (pathname.startsWith('/_app/')) return false;
return true;
}
/** Pfade auf denen wir vor dem Resolve einen CMS-Health-Check machen. */
function shouldHealthCheck(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
if (pathname.startsWith('/_app/')) return false;
if (pathname.startsWith('/cms-images/')) return false;
if (pathname.startsWith('/cms-files/')) return false;
if (pathname === '/sitemap.xml' || pathname === '/robots.txt') return false;
if (ASSET_EXT_RE.test(pathname)) return false;
return true;
}
function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boolean {
if (pathname === '/maintenance' || pathname === '/maintenance/') return true;
if (search.has('cms_down')) return true;
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.
if (isMaintenanceSimulation(event.url.pathname, event.url.searchParams)) {
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)) {
const ok = await isCmsAvailable();
if (!ok) return maintenanceResponse(503);
}
try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' });
if (bundle?.strings && typeof bundle.strings === 'object') {
const normalized: Record<string, string> = {};
for (const [key, val] of Object.entries(bundle.strings)) {
const v = val as unknown;
const str =
typeof v === 'string'
? v
: typeof v === 'object' && v !== null && 'value' in v
? (v as { value?: unknown }).value
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
if (typeof str === 'string') normalized[key] = str;
}
event.locals.translations = normalized;
} else {
event.locals.translations = {};
}
} catch (err) {
event.locals.translations = {};
logWarn('hooks.translations', err);
}
const response = await resolve(event, {
preload: ({ type, path }) => {
if (type === 'js' || type === 'css') return true;
if (type === 'font') {
return /inter-latin-(400|700)-normal\.[a-f0-9]*\.?woff2$/i.test(path);
}
return false;
},
});
const isPreview =
event.url.searchParams.has('preview') ||
event.url.searchParams.has('preview_draft');
const isPreviewDraft = event.url.searchParams.has('preview_draft');
if (
event.request.method === 'GET' &&
isCacheablePath(event.url.pathname) &&
!isPreview &&
!response.headers.has('cache-control')
) {
response.headers.set('cache-control', PAGE_CACHE_CONTROL);
}
if (
event.request.method === 'GET' &&
!response.headers.has('x-robots-tag') &&
(response.headers.get('content-type') ?? '').includes('text/html')
) {
response.headers.set(
'x-robots-tag',
isPreview
? 'noindex, nofollow'
: 'index, follow, max-image-preview:large, max-snippet:-1',
);
}
// Live-preview: allow the admin iframe to embed this response. The admin
// origin is configured via PUBLIC_PREVIEW_PARENT_ORIGIN; when unset we fall
// back to wildcard (dev only). Outside live-preview we leave existing
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
// stays intact.
if (isPreviewDraft) {
const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
response.headers.set(
'content-security-policy',
`frame-ancestors 'self' ${parent || '*'}`,
);
response.headers.delete('x-frame-options');
}
return response;
};