fix(ui): sanitize markdown before chat DOM insertion

This commit is contained in:
William Valentin
2026-02-15 21:44:32 -08:00
parent 157e99ccb5
commit 22959ea3aa
5 changed files with 183 additions and 16 deletions
+51 -2
View File
@@ -42,6 +42,55 @@
let pendingAttachments = [];
function sanitizeHtml(html) {
const root = document.createElement('div');
root.innerHTML = html;
const blockedTags = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base',
'form', 'input', 'button', 'textarea', 'select',
]);
const nodes = root.querySelectorAll('*');
for (const el of nodes) {
const tag = el.tagName.toLowerCase();
if (blockedTags.has(tag)) {
el.remove();
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (name.startsWith('on') || name === 'style') {
el.removeAttribute(attr.name);
continue;
}
if (name === 'href' || name === 'src' || name === 'xlink:href') {
const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/g, '').toLowerCase();
if (
normalized.startsWith('javascript:')
|| normalized.startsWith('vbscript:')
|| normalized.startsWith('data:text/html')
) {
el.removeAttribute(attr.name);
}
}
}
if (tag === 'a' && el.getAttribute('target') === '_blank') {
el.setAttribute('rel', 'noopener noreferrer');
}
}
return root.innerHTML;
}
function renderSafeMarkdown(text) {
const html = marked.parse(String(text ?? ''));
return sanitizeHtml(html);
}
function isSupportedImageMime(mimeType) {
return mimeType === 'image/jpeg'
|| mimeType === 'image/png'
@@ -280,7 +329,7 @@
// Render final response as markdown inside the assistant area
const responseDiv = document.createElement('div');
responseDiv.className = 'message assistant';
responseDiv.innerHTML = marked.parse(data.content || '');
responseDiv.innerHTML = renderSafeMarkdown(data.content || '');
area.appendChild(responseDiv);
}
@@ -326,7 +375,7 @@
function appendAssistantMessage(content) {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = marked.parse(content);
div.innerHTML = renderSafeMarkdown(content);
messagesEl.appendChild(div);
scrollToBottom();
}
+1
View File
@@ -0,0 +1 @@
export function renderSafeMarkdown(text: unknown): string;
+92
View File
@@ -0,0 +1,92 @@
/**
* Render markdown and sanitize the resulting HTML before DOM insertion.
* This protects the web UI from untrusted model/tool output.
*/
export function renderSafeMarkdown(text) {
const raw = String(text ?? '');
let html = '';
const md = globalThis.marked;
try {
if (md) {
html = md.parse(raw);
}
} catch {
// Fall through to plain-text fallback
}
if (!html) {
html = `<p>${escapeHtml(raw)}</p>`;
}
return sanitizeHtml(html);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function sanitizeHtml(html) {
const root = document.createElement('div');
root.innerHTML = html;
// Drop elements that can execute script or embed active content.
const blockedTags = new Set([
'script',
'style',
'iframe',
'object',
'embed',
'link',
'meta',
'base',
'form',
'input',
'button',
'textarea',
'select',
]);
const nodes = root.querySelectorAll('*');
for (const el of nodes) {
const tag = el.tagName.toLowerCase();
if (blockedTags.has(tag)) {
el.remove();
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
// Remove event handlers and inline styles.
if (name.startsWith('on') || name === 'style') {
el.removeAttribute(attr.name);
continue;
}
// Strip scriptable URL schemes.
if (name === 'href' || name === 'src' || name === 'xlink:href') {
if (isUnsafeUrl(value)) {
el.removeAttribute(attr.name);
}
}
}
if (tag === 'a' && el.getAttribute('target') === '_blank') {
const rel = (el.getAttribute('rel') ?? '').toLowerCase();
if (!rel.includes('noopener') || !rel.includes('noreferrer')) {
el.setAttribute('rel', 'noopener noreferrer');
}
}
}
return root.innerHTML;
}
function isUnsafeUrl(value) {
const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/g, '').toLowerCase();
return normalized.startsWith('javascript:') || normalized.startsWith('vbscript:') || normalized.startsWith('data:text/html');
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { parseHTML } from 'linkedom';
import { renderSafeMarkdown } from './markdown.js';
describe('renderSafeMarkdown', () => {
const { document, window } = parseHTML('<!doctype html><html><body></body></html>');
(globalThis as { document?: unknown }).document = document;
(globalThis as { window?: unknown }).window = window;
(globalThis as { marked?: { parse: (text: string) => string } }).marked = {
parse: (text: string) => text,
};
it('removes script tags', () => {
const html = renderSafeMarkdown('hello<script>alert(1)</script>');
expect(html).not.toContain('<script');
expect(html).toContain('hello');
});
it('removes inline event handlers', () => {
const html = renderSafeMarkdown('<img src="https://example.com/x.png" onerror="alert(1)">');
expect(html).toContain('<img');
expect(html).not.toContain('onerror=');
});
it('removes javascript: href values', () => {
const html = renderSafeMarkdown('<a href="javascript:alert(1)">click</a>');
expect(html).toContain('<a');
expect(html).not.toContain('javascript:');
});
it('adds rel noopener noreferrer for target=_blank links', () => {
const html = renderSafeMarkdown('<a href="https://example.com" target="_blank">x</a>');
expect(html).toContain('rel="noopener noreferrer"');
});
});
+4 -14
View File
@@ -5,7 +5,8 @@
* markdown-rendered responses, slash commands, and web search.
*/
/* global marked, hljs */
/* global hljs */
import { renderSafeMarkdown } from '../lib/markdown.js';
let _currentSession = null;
let _sending = false;
@@ -33,17 +34,6 @@ function escapeHtml(text) {
return div.innerHTML;
}
function renderMarkdown(text) {
try {
if (typeof marked !== 'undefined') {
return marked.parse(text);
}
} catch {
// Fall through to plain text
}
return `<p>${escapeHtml(text)}</p>`;
}
function highlightCode() {
if (typeof hljs !== 'undefined') {
document.querySelectorAll('.chat-messages pre code').forEach(block => {
@@ -60,7 +50,7 @@ function createMessageEl(role, content) {
div.className = `message ${role}`;
if (role === 'assistant' || role === 'system') {
div.innerHTML = renderMarkdown(content);
div.innerHTML = renderSafeMarkdown(content);
setTimeout(highlightCode, 0);
} else {
div.textContent = content;
@@ -607,7 +597,7 @@ async function sendMessage(client, overrideText) {
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderMarkdown(content);
placeholder.innerHTML = renderSafeMarkdown(content);
placeholder.appendChild(createMessageActions('assistant'));
setTimeout(highlightCode, 0);
} catch (err) {