Files
windwiderstand/src/routes/+page.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

123 lines
4.1 KiB
TypeScript

import type { PageServerLoad } from './$types';
import { getPageBySlug, getPages, getHomePageSlugFromConfig } from '$lib/cms';
import {
extractCmsImageField,
resolveContentImages,
resolveImageUrls,
} from '$lib/rusty-image';
import { buildResponsiveImage, warmResponsiveImage, type ResponsiveImage } from '$lib/rusty-image.server';
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
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';
function focalToCss(focal: { x: number; y: number } | undefined): string | null {
if (!focal) return null;
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
}
export const load: PageServerLoad = async ({ locals, fetch }) => {
const translations = locals.translations ?? {};
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
try {
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
} catch (err) {
logWarn('home.slug', err);
}
let page = null;
let pages: Awaited<ReturnType<typeof getPages>> = [];
try {
const [homePage, allPages] = await Promise.all([
getPageBySlug(homeSlug, { locale: 'de', resolve: PAGE_RESOLVE }),
getPages(),
]);
page = homePage;
pages = allPages ?? [];
if (page) {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
await resolveCalendarBlocks(page);
await resolveDeadlineBannerBlocks(page);
sanitizeBlocks(page);
}
} catch (err) {
logWarn('home.page', err, { homeSlug });
}
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
let topBanner: {
banner: import('$lib/cms').FullwidthBannerEntry | null;
resolvedImages: string[];
responsive: ResponsiveImage | null;
focalCss: string | null;
headline?: string;
subheadline?: string;
} | null = null;
if (page) {
const bannerRaw = (page as { topFullwidthBanner?: unknown }).topFullwidthBanner;
if (bannerRaw && typeof bannerRaw === 'object' && 'image' in bannerRaw) {
const bannerImages = Array.isArray((bannerRaw as { image?: unknown }).image)
? ((bannerRaw as { image?: unknown[] }).image ?? [])
: (bannerRaw as { image?: unknown }).image != null
? [(bannerRaw as { image?: unknown }).image]
: [];
let resolvedImages: string[] = [];
let responsive: ResponsiveImage | null = null;
let focalCss: string | null = null;
if (bannerImages.length > 0) {
const primary = extractCmsImageField(bannerImages[0]);
if (primary?.url) {
focalCss = focalToCss(primary.focal);
responsive = await buildResponsiveImage(primary.url, {
widths: BANNER_WIDTHS,
fit: 'cover',
ar: BANNER_AR,
quality: 72,
focal: primary.focal,
});
}
resolvedImages = await resolveImageUrls(bannerImages as string[], {
width: 2000,
height: 750,
fit: 'cover',
format: 'webp',
});
}
warmResponsiveImage(responsive, fetch);
topBanner = {
banner: bannerRaw as import('$lib/cms').FullwidthBannerEntry | null,
resolvedImages,
responsive,
focalCss,
headline: page.headline ?? undefined,
subheadline: page.subheadline ?? undefined,
};
}
}
return {
page,
pages,
translations,
topBanner,
seoTitle: page
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
: siteName,
seoDescription: page ? (page.seoDescription ?? page.subheadline ?? '') : '',
robots: (page as { seoMetaRobots?: string } | null)?.seoMetaRobots ?? undefined,
keywords: (page as { seoKeywords?: string } | null)?.seoKeywords ?? undefined,
};
};