feat: allowlist html sanitizer
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, '"')}"`;
|
||||
}
|
||||
return `<${tag}${kept}>`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user