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
';
+ 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('lm
'))
+ .toBe('lm
');
+ });
+ 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]*?${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}>`;
+ });
+}