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>
64 lines
2.0 KiB
TypeScript
64 lines
2.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const ORIG_ENV = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
process.env.PUBLIC_CMS_URL = 'https://cms.example.com';
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...ORIG_ENV };
|
|
});
|
|
|
|
async function loadResolver() {
|
|
const mod = await import('./cms-source.server');
|
|
return mod.resolveCmsSource;
|
|
}
|
|
|
|
describe('resolveCmsSource', () => {
|
|
it('accepts asset filename', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('photo.jpg')).toBe('https://cms.example.com/api/assets/photo.jpg');
|
|
});
|
|
|
|
it('accepts absolute path on CMS host', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('/api/assets/foo.png')).toBe('https://cms.example.com/api/assets/foo.png');
|
|
});
|
|
|
|
it('accepts full URL on CMS host', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('https://cms.example.com/some/path')).toBe('https://cms.example.com/some/path');
|
|
});
|
|
|
|
it('rejects foreign host', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('https://attacker.com/exploit.jpg')).toBeNull();
|
|
});
|
|
|
|
it('rejects http when CMS uses https', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('http://cms.example.com/foo')).toBeNull();
|
|
});
|
|
|
|
it('rejects protocol-relative URLs', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('//attacker.com/x.jpg')).toBeNull();
|
|
});
|
|
|
|
it('rejects path traversal in filename', async () => {
|
|
const resolve = await loadResolver();
|
|
expect(resolve('../etc/passwd')).toBeNull();
|
|
expect(resolve('./hidden')).toBeNull();
|
|
});
|
|
|
|
it('encodes "javascript:" payloads as harmless asset filename', async () => {
|
|
const resolve = await loadResolver();
|
|
// Kein Schema-Marker (kein "://") → wird als Filename behandelt und
|
|
// url-encoded an /api/assets angehängt. Server liefert 404 zurück,
|
|
// keine Code-Ausführung möglich.
|
|
expect(resolve('javascript:alert(1)')).toContain('/api/assets/javascript%3Aalert');
|
|
});
|
|
});
|