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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(301, '/posts');
|
||||
};
|
||||
@@ -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)}/`);
|
||||
};
|
||||
@@ -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)}/`);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
Reference in New Issue
Block a user