spa(task 7 polish): scoped marked-instance, ssr-guard, erweiterte xss-tests
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
2bcb2451b4
commit
ec9d361a13
|
|
@ -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 `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 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 `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ describe('renderMarkdown', () => {
|
|||
expect(html).toContain('<hr>');
|
||||
});
|
||||
|
||||
it('rendert fenced code blocks', () => {
|
||||
it('rendert fenced code blocks mit hljs-klasse', () => {
|
||||
const html = renderMarkdown('```js\nconst x = 1;\n```');
|
||||
expect(html).toContain('<pre>');
|
||||
expect(html).toContain('<code');
|
||||
expect(html).toContain('class="hljs');
|
||||
});
|
||||
|
||||
it('rendert GFM tables', () => {
|
||||
|
|
@ -46,4 +47,35 @@ describe('renderMarkdown', () => {
|
|||
expect(html).toContain('<img');
|
||||
expect(html).toContain('src="https://example.com/img.png"');
|
||||
});
|
||||
|
||||
// Erweiterte XSS-Matrix — relevant ab Reply-Komponenten (3rd-party Content).
|
||||
it('entfernt onerror-Attribute auf inline-HTML-img', () => {
|
||||
const html = renderMarkdown('<img src="x" onerror="alert(1)">');
|
||||
expect(html.toLowerCase()).not.toContain('onerror');
|
||||
});
|
||||
|
||||
it('entfernt onclick-Attribute auf inline-HTML', () => {
|
||||
const html = renderMarkdown('<a href="#" onclick="alert(1)">x</a>');
|
||||
expect(html.toLowerCase()).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('entfernt iframe-Tags', () => {
|
||||
const html = renderMarkdown('<iframe src="https://evil.com"></iframe>');
|
||||
expect(html.toLowerCase()).not.toContain('<iframe');
|
||||
});
|
||||
|
||||
it('entfernt data:text/html-URLs in Links', () => {
|
||||
const html = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)');
|
||||
expect(html.toLowerCase()).not.toMatch(/href="data:text\/html/);
|
||||
});
|
||||
|
||||
it('entfernt vbscript:-URLs', () => {
|
||||
const html = renderMarkdown('<a href="vbscript:msgbox(1)">x</a>');
|
||||
expect(html.toLowerCase()).not.toContain('vbscript:');
|
||||
});
|
||||
|
||||
it('entfernt script-Tag innerhalb svg', () => {
|
||||
const html = renderMarkdown('<svg><script>alert(1)</script></svg>');
|
||||
expect(html.toLowerCase()).not.toContain('<script');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue