From 8395a4e55a87c9ffb6e89f00f79f03e9d15f7411 Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:29:16 +0200 Subject: [PATCH] feat: allowlist html sanitizer --- src/lib/sanitize.test.ts | 21 +++++++++++++++++++++ src/lib/sanitize.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/lib/sanitize.test.ts create mode 100644 src/lib/sanitize.ts diff --git a/src/lib/sanitize.test.ts b/src/lib/sanitize.test.ts new file mode 100644 index 0000000..e895657 --- /dev/null +++ b/src/lib/sanitize.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeHtml } from './sanitize'; + +describe('sanitizeHtml', () => { + it('keeps structural/prose tags', () => { + const html = '

T

a b c

y
z
'; + expect(sanitizeHtml(html)).toBe(html); + }); + it('strips script/style/iframe and event handlers', () => { + expect(sanitizeHtml('

a

')).toBe('

a

'); + }); + it('keeps safe links and images, strips javascript: urls', () => { + expect(sanitizeHtml('lmi')) + .toBe('lmi'); + }); + it('realistic Moodle fragment: div/span survive, class stripped', () => { + const input = '

Registres ESP32

Le registre GPIO_OUT contrĂ´le les sorties.

#define GPIO_OUT 0x3FF44004
'; + const expected = '

Registres ESP32

Le registre GPIO_OUT contrĂ´le les sorties.

#define GPIO_OUT 0x3FF44004
'; + expect(sanitizeHtml(input)).toBe(expected); + }); +}); diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts new file mode 100644 index 0000000..d6fe2bd --- /dev/null +++ b/src/lib/sanitize.ts @@ -0,0 +1,35 @@ +const ALLOWED: Record = { + 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]*?`, '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('`; + 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}>`; + }); +}