fix(ui): sanitize markdown before chat DOM insertion
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export function renderSafeMarkdown(text: unknown): string;
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user