diff --git a/static/js/chat.js b/static/js/chat.js
index 4ba6f11..c34d6a0 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -844,7 +844,7 @@ import createResearchSynapse from './researchSynapse.js';
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
if (_charNameInit) roleLabel = _charNameInit;
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
- holder.innerHTML = `
${roleLabel} ${roleTs}
`;
+ holder.innerHTML = `${uiModule.esc(roleLabel)} ${roleTs}
`;
_applyModelColor(holder.querySelector('.role'), modelName);
holder.style.position = 'relative';
@@ -2002,7 +2002,7 @@ import createResearchSynapse from './researchSynapse.js';
const node = document.createElement('div')
node.className = 'agent-thread-node running';
const cmdHtml = cmd ? `${esc(cmd)}` : '';
- node.innerHTML = `${cmdHtml}
`;
+ node.innerHTML = `${cmdHtml}
`;
// Expand/collapse via delegated click handler (init at module bottom).
threadWrap.appendChild(node);
currentToolBubble = node;
@@ -2132,10 +2132,19 @@ import createResearchSynapse from './researchSynapse.js';
if (json.screenshot && currentToolBubble) {
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
if (contentEl) {
- const details = document.createElement('details');
- details.className = 'agent-tool-output';
- details.innerHTML = `Screenshot
`;
- contentEl.appendChild(details);
+ const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
+ if (screenshotSrc) {
+ const details = document.createElement('details');
+ details.className = 'agent-tool-output';
+ const summary = document.createElement('summary');
+ summary.textContent = 'Screenshot';
+ const img = document.createElement('img');
+ img.src = screenshotSrc;
+ img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
+ details.appendChild(summary);
+ details.appendChild(img);
+ contentEl.appendChild(details);
+ }
}
}
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
@@ -3271,7 +3280,7 @@ import createResearchSynapse from './researchSynapse.js';
var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
var roleLabel = _shortModel(meta && meta.model);
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
- holder.innerHTML = '' + roleLabel + ' ' + roleTs + '
';
+ holder.innerHTML = '' + uiModule.esc(roleLabel) + ' ' + roleTs + '
';
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
var bodyDiv = holder.querySelector('.body');
@@ -4073,7 +4082,7 @@ import createResearchSynapse from './researchSynapse.js';
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const agentModelLabel = _shortModel(agentMeta?.model);
- holder.innerHTML = `${agentModelLabel} ${roleTs}
`;
+ holder.innerHTML = `${uiModule.esc(agentModelLabel)} ${roleTs}
`;
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
box.appendChild(holder);
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index 8780864..63c5650 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -26,6 +26,29 @@ function _safeHref(url) {
return '#';
}
+export function safeToolScreenshotSrc(raw) {
+ const src = String(raw || '').trim();
+ if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
+ return src;
+ }
+ return '';
+}
+
+export function safeDisplayImageSrc(raw) {
+ const src = String(raw || '').trim();
+ if (!src) return '';
+ if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
+ return src;
+ }
+ try {
+ const parsed = new URL(src, window.location.origin);
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return parsed.href;
+ }
+ } catch (_) {}
+ return '';
+}
+
function _makeActionBtn(className, title, text, handler) {
const btn = document.createElement('button');
btn.className = className;
@@ -1058,12 +1081,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
const body = document.createElement('div');
body.className = 'body';
+ const safeImageUrl = safeDisplayImageSrc(imageUrl);
+ if (!safeImageUrl) {
+ body.textContent = '[Image unavailable]';
+ wrap.appendChild(body);
+ return wrap;
+ }
+
const img = document.createElement('img');
img.className = 'generated-image';
img.alt = prompt || 'Generated image';
img.title = prompt || 'Generated image';
- img.src = imageUrl;
- img.addEventListener('click', () => { window.open(img.src, '_blank'); });
+ img.src = safeImageUrl;
+ img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
body.appendChild(img);
if (prompt) {
@@ -1953,8 +1983,9 @@ export function addMessage(role, content, modelName, metadata) {
if (ev.output && ev.output.trim()) {
outHtml = `Output
${esc(ev.output)} `;
}
- if (ev.screenshot) {
- outHtml += `Screenshot
})
`;
+ const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
+ if (screenshotSrc) {
+ outHtml += `Screenshot
})
`;
}
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
// so it survives reload, matching the live stream.
@@ -2308,6 +2339,8 @@ const chatRenderer = {
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
+ safeToolScreenshotSrc,
+ safeDisplayImageSrc,
buildSourcesBox,
buildFindingsBox,
appendReportButton,
diff --git a/static/js/compare/selector.js b/static/js/compare/selector.js
index 2ad5d82..011d9cb 100644
--- a/static/js/compare/selector.js
+++ b/static/js/compare/selector.js
@@ -1195,7 +1195,7 @@ async function showModelSelector() {
const row = document.createElement('div');
row.className = 'compare-probe-row';
row.dataset.idx = 'p' + i;
- row.innerHTML = `▁▂▃${p.label || p.id}`;
+ row.innerHTML = `▁▂▃${escapeHtml(p.label || p.id)}`;
const waveEl = row.querySelector('.compare-probe-spinner');
const waveFrames = WAVE_FRAMES;
let wIdx = 0;
diff --git a/static/js/compare/stream.js b/static/js/compare/stream.js
index 15ec8ce..6117922 100644
--- a/static/js/compare/stream.js
+++ b/static/js/compare/stream.js
@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
-import { getModelCost } from '../chatRenderer.js';
+import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
+function _safeHttpHref(raw) {
+ try {
+ const parsed = new URL(String(raw || '').trim(), window.location.origin);
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return parsed.href;
+ }
+ } catch (_) {}
+ return '';
+}
+
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
const card = document.createElement('div');
card.className = 'compare-search-result';
const titleLink = document.createElement('a');
- titleLink.href = r.url || '#';
- titleLink.target = '_blank';
- titleLink.rel = 'noopener';
+ const safeUrl = _safeHttpHref(r.url);
+ if (safeUrl) {
+ titleLink.href = safeUrl;
+ titleLink.target = '_blank';
+ titleLink.rel = 'noopener noreferrer';
+ }
titleLink.className = 'search-result-title';
titleLink.textContent = r.title || 'Untitled';
card.appendChild(titleLink);
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const cmdHtml = cmd ? `${escapeHtml(cmd)}` : '';
const node = document.createElement('div');
node.className = 'agent-thread-node running';
- node.innerHTML = `${cmdHtml}
`;
+ node.innerHTML = `${cmdHtml}
`;
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
// Animate wave
const waveEl = node.querySelector('.agent-thread-wave');
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (json.image_url) {
// Stop image spinner and render generated image in pane
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
+ const safeImageUrl = safeDisplayImageSrc(json.image_url);
aiBody.innerHTML = '';
- const img = document.createElement('img');
- img.className = 'compare-gen-image';
- img.src = json.image_url;
- img.alt = json.image_prompt || '';
- img.title = json.image_prompt || '';
- img.addEventListener('click', () => window.open(img.src, '_blank'));
- aiBody.appendChild(img);
- if (json.image_prompt) {
- const caption = document.createElement('div');
- caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
- caption.textContent = json.image_prompt;
- aiBody.appendChild(caption);
+ if (!safeImageUrl) {
+ aiBody.textContent = '[Image unavailable]';
+ } else {
+ const img = document.createElement('img');
+ img.className = 'compare-gen-image';
+ img.src = safeImageUrl;
+ img.alt = json.image_prompt || '';
+ img.title = json.image_prompt || '';
+ img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
+ aiBody.appendChild(img);
+ if (json.image_prompt) {
+ const caption = document.createElement('div');
+ caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
+ caption.textContent = json.image_prompt;
+ aiBody.appendChild(caption);
+ }
+ // Show model name below image (hidden in blind mode until vote)
+ if (json.image_model && !state._blindMode) {
+ const modelLabel = document.createElement('div');
+ modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
+ modelLabel.textContent = json.image_model;
+ aiBody.appendChild(modelLabel);
+ }
+ aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
}
- // Show model name below image (hidden in blind mode until vote)
- if (json.image_model && !state._blindMode) {
- const modelLabel = document.createElement('div');
- modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
- modelLabel.textContent = json.image_model;
- aiBody.appendChild(modelLabel);
- }
- aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
} else if (currentToolBlock) {
// Stop wave animation
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
diff --git a/static/js/group.js b/static/js/group.js
index 122fd01..64f1859 100644
--- a/static/js/group.js
+++ b/static/js/group.js
@@ -676,7 +676,7 @@ function _createGroupBubble(model, box) {
// Role label — use character name if assigned, otherwise model name
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- wrap.innerHTML = `${roleLabel} ${roleTs}
`;
+ wrap.innerHTML = `${uiModule.esc(roleLabel)} ${roleTs}
`;
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
// Spinner — identical to chat.js line 3062
@@ -860,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
}
// Generated image
else if (json.type === 'generated_image' && json.url) {
- const img = document.createElement('img');
- img.src = json.url;
- img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
- img.loading = 'lazy';
- bodyEl.appendChild(img);
+ const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
+ if (safeImageUrl) {
+ const img = document.createElement('img');
+ img.src = safeImageUrl;
+ img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
+ img.loading = 'lazy';
+ bodyEl.appendChild(img);
+ }
}
// Error
else if (json.error) {
diff --git a/tests/test_chat_tool_screenshot_xss.py b/tests/test_chat_tool_screenshot_xss.py
new file mode 100644
index 0000000..9e26a2b
--- /dev/null
+++ b/tests/test_chat_tool_screenshot_xss.py
@@ -0,0 +1,83 @@
+"""Regression guards for agent-tool screenshot DOM sinks."""
+
+from pathlib import Path
+
+
+_REPO = Path(__file__).resolve().parent.parent
+
+
+def test_live_tool_screenshot_does_not_template_raw_sse_value():
+ chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
+
+ assert "safeToolScreenshotSrc(json.screenshot)" in chat
+ assert 'img.src = screenshotSrc' in chat
+ assert 'details.innerHTML = `Screenshot
${esc(toolLabel)}' in chat
+ assert '${toolLabel}' not in chat
+ assert '${escapeHtml(toolLabel)}' in compare
+ assert '${toolLabel}' not in compare
+
+
+def test_generated_image_urls_are_vetted_before_assignment_or_open():
+ renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
+ compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
+ group = (_REPO / "static" / "js" / "group.js").read_text(encoding="utf-8")
+
+ assert "export function safeDisplayImageSrc(raw)" in renderer
+ assert "safeDisplayImageSrc(imageUrl)" in renderer
+ assert "img.src = safeImageUrl" in renderer
+ assert "window.open(safeImageUrl, '_blank', 'noopener,noreferrer')" in renderer
+ assert "safeDisplayImageSrc," in renderer
+ assert "safeDisplayImageSrc(json.image_url)" in compare
+ assert "img.src = json.image_url" not in compare
+ assert "chatRenderer.safeDisplayImageSrc(json.url)" in group
+ assert "img.src = json.url" not in group
+
+
+def test_group_chat_role_labels_are_escaped_before_inner_html():
+ group = (_REPO / "static" / "js" / "group.js").read_text(encoding="utf-8")
+
+ assert '${uiModule.esc(roleLabel)}' in group
+ assert '
${roleLabel}' not in group
+
+
+def test_main_chat_role_labels_are_escaped_before_inner_html():
+ chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
+
+ assert '
${uiModule.esc(roleLabel)}' in chat
+ assert "'
' + uiModule.esc(roleLabel)" in chat
+ assert '
${uiModule.esc(agentModelLabel)}' in chat
+ assert '
${roleLabel}' not in chat
+ assert "'
' + roleLabel" not in chat
+ assert '
${agentModelLabel}' not in chat
+
+
+def test_compare_search_result_links_are_http_only():
+ compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
+
+ assert "function _safeHttpHref(raw)" in compare
+ assert "const safeUrl = _safeHttpHref(r.url);" in compare
+ assert "titleLink.href = safeUrl;" in compare
+ assert "titleLink.href = r.url || '#';" not in compare
+
+
+def test_compare_probe_provider_labels_are_escaped():
+ selector = (_REPO / "static" / "js" / "compare" / "selector.js").read_text(encoding="utf-8")
+
+ assert "${escapeHtml(p.label || p.id)}" in selector
+ assert "${p.label || p.id}" not in selector