fix(ui): sanitize markdown before chat DOM insertion
This commit is contained in:
@@ -42,6 +42,55 @@
|
|||||||
|
|
||||||
let pendingAttachments = [];
|
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) {
|
function isSupportedImageMime(mimeType) {
|
||||||
return mimeType === 'image/jpeg'
|
return mimeType === 'image/jpeg'
|
||||||
|| mimeType === 'image/png'
|
|| mimeType === 'image/png'
|
||||||
@@ -280,7 +329,7 @@
|
|||||||
// Render final response as markdown inside the assistant area
|
// Render final response as markdown inside the assistant area
|
||||||
const responseDiv = document.createElement('div');
|
const responseDiv = document.createElement('div');
|
||||||
responseDiv.className = 'message assistant';
|
responseDiv.className = 'message assistant';
|
||||||
responseDiv.innerHTML = marked.parse(data.content || '');
|
responseDiv.innerHTML = renderSafeMarkdown(data.content || '');
|
||||||
area.appendChild(responseDiv);
|
area.appendChild(responseDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +375,7 @@
|
|||||||
function appendAssistantMessage(content) {
|
function appendAssistantMessage(content) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'message assistant';
|
div.className = 'message assistant';
|
||||||
div.innerHTML = marked.parse(content);
|
div.innerHTML = renderSafeMarkdown(content);
|
||||||
messagesEl.appendChild(div);
|
messagesEl.appendChild(div);
|
||||||
scrollToBottom();
|
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.
|
* markdown-rendered responses, slash commands, and web search.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* global marked, hljs */
|
/* global hljs */
|
||||||
|
import { renderSafeMarkdown } from '../lib/markdown.js';
|
||||||
|
|
||||||
let _currentSession = null;
|
let _currentSession = null;
|
||||||
let _sending = false;
|
let _sending = false;
|
||||||
@@ -33,17 +34,6 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
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() {
|
function highlightCode() {
|
||||||
if (typeof hljs !== 'undefined') {
|
if (typeof hljs !== 'undefined') {
|
||||||
document.querySelectorAll('.chat-messages pre code').forEach(block => {
|
document.querySelectorAll('.chat-messages pre code').forEach(block => {
|
||||||
@@ -60,7 +50,7 @@ function createMessageEl(role, content) {
|
|||||||
div.className = `message ${role}`;
|
div.className = `message ${role}`;
|
||||||
|
|
||||||
if (role === 'assistant' || role === 'system') {
|
if (role === 'assistant' || role === 'system') {
|
||||||
div.innerHTML = renderMarkdown(content);
|
div.innerHTML = renderSafeMarkdown(content);
|
||||||
setTimeout(highlightCode, 0);
|
setTimeout(highlightCode, 0);
|
||||||
} else {
|
} else {
|
||||||
div.textContent = content;
|
div.textContent = content;
|
||||||
@@ -607,7 +597,7 @@ async function sendMessage(client, overrideText) {
|
|||||||
// Replace placeholder with actual response
|
// Replace placeholder with actual response
|
||||||
placeholder.classList.remove('streaming-cursor');
|
placeholder.classList.remove('streaming-cursor');
|
||||||
const content = done?.content ?? done?.text ?? '(no response)';
|
const content = done?.content ?? done?.text ?? '(no response)';
|
||||||
placeholder.innerHTML = renderMarkdown(content);
|
placeholder.innerHTML = renderSafeMarkdown(content);
|
||||||
placeholder.appendChild(createMessageActions('assistant'));
|
placeholder.appendChild(createMessageActions('assistant'));
|
||||||
setTimeout(highlightCode, 0);
|
setTimeout(highlightCode, 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user