From ec9d361a13f71c9fdca194e564ad8470ac2faa9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Wed, 15 Apr 2026 16:06:51 +0200 Subject: [PATCH] spa(task 7 polish): scoped marked-instance, ssr-guard, erweiterte xss-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eigene `new Marked({...})`-Instanz statt globaler `marked.use()`-Mutation — schützt andere Module vor Konfigurationsleckage, schärft Spec §3 ("lokale Ersetzbarkeit"). - SSR-Guard: `renderMarkdown` wirft in Non-DOM-Umgebungen eine Fehlermeldung statt stumm unsicher durchzulaufen. SPA hat `ssr=false`, Vitest läuft in jsdom — Guard ist Early-Fail für versehentliche Node-Aufrufe. - `ADD_ATTR: ['target', 'rel']` entfernt — war ein No-Op, weil Marked diese Attribute nicht einfügt. Link-Attribut-Hardening kommt später, wenn externe Links tatsächlich `target="_blank"` bekommen sollen. - Code-Block-Test prüft zusätzlich `class="hljs"` (Regression-Anker für Custom-Renderer). - Erweiterte XSS-Matrix: onerror, onclick, iframe, data:text/html, vbscript:, svg+script — relevant für spätere Reply-Darstellung. 14/14 Tests grün. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/lib/render/markdown.ts | 48 ++++++++++++++++++++------------- app/tests/unit/markdown.test.ts | 34 ++++++++++++++++++++++- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/app/src/lib/render/markdown.ts b/app/src/lib/render/markdown.ts index d36bf64..0f6f2ff 100644 --- a/app/src/lib/render/markdown.ts +++ b/app/src/lib/render/markdown.ts @@ -1,4 +1,4 @@ -import { marked } from 'marked'; +import { Marked } from 'marked'; import DOMPurify from 'dompurify'; import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; @@ -14,28 +14,40 @@ hljs.registerLanguage('bash', bash); hljs.registerLanguage('sh', bash); hljs.registerLanguage('json', json); -marked.use({ - breaks: true, - gfm: true, - renderer: { - code({ text, lang }) { - const language = lang && hljs.getLanguage(lang) ? lang : undefined; - const highlighted = language - ? hljs.highlight(text, { language }).value - : hljs.highlightAuto(text).value; - const cls = language ? ` language-${language}` : ''; - return `
${highlighted}
`; - }, - }, +/** + * Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht + * mutiert wird — andere Module können `marked` unbeeinflusst weiterverwenden. + * (Spec §3: lokale Ersetzbarkeit der Engine.) + */ +const markedInstance = new Marked({ + breaks: true, + gfm: true, + renderer: { + code({ text, lang }) { + const language = lang && hljs.getLanguage(lang) ? lang : undefined; + const highlighted = language + ? hljs.highlight(text, { language }).value + : hljs.highlightAuto(text).value; + const cls = language ? ` language-${language}` : ''; + return `
${highlighted}
`; + } + } }); /** * Rendert einen Markdown-String zu sanitized HTML. * Einziger Export des Moduls — so bleibt Austausch der Engine lokal. + * + * Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA + * hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft + * in jsdom — beide Szenarien sind abgedeckt. Ein Aufruf in reiner + * Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher + * durchzulaufen. */ export function renderMarkdown(md: string): string { - const raw = marked.parse(md, { async: false }) as string; - return DOMPurify.sanitize(raw, { - ADD_ATTR: ['target', 'rel'], - }); + if (typeof window === 'undefined') { + throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).'); + } + const raw = markedInstance.parse(md, { async: false }) as string; + return DOMPurify.sanitize(raw); } diff --git a/app/tests/unit/markdown.test.ts b/app/tests/unit/markdown.test.ts index 3cddab0..5d05f81 100644 --- a/app/tests/unit/markdown.test.ts +++ b/app/tests/unit/markdown.test.ts @@ -28,10 +28,11 @@ describe('renderMarkdown', () => { expect(html).toContain('
'); }); - it('rendert fenced code blocks', () => { + it('rendert fenced code blocks mit hljs-klasse', () => { const html = renderMarkdown('```js\nconst x = 1;\n```'); expect(html).toContain('
');
     expect(html).toContain(' {
+    const html = renderMarkdown('');
+    expect(html.toLowerCase()).not.toContain('onerror');
+  });
+
+  it('entfernt onclick-Attribute auf inline-HTML', () => {
+    const html = renderMarkdown('x');
+    expect(html.toLowerCase()).not.toContain('onclick');
+  });
+
+  it('entfernt iframe-Tags', () => {
+    const html = renderMarkdown('');
+    expect(html.toLowerCase()).not.toContain(' {
+    const html = renderMarkdown('[x](data:text/html,)');
+    expect(html.toLowerCase()).not.toMatch(/href="data:text\/html/);
+  });
+
+  it('entfernt vbscript:-URLs', () => {
+    const html = renderMarkdown('x');
+    expect(html.toLowerCase()).not.toContain('vbscript:');
+  });
+
+  it('entfernt script-Tag innerhalb svg', () => {
+    const html = renderMarkdown('');
+    expect(html.toLowerCase()).not.toContain('