port(pi-mono): escape session metadata + image data in HTML export (refs 7617c1ad9, 57787b655)

Pi-mono Tier 0 #1 (security) — sf-driven port.

Two upstream security fixes (pi-mono PR #3819, #3883) that escape
user-controlled session content before embedding in HTML exports.
Crafted session content (image mime types, image data, model IDs,
tool names, entry IDs) could otherwise inject markup at the export
boundary.

What sf changed in
packages/pi-coding-agent/src/core/export-html/template.js:

- Image tags: escape `mimeType` and `data` attributes for both
  tool-result and user-message image renders (PR #3819).
- Session metadata: escape `msg.toolName`, `msg.role`, `entry.modelId`,
  `entry.thinkingLevel`, `entry.type`, `entry.id`, and
  `globalStats.models` (PR #3883).
- DOM id construction: renamed `entryId` → `entryDomId` and escape
  `entry.id` to prevent attribute-breakout from a crafted id.

The existing `escapeHtml()` helper was used at every site; no new
helper introduced. Type-check passes.

Co-Authored-By: sf v2.75.1 (session 150fe2c1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-29 14:20:23 +02:00
parent 7c487bb60e
commit 701ec8fb88

View file

@ -634,13 +634,13 @@
if (toolCall) { if (toolCall) {
return labelHtml + `<span class="tree-role-tool">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`; return labelHtml + `<span class="tree-role-tool">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`;
} }
return labelHtml + `<span class="tree-role-tool">[${msg.toolName || 'tool'}]</span>`; return labelHtml + `<span class="tree-role-tool">[${escapeHtml(msg.toolName || 'tool')}]</span>`;
} }
if (msg.role === 'bashExecution') { if (msg.role === 'bashExecution') {
const cmd = truncate(normalize(msg.command || '')); const cmd = truncate(normalize(msg.command || ''));
return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`; return labelHtml + `<span class="tree-role-tool">[bash]:</span> ${escapeHtml(cmd)}`;
} }
return labelHtml + `<span class="tree-muted">[${msg.role}]</span>`; return labelHtml + `<span class="tree-muted">[${escapeHtml(msg.role)}]</span>`;
} }
case 'compaction': case 'compaction':
return labelHtml + `<span class="tree-compaction">[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]</span>`; return labelHtml + `<span class="tree-compaction">[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]</span>`;
@ -653,11 +653,11 @@
return labelHtml + `<span class="tree-custom">[${escapeHtml(entry.customType)}]:</span> ${escapeHtml(truncate(normalize(content)))}`; return labelHtml + `<span class="tree-custom">[${escapeHtml(entry.customType)}]:</span> ${escapeHtml(truncate(normalize(content)))}`;
} }
case 'model_change': case 'model_change':
return labelHtml + `<span class="tree-muted">[model: ${entry.modelId}]</span>`; return labelHtml + `<span class="tree-muted">[model: ${escapeHtml(entry.modelId)}]</span>`;
case 'thinking_level_change': case 'thinking_level_change':
return labelHtml + `<span class="tree-muted">[thinking: ${entry.thinkingLevel}]</span>`; return labelHtml + `<span class="tree-muted">[thinking: ${escapeHtml(entry.thinkingLevel)}]</span>`;
default: default:
return labelHtml + `<span class="tree-muted">[${entry.type}]</span>`; return labelHtml + `<span class="tree-muted">[${escapeHtml(entry.type)}]</span>`;
} }
} }
@ -880,7 +880,7 @@
const images = getResultImages(); const images = getResultImages();
if (images.length === 0) return ''; if (images.length === 0) return '';
return '<div class="tool-images">' + return '<div class="tool-images">' +
images.map(img => `<img src="data:${img.mimeType};base64,${img.data}" class="tool-image" />`).join('') + images.map(img => `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="tool-image" />`).join('') +
'</div>'; '</div>';
}; };
@ -1105,7 +1105,7 @@
* Render the copy-link button HTML for a message. * Render the copy-link button HTML for a message.
*/ */
function renderCopyLinkButton(entryId) { function renderCopyLinkButton(entryId) {
return `<button class="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message"> return `<button class="copy-link-btn" data-entry-id="${escapeHtml(entryId)}" title="Copy link to this message">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
@ -1116,14 +1116,14 @@
function renderEntry(entry) { function renderEntry(entry) {
const ts = formatTimestamp(entry.timestamp); const ts = formatTimestamp(entry.timestamp);
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : ''; const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
const entryId = `entry-${entry.id}`; const entryDomId = `entry-${escapeHtml(entry.id)}`;
const copyBtnHtml = renderCopyLinkButton(entry.id); const copyBtnHtml = renderCopyLinkButton(entry.id);
if (entry.type === 'message') { if (entry.type === 'message') {
const msg = entry.message; const msg = entry.message;
if (msg.role === 'user') { if (msg.role === 'user') {
let html = `<div class="user-message" id="${entryId}">${copyBtnHtml}${tsHtml}`; let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content; const content = msg.content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
@ -1131,7 +1131,7 @@
if (images.length > 0) { if (images.length > 0) {
html += '<div class="message-images">'; html += '<div class="message-images">';
for (const img of images) { for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" class="message-image" />`; html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`;
} }
html += '</div>'; html += '</div>';
} }
@ -1147,7 +1147,7 @@
} }
if (msg.role === 'assistant') { if (msg.role === 'assistant') {
let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`; let html = `<div class="assistant-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
for (const block of msg.content) { for (const block of msg.content) {
if (block.type === 'text' && block.text.trim()) { if (block.type === 'text' && block.text.trim()) {
@ -1178,7 +1178,7 @@
if (msg.role === 'bashExecution') { if (msg.role === 'bashExecution') {
const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null); const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null);
let html = `<div class="tool-execution ${isError ? 'error' : 'success'}" id="${entryId}">${tsHtml}`; let html = `<div class="tool-execution ${isError ? 'error' : 'success'}" id="${entryDomId}">${tsHtml}`;
html += `<div class="tool-command">$ ${escapeHtml(msg.command)}</div>`; html += `<div class="tool-command">$ ${escapeHtml(msg.command)}</div>`;
if (msg.output) html += formatExpandableOutput(msg.output, 10); if (msg.output) html += formatExpandableOutput(msg.output, 10);
if (msg.cancelled) { if (msg.cancelled) {
@ -1194,11 +1194,11 @@
} }
if (entry.type === 'model_change') { if (entry.type === 'model_change') {
return `<div class="model-change" id="${entryId}">${tsHtml}Switched to model: <span class="model-name">${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span></div>`; return `<div class="model-change" id="${entryDomId}">${tsHtml}Switched to model: <span class="model-name">${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span></div>`;
} }
if (entry.type === 'compaction') { if (entry.type === 'compaction') {
return `<div class="compaction" id="${entryId}" onclick="this.classList.toggle('expanded')"> return `<div class="compaction" id="${entryDomId}" onclick="this.classList.toggle('expanded')">
<div class="compaction-label">[compaction]</div> <div class="compaction-label">[compaction]</div>
<div class="compaction-collapsed">Compacted from ${entry.tokensBefore.toLocaleString()} tokens</div> <div class="compaction-collapsed">Compacted from ${entry.tokensBefore.toLocaleString()} tokens</div>
<div class="compaction-content"><strong>Compacted from ${entry.tokensBefore.toLocaleString()} tokens</strong>\n\n${escapeHtml(entry.summary)}</div> <div class="compaction-content"><strong>Compacted from ${entry.tokensBefore.toLocaleString()} tokens</strong>\n\n${escapeHtml(entry.summary)}</div>
@ -1206,14 +1206,14 @@
} }
if (entry.type === 'branch_summary') { if (entry.type === 'branch_summary') {
return `<div class="branch-summary" id="${entryId}">${tsHtml} return `<div class="branch-summary" id="${entryDomId}">${tsHtml}
<div class="branch-summary-header">Branch Summary</div> <div class="branch-summary-header">Branch Summary</div>
<div class="markdown-content">${safeMarkedParse(entry.summary)}</div> <div class="markdown-content">${safeMarkedParse(entry.summary)}</div>
</div>`; </div>`;
} }
if (entry.type === 'custom_message' && entry.display) { if (entry.type === 'custom_message' && entry.display) {
return `<div class="hook-message" id="${entryId}">${tsHtml} return `<div class="hook-message" id="${entryDomId}">${tsHtml}
<div class="hook-type">[${escapeHtml(entry.customType)}]</div> <div class="hook-type">[${escapeHtml(entry.customType)}]</div>
<div class="markdown-content">${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}</div> <div class="markdown-content">${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}</div>
</div>`; </div>`;
@ -1295,7 +1295,7 @@
</div> </div>
<div class="header-info"> <div class="header-info">
<div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div> <div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>
<div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(', ') || 'unknown'}</span></div> <div class="info-item"><span class="info-label">Models:</span><span class="info-value">${escapeHtml(globalStats.models.join(', ') || 'unknown')}</span></div>
<div class="info-item"><span class="info-label">Messages:</span><span class="info-value">${msgParts.join(', ') || '0'}</span></div> <div class="info-item"><span class="info-label">Messages:</span><span class="info-value">${msgParts.join(', ') || '0'}</span></div>
<div class="info-item"><span class="info-label">Tool Calls:</span><span class="info-value">${globalStats.toolCalls}</span></div> <div class="info-item"><span class="info-label">Tool Calls:</span><span class="info-value">${globalStats.toolCalls}</span></div>
<div class="info-item"><span class="info-label">Tokens:</span><span class="info-value">${tokenParts.join(' ') || '0'}</span></div> <div class="info-item"><span class="info-label">Tokens:</span><span class="info-value">${tokenParts.join(' ') || '0'}</span></div>