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:
+40
-22
@@ -15,6 +15,24 @@ const getBaseUrl = (): string => {
|
||||
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei
|
||||
* langsamem oder unerreichbarem CMS. Override per Call via `init.signal`
|
||||
* oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS =
|
||||
Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000;
|
||||
|
||||
/**
|
||||
* Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal`
|
||||
* mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout`
|
||||
* gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`.
|
||||
*/
|
||||
async function cmsFetch(input: string | URL, init?: RequestInit): Promise<Response> {
|
||||
const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
return fetch(input, { ...init, signal });
|
||||
}
|
||||
|
||||
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
|
||||
export type PageEntry = components["schemas"]["page"];
|
||||
|
||||
@@ -125,7 +143,7 @@ export function cacheStats(): { size: number; keys: string[] } {
|
||||
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
if (openApiCache && !import.meta.env.DEV) return openApiCache;
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
||||
const res = await cmsFetch(`${base}/api-docs/openapi.json`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||
@@ -142,7 +160,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
return cached("page", "page:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
const res = await cmsFetch(`${base}/api/content/page`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -168,7 +186,7 @@ export async function getPageConfigs(options?: {
|
||||
const url = new URL(`${base}/api/content/page_config`);
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
url.searchParams.set("_per_page", String(per));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -195,7 +213,7 @@ export async function getPageConfigBySlug(
|
||||
);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -279,7 +297,7 @@ export type BatchResponse = { results: Record<string, BatchResult> };
|
||||
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
|
||||
if (requests.length === 0) return { results: {} };
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/_batch`, {
|
||||
const res = await cmsFetch(`${base}/api/content/_batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requests }),
|
||||
@@ -328,7 +346,7 @@ export async function getPageBySlug(
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return fetch(u.toString());
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePageSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -383,7 +401,7 @@ export async function getPageStubs(options?: { locale?: string }): Promise<PageS
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PageStub[] };
|
||||
return data.items ?? [];
|
||||
@@ -406,7 +424,7 @@ export async function getPostStubs(options?: { locale?: string }): Promise<PostS
|
||||
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
if (locale) url.searchParams.set("_locale", locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
|
||||
const data = (await res.json()) as { items?: PostStub[] };
|
||||
return data.items ?? [];
|
||||
@@ -449,7 +467,7 @@ export async function getNavigations(options?: {
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
url.searchParams.set("_page", String(opts.page ?? 1));
|
||||
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -509,7 +527,7 @@ export async function getNavigationBySlug(
|
||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NavigationEntry;
|
||||
@@ -534,7 +552,7 @@ export async function getFooterBySlug(
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
url.searchParams.set("_resolve", options.resolve.join(","));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -577,7 +595,7 @@ export async function getPostBySlug(
|
||||
u.searchParams.set("_fields", options.fields.join(","));
|
||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||
return fetch(u.toString());
|
||||
return cmsFetch(u.toString());
|
||||
};
|
||||
const normSlug = normalizePostSlug(slug);
|
||||
let res = await trySlug(normSlug);
|
||||
@@ -645,7 +663,7 @@ export async function getPosts(
|
||||
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
||||
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
|
||||
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||
const data = (await res.json()) as { items?: PostEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -659,7 +677,7 @@ export type TagEntry = components["schemas"]["tag"];
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
return cached("tag", "tag:list:", async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/tag`);
|
||||
const res = await cmsFetch(`${base}/api/content/tag`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TagEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -681,7 +699,7 @@ export async function getTextFragmentBySlug(
|
||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -707,7 +725,7 @@ export async function getFullwidthBannerBySlug(
|
||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -733,7 +751,7 @@ export async function getTranslationBySlug(
|
||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationEntry;
|
||||
@@ -761,7 +779,7 @@ export async function getTranslationBundleBySlug(
|
||||
);
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as TranslationBundleEntry;
|
||||
@@ -787,7 +805,7 @@ export async function getCalendarBySlug(
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarEntry;
|
||||
@@ -806,7 +824,7 @@ export async function getCalendarItemBySlug(
|
||||
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarItemEntry;
|
||||
@@ -824,7 +842,7 @@ export async function getCalendarItems(
|
||||
url.searchParams.set("_per_page", "1000");
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: CalendarItemEntry[] };
|
||||
return data.items ?? [];
|
||||
@@ -853,7 +871,7 @@ export async function getCommentCounts(
|
||||
url.searchParams.set("_environment", options.environment);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
const res = await cmsFetch(url.toString());
|
||||
if (!res.ok) return {};
|
||||
const data = (await res.json()) as { counts?: Record<string, number> };
|
||||
return data.counts ?? {};
|
||||
|
||||
Reference in New Issue
Block a user