feat: allowlist html sanitizer

This commit is contained in:
electron-rare
2026-06-10 21:29:16 +02:00
parent 22d34f1df4
commit 8395a4e55a
2 changed files with 56 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { sanitizeHtml } from './sanitize';
describe('sanitizeHtml', () => {
it('keeps structural/prose tags', () => {
const html = '<h1>T</h1><p>a <strong>b</strong> <em>c</em></p><ul><li>x</li></ul><pre><code>y</code></pre><table><tr><td>z</td></tr></table>';
expect(sanitizeHtml(html)).toBe(html);
});
it('strips script/style/iframe and event handlers', () => {
expect(sanitizeHtml('<p onclick="x()">a</p><script>evil()</script><iframe src="x"></iframe>')).toBe('<p>a</p>');
});
it('keeps safe links and images, strips javascript: urls', () => {
expect(sanitizeHtml('<a href="https://x.y">l</a><a href="javascript:e()">m</a><img src="https://x.y/i.png" alt="i">'))
.toBe('<a href="https://x.y">l</a><a>m</a><img src="https://x.y/i.png" alt="i">');
});
it('realistic Moodle fragment: div/span survive, class stripped', () => {
const input = '<div class="no-overflow"><h3>Registres ESP32</h3><p>Le registre <span class="highlight">GPIO_OUT</span> contrôle les sorties.</p><pre><code>#define GPIO_OUT 0x3FF44004</code></pre></div>';
const expected = '<div><h3>Registres ESP32</h3><p>Le registre <span>GPIO_OUT</span> contrôle les sorties.</p><pre><code>#define GPIO_OUT 0x3FF44004</code></pre></div>';
expect(sanitizeHtml(input)).toBe(expected);
});
});
+35
View File
@@ -0,0 +1,35 @@
const ALLOWED: Record<string, string[]> = {
h1: [], h2: [], h3: [], h4: [], p: [], br: [], hr: [],
strong: [], b: [], em: [], i: [], u: [], s: [], mark: [], small: [], sub: [], sup: [],
ul: [], ol: ['start'], li: [], blockquote: [], pre: [], code: [],
table: [], thead: [], tbody: [], tr: [], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'],
a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'],
figure: [], figcaption: [], div: [], span: [], dl: [], dt: [], dd: [],
};
const DROP_WITH_CONTENT = new Set(['script', 'style', 'iframe', 'object', 'embed', 'noscript']);
const SAFE_URL = /^(https?:|\/|#|mailto:)/i;
export function sanitizeHtml(html: string): string {
let out = html;
for (const tag of DROP_WITH_CONTENT) {
out = out.replace(new RegExp(`<${tag}[\\s\\S]*?</${tag}>`, 'gi'), '');
out = out.replace(new RegExp(`<${tag}[^>]*/?>`, 'gi'), '');
}
return out.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^<>]*?)?)(\/?)>/g, (m, name, attrs, selfClose) => {
const tag = name.toLowerCase();
if (!(tag in ALLOWED)) return '';
if (m.startsWith('</')) return `</${tag}>`;
const allowed = ALLOWED[tag];
let kept = '';
const attrRe = /([a-zA-Z-]+)\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/g;
let a: RegExpExecArray | null;
while ((a = attrRe.exec(attrs)) !== null) {
const attr = a[1].toLowerCase();
const val = a[3] ?? a[4] ?? a[5] ?? '';
if (!allowed.includes(attr)) continue;
if ((attr === 'href' || attr === 'src') && !SAFE_URL.test(val)) continue;
kept += ` ${attr}="${val.replace(/"/g, '&quot;')}"`;
}
return `<${tag}${kept}>`;
});
}