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
+14 -11
View File
@@ -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<NavigationEntry>(batch.results.navSocial);
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
bootstrapConfig = batchData<PageConfigEntry>(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<PageConfigEntry>(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('<svg')) logoSvgHtml = text;
}
} catch { /* Fallback: img mit logoUrl */ }
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
@@ -266,8 +268,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
});
}
}
} 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 {