Harden chat streaming DOM sinks (#2498)
This commit is contained in:
@@ -844,7 +844,7 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||||
if (_charNameInit) roleLabel = _charNameInit;
|
if (_charNameInit) roleLabel = _charNameInit;
|
||||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
holder.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||||
_applyModelColor(holder.querySelector('.role'), modelName);
|
_applyModelColor(holder.querySelector('.role'), modelName);
|
||||||
holder.style.position = 'relative';
|
holder.style.position = 'relative';
|
||||||
|
|
||||||
@@ -2002,7 +2002,7 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
const node = document.createElement('div')
|
const node = document.createElement('div')
|
||||||
node.className = 'agent-thread-node running';
|
node.className = 'agent-thread-node running';
|
||||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||||
// Expand/collapse via delegated click handler (init at module bottom).
|
// Expand/collapse via delegated click handler (init at module bottom).
|
||||||
threadWrap.appendChild(node);
|
threadWrap.appendChild(node);
|
||||||
currentToolBubble = node;
|
currentToolBubble = node;
|
||||||
@@ -2132,10 +2132,19 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
if (json.screenshot && currentToolBubble) {
|
if (json.screenshot && currentToolBubble) {
|
||||||
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
|
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const details = document.createElement('details');
|
const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
|
||||||
details.className = 'agent-tool-output';
|
if (screenshotSrc) {
|
||||||
details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" />`;
|
const details = document.createElement('details');
|
||||||
contentEl.appendChild(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.) ---
|
// --- 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 meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
|
||||||
var roleLabel = _shortModel(meta && meta.model);
|
var roleLabel = _shortModel(meta && meta.model);
|
||||||
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
holder.innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
||||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||||
|
|
||||||
var bodyDiv = holder.querySelector('.body');
|
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 roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||||
const agentModelLabel = _shortModel(agentMeta?.model);
|
const agentModelLabel = _shortModel(agentMeta?.model);
|
||||||
holder.innerHTML = `<div class="role">${agentModelLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
holder.innerHTML = `<div class="role">${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||||
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
|
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
|
||||||
box.appendChild(holder);
|
box.appendChild(holder);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ function _safeHref(url) {
|
|||||||
return '#';
|
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) {
|
function _makeActionBtn(className, title, text, handler) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = className;
|
btn.className = className;
|
||||||
@@ -1058,12 +1081,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'body';
|
body.className = 'body';
|
||||||
|
|
||||||
|
const safeImageUrl = safeDisplayImageSrc(imageUrl);
|
||||||
|
if (!safeImageUrl) {
|
||||||
|
body.textContent = '[Image unavailable]';
|
||||||
|
wrap.appendChild(body);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'generated-image';
|
img.className = 'generated-image';
|
||||||
img.alt = prompt || 'Generated image';
|
img.alt = prompt || 'Generated image';
|
||||||
img.title = prompt || 'Generated image';
|
img.title = prompt || 'Generated image';
|
||||||
img.src = imageUrl;
|
img.src = safeImageUrl;
|
||||||
img.addEventListener('click', () => { window.open(img.src, '_blank'); });
|
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
|
||||||
body.appendChild(img);
|
body.appendChild(img);
|
||||||
|
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
@@ -1953,8 +1983,9 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
if (ev.output && ev.output.trim()) {
|
if (ev.output && ev.output.trim()) {
|
||||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
|
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
|
||||||
}
|
}
|
||||||
if (ev.screenshot) {
|
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
|
||||||
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
if (screenshotSrc) {
|
||||||
|
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
||||||
}
|
}
|
||||||
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
|
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
|
||||||
// so it survives reload, matching the live stream.
|
// so it survives reload, matching the live stream.
|
||||||
@@ -2308,6 +2339,8 @@ const chatRenderer = {
|
|||||||
updateSessionCostUI,
|
updateSessionCostUI,
|
||||||
roleTimestamp,
|
roleTimestamp,
|
||||||
stripToolBlocks,
|
stripToolBlocks,
|
||||||
|
safeToolScreenshotSrc,
|
||||||
|
safeDisplayImageSrc,
|
||||||
buildSourcesBox,
|
buildSourcesBox,
|
||||||
buildFindingsBox,
|
buildFindingsBox,
|
||||||
appendReportButton,
|
appendReportButton,
|
||||||
|
|||||||
@@ -1195,7 +1195,7 @@ async function showModelSelector() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'compare-probe-row';
|
row.className = 'compare-probe-row';
|
||||||
row.dataset.idx = 'p' + i;
|
row.dataset.idx = 'p' + i;
|
||||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
|
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
|
||||||
const waveEl = row.querySelector('.compare-probe-spinner');
|
const waveEl = row.querySelector('.compare-probe-spinner');
|
||||||
const waveFrames = WAVE_FRAMES;
|
const waveFrames = WAVE_FRAMES;
|
||||||
let wIdx = 0;
|
let wIdx = 0;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// compare/stream.js — SSE streaming to panes
|
// compare/stream.js — SSE streaming to panes
|
||||||
import state from './state.js';
|
import state from './state.js';
|
||||||
import { addFinishBadge } from './vote.js';
|
import { addFinishBadge } from './vote.js';
|
||||||
import { getModelCost } from '../chatRenderer.js';
|
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
import spinnerModule from '../spinner.js';
|
import spinnerModule from '../spinner.js';
|
||||||
import uiModule from '../ui.js';
|
import uiModule from '../ui.js';
|
||||||
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
|
|||||||
|
|
||||||
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
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) ──
|
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||||
let _rerollPane = null;
|
let _rerollPane = null;
|
||||||
let _autoPreviewHtml = null;
|
let _autoPreviewHtml = null;
|
||||||
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'compare-search-result';
|
card.className = 'compare-search-result';
|
||||||
const titleLink = document.createElement('a');
|
const titleLink = document.createElement('a');
|
||||||
titleLink.href = r.url || '#';
|
const safeUrl = _safeHttpHref(r.url);
|
||||||
titleLink.target = '_blank';
|
if (safeUrl) {
|
||||||
titleLink.rel = 'noopener';
|
titleLink.href = safeUrl;
|
||||||
|
titleLink.target = '_blank';
|
||||||
|
titleLink.rel = 'noopener noreferrer';
|
||||||
|
}
|
||||||
titleLink.className = 'search-result-title';
|
titleLink.className = 'search-result-title';
|
||||||
titleLink.textContent = r.title || 'Untitled';
|
titleLink.textContent = r.title || 'Untitled';
|
||||||
card.appendChild(titleLink);
|
card.appendChild(titleLink);
|
||||||
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||||
const node = document.createElement('div');
|
const node = document.createElement('div');
|
||||||
node.className = 'agent-thread-node running';
|
node.className = 'agent-thread-node running';
|
||||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||||
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
||||||
// Animate wave
|
// Animate wave
|
||||||
const waveEl = node.querySelector('.agent-thread-wave');
|
const waveEl = node.querySelector('.agent-thread-wave');
|
||||||
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
if (json.image_url) {
|
if (json.image_url) {
|
||||||
// Stop image spinner and render generated image in pane
|
// Stop image spinner and render generated image in pane
|
||||||
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
||||||
|
const safeImageUrl = safeDisplayImageSrc(json.image_url);
|
||||||
aiBody.innerHTML = '';
|
aiBody.innerHTML = '';
|
||||||
const img = document.createElement('img');
|
if (!safeImageUrl) {
|
||||||
img.className = 'compare-gen-image';
|
aiBody.textContent = '[Image unavailable]';
|
||||||
img.src = json.image_url;
|
} else {
|
||||||
img.alt = json.image_prompt || '';
|
const img = document.createElement('img');
|
||||||
img.title = json.image_prompt || '';
|
img.className = 'compare-gen-image';
|
||||||
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
img.src = safeImageUrl;
|
||||||
aiBody.appendChild(img);
|
img.alt = json.image_prompt || '';
|
||||||
if (json.image_prompt) {
|
img.title = json.image_prompt || '';
|
||||||
const caption = document.createElement('div');
|
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
|
||||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
aiBody.appendChild(img);
|
||||||
caption.textContent = json.image_prompt;
|
if (json.image_prompt) {
|
||||||
aiBody.appendChild(caption);
|
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) {
|
} else if (currentToolBlock) {
|
||||||
// Stop wave animation
|
// Stop wave animation
|
||||||
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
|
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
|
||||||
|
|||||||
@@ -676,7 +676,7 @@ function _createGroupBubble(model, box) {
|
|||||||
// Role label — use character name if assigned, otherwise model name
|
// Role label — use character name if assigned, otherwise model name
|
||||||
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
|
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
|
||||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
wrap.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||||
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
|
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
|
||||||
|
|
||||||
// Spinner — identical to chat.js line 3062
|
// Spinner — identical to chat.js line 3062
|
||||||
@@ -860,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
|
|||||||
}
|
}
|
||||||
// Generated image
|
// Generated image
|
||||||
else if (json.type === 'generated_image' && json.url) {
|
else if (json.type === 'generated_image' && json.url) {
|
||||||
const img = document.createElement('img');
|
const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
|
||||||
img.src = json.url;
|
if (safeImageUrl) {
|
||||||
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
const img = document.createElement('img');
|
||||||
img.loading = 'lazy';
|
img.src = safeImageUrl;
|
||||||
bodyEl.appendChild(img);
|
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
bodyEl.appendChild(img);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Error
|
// Error
|
||||||
else if (json.error) {
|
else if (json.error) {
|
||||||
|
|||||||
83
tests/test_chat_tool_screenshot_xss.py
Normal file
83
tests/test_chat_tool_screenshot_xss.py
Normal file
@@ -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 = `<summary>Screenshot</summary><img src="${json.screenshot}"' not in chat
|
||||||
|
|
||||||
|
|
||||||
|
def test_restored_tool_screenshot_uses_raster_data_url_whitelist():
|
||||||
|
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "export function safeToolScreenshotSrc(raw)" in renderer
|
||||||
|
assert "(?:png|jpe?g|gif|webp)" in renderer
|
||||||
|
assert "safeToolScreenshotSrc(ev.screenshot)" in renderer
|
||||||
|
assert 'src="${esc(ev.screenshot)}"' not in renderer
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_tool_labels_are_escaped_before_inner_html():
|
||||||
|
chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||||
|
compare = (_REPO / "static" / "js" / "compare" / "stream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert '<span class="agent-thread-tool">${esc(toolLabel)}</span>' in chat
|
||||||
|
assert '<span class="agent-thread-tool">${toolLabel}</span>' not in chat
|
||||||
|
assert '<span class="agent-thread-tool">${escapeHtml(toolLabel)}</span>' in compare
|
||||||
|
assert '<span class="agent-thread-tool">${toolLabel}</span>' 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 '<div class="role">${uiModule.esc(roleLabel)}' in group
|
||||||
|
assert '<div class="role">${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 '<div class="role">${uiModule.esc(roleLabel)}' in chat
|
||||||
|
assert "'<div class=\"role\">' + uiModule.esc(roleLabel)" in chat
|
||||||
|
assert '<div class="role">${uiModule.esc(agentModelLabel)}' in chat
|
||||||
|
assert '<div class="role">${roleLabel}' not in chat
|
||||||
|
assert "'<div class=\"role\">' + roleLabel" not in chat
|
||||||
|
assert '<div class="role">${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
|
||||||
Reference in New Issue
Block a user