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>
This commit is contained in:
+29
-2
@@ -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 || '*'}`,
|
||||
|
||||
Reference in New Issue
Block a user