diff --git a/src/gateway/ui/chat.html b/src/gateway/ui/chat.html index 647c7ea..84ea3fa 100644 --- a/src/gateway/ui/chat.html +++ b/src/gateway/ui/chat.html @@ -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(); } diff --git a/src/gateway/ui/lib/markdown.d.ts b/src/gateway/ui/lib/markdown.d.ts new file mode 100644 index 0000000..ca34253 --- /dev/null +++ b/src/gateway/ui/lib/markdown.d.ts @@ -0,0 +1 @@ +export function renderSafeMarkdown(text: unknown): string; diff --git a/src/gateway/ui/lib/markdown.js b/src/gateway/ui/lib/markdown.js new file mode 100644 index 0000000..4d06811 --- /dev/null +++ b/src/gateway/ui/lib/markdown.js @@ -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 = `
${escapeHtml(raw)}
`; + } + + 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'); +} diff --git a/src/gateway/ui/lib/markdown.test.ts b/src/gateway/ui/lib/markdown.test.ts new file mode 100644 index 0000000..76778e4 --- /dev/null +++ b/src/gateway/ui/lib/markdown.test.ts @@ -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(''); + (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'); + expect(html).not.toContain('