fix(security): harden remaining inline onclick handlers against XSS double-context

Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's ' reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.

Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.

Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
  into a single-quoted JS string (selectSession / killMuxSession). Now
  JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
  orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).

The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.

Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-06-19 15:26:42 +02:00
parent d8da1bd3ff
commit a8782b364f
6 changed files with 33 additions and 33 deletions
+4 -4
View File
@@ -2782,7 +2782,7 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -2797,9 +2797,9 @@ class CodemanApp {
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
${ultracodeBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
}
+1 -1
View File
@@ -273,7 +273,7 @@ class NotificationManager {
const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : '';
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
<div class="notif-item-header">
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
+2 -2
View File
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
let actions = '';
if (orchState === 'executing' || orchState === 'failed') {
if (phase.status === 'pending') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
}
if (phase.status === 'failed') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
}
}
+17 -17
View File
@@ -753,8 +753,8 @@ Object.assign(CodemanApp.prototype, {
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
html.push(`
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
title="Double-click to open tracking window">
<div class="subagent-header">
${agentIcon}
@@ -762,8 +762,8 @@ Object.assign(CodemanApp.prototype, {
${teammateBadge}
${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
${hasWindow ? '✕' : '⧉'}
</button>
</div>
@@ -810,7 +810,7 @@ Object.assign(CodemanApp.prototype, {
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${escapeHtml(a.tool)}</span>
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'tool_result') {
@@ -859,7 +859,7 @@ Object.assign(CodemanApp.prototype, {
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
View Full Transcript
</button>
</div>
@@ -1195,7 +1195,7 @@ Object.assign(CodemanApp.prototype, {
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
`;
header.insertAdjacentElement('afterend', parentDiv);
}
@@ -1687,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
@@ -2200,7 +2200,7 @@ Object.assign(CodemanApp.prototype, {
const fileName = path.split('/').pop();
html.push(`
<span class="project-insight-filepath"
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
`);
}
@@ -3099,7 +3099,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status streaming">streaming</span>
</div>
<div class="log-viewer-window-actions">
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
</div>
</div>
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
@@ -3275,14 +3275,14 @@ Object.assign(CodemanApp.prototype, {
<span class="size-badge">${sizeKB} KB</span>
</div>
<div class="image-popup-actions">
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
</div>
</div>
<div class="image-popup-body">
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
</div>
`;
@@ -3505,9 +3505,9 @@ Object.assign(CodemanApp.prototype, {
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
}
const sid = escapeHtml(muxSession.sessionId);
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
html += `
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
<div class="process-info">
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
@@ -3520,7 +3520,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
</div>
</div>
`;
@@ -3563,7 +3563,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
</div>
</div>
`;
+5 -5
View File
@@ -1385,11 +1385,11 @@ Object.assign(CodemanApp.prototype, {
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
</div>
@@ -1484,14 +1484,14 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
+4 -4
View File
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
const statusClass = agent?.status || 'idle';
agentItems.push(`
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
<span class="subagent-dropdown-status ${statusClass}"></span>
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">&times;</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</span>
</div>
`);
}
@@ -699,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
</div>`
: '';
@@ -720,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status ${agent.status}">${agent.status}</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
</div>
</div>
${parentHeader}