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:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Walkt einen vom CMS gelieferten Page-/Post-Content-Tree und sanitisiert
|
||||
* Felder, die später roh über `{@html}` gerendert werden — primär das
|
||||
* `html`-Feld von HtmlBlocks.
|
||||
*
|
||||
* Wird in den Server-Loads aufgerufen (`+layout.server.ts`, `+page.server.ts`),
|
||||
* **nachdem** `resolveContentImages` etc. den Tree fertig aufgelöst haben.
|
||||
* Die Daten landen anschließend als sanitiertes HTML in der Page-Data, sodass
|
||||
* Svelte-Komponenten beim Render (Server + Client) keine zusätzliche Filter-
|
||||
* Logik brauchen.
|
||||
*
|
||||
* Server-only (`sanitize-html` braucht Node-APIs). Komponenten dürfen das
|
||||
* Modul daher nicht importieren — der `.server.ts`-Suffix erzwingt das.
|
||||
*/
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = {
|
||||
// Allowlist: bewusst restriktiv. Alles, was nicht in `allowedTags` steht,
|
||||
// wird entfernt. `allowedSchemes` blockiert javascript:, data: usw.
|
||||
allowedTags: [
|
||||
'p', 'br', 'hr',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup', 'mark',
|
||||
'a', 'blockquote', 'cite', 'q',
|
||||
'ul', 'ol', 'li',
|
||||
'pre', 'code', 'kbd', 'samp',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||
'div', 'span', 'section', 'article', 'aside', 'figure', 'figcaption',
|
||||
'img', 'picture', 'source',
|
||||
'iframe',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'name', 'target', 'rel', 'title'],
|
||||
img: ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||
source: ['src', 'srcset', 'sizes', 'type', 'media'],
|
||||
iframe: [
|
||||
'src', 'width', 'height', 'allow', 'allowfullscreen',
|
||||
'referrerpolicy', 'loading', 'title', 'frameborder',
|
||||
],
|
||||
'*': ['class', 'id', 'style', 'data-*', 'aria-*'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
allowedSchemesByTag: {
|
||||
img: ['http', 'https', 'data'],
|
||||
},
|
||||
allowedIframeHostnames: [
|
||||
'www.youtube.com',
|
||||
'youtube.com',
|
||||
'www.youtube-nocookie.com',
|
||||
'player.vimeo.com',
|
||||
'open.spotify.com',
|
||||
],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
rel: attribs.target === '_blank' ? 'noopener noreferrer' : (attribs.rel ?? ''),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function sanitizeUntrustedHtml(html: string): string {
|
||||
return sanitizeHtml(html, HTML_BLOCK_OPTIONS);
|
||||
}
|
||||
|
||||
type Block = { _type?: string; html?: unknown } & Record<string, unknown>;
|
||||
|
||||
function isBlock(value: unknown): value is Block {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function visit(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isBlock(node)) return;
|
||||
|
||||
if (node._type === 'html' && typeof node.html === 'string') {
|
||||
node.html = sanitizeUntrustedHtml(node.html);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === '_type' || key === 'html') continue;
|
||||
visit(node[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutiert den Tree in-place: alle HtmlBlocks bekommen sanitisiertes `html`. */
|
||||
export function sanitizeBlocks(root: unknown): void {
|
||||
visit(root);
|
||||
}
|
||||
Reference in New Issue
Block a user