a2ca0db9ca
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>
71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { sanitizeBlocks, sanitizeUntrustedHtml } from './sanitize-blocks.server';
|
|
|
|
describe('sanitizeUntrustedHtml', () => {
|
|
it('strips <script>', () => {
|
|
const out = sanitizeUntrustedHtml('<p>ok</p><script>alert(1)</script>');
|
|
expect(out).not.toContain('script');
|
|
expect(out).toContain('ok');
|
|
});
|
|
|
|
it('strips on* event handlers', () => {
|
|
const out = sanitizeUntrustedHtml('<a href="#" onclick="alert(1)">x</a>');
|
|
expect(out).not.toContain('onclick');
|
|
});
|
|
|
|
it('strips javascript: URLs', () => {
|
|
const out = sanitizeUntrustedHtml('<a href="javascript:alert(1)">x</a>');
|
|
expect(out).not.toContain('javascript');
|
|
});
|
|
|
|
it('keeps allowed tags + attributes', () => {
|
|
const out = sanitizeUntrustedHtml('<p class="foo"><strong>bold</strong></p>');
|
|
expect(out).toContain('<p class="foo">');
|
|
expect(out).toContain('<strong>bold</strong>');
|
|
});
|
|
|
|
it('allows iframes only from approved hosts', () => {
|
|
const ok = sanitizeUntrustedHtml('<iframe src="https://www.youtube.com/embed/x"></iframe>');
|
|
expect(ok).toContain('youtube.com');
|
|
const bad = sanitizeUntrustedHtml('<iframe src="https://evil.com/x"></iframe>');
|
|
expect(bad).not.toContain('evil');
|
|
});
|
|
|
|
it('adds rel=noopener on target=_blank', () => {
|
|
const out = sanitizeUntrustedHtml('<a href="https://x.com" target="_blank">x</a>');
|
|
expect(out).toContain('rel="noopener noreferrer"');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeBlocks (walker)', () => {
|
|
it('mutates html-block fields in place', () => {
|
|
const tree = {
|
|
row1Content: [
|
|
{ _type: 'html', html: '<p>safe</p><script>x</script>' },
|
|
{ _type: 'markdown', content: 'untouched' },
|
|
],
|
|
};
|
|
sanitizeBlocks(tree);
|
|
expect(tree.row1Content[0].html).not.toContain('script');
|
|
expect(tree.row1Content[0].html).toContain('safe');
|
|
expect((tree.row1Content[1] as { content: string }).content).toBe('untouched');
|
|
});
|
|
|
|
it('walks nested arrays', () => {
|
|
const tree = {
|
|
sections: [
|
|
{ items: [{ _type: 'html', html: '<img src=x onerror=alert(1)>' }] },
|
|
],
|
|
};
|
|
sanitizeBlocks(tree);
|
|
const html = (tree.sections[0].items[0] as { html: string }).html;
|
|
expect(html).not.toContain('onerror');
|
|
});
|
|
|
|
it('handles non-object inputs gracefully', () => {
|
|
expect(() => sanitizeBlocks(null)).not.toThrow();
|
|
expect(() => sanitizeBlocks('string')).not.toThrow();
|
|
expect(() => sanitizeBlocks([1, 2, 3])).not.toThrow();
|
|
});
|
|
});
|