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('