From 42d413a02c0cd400a26775b4c28609f869ec950d Mon Sep 17 00:00:00 2001 From: Sage Hart Date: Sat, 11 Jul 2026 19:38:36 -0500 Subject: [PATCH] fix(dashboard): eliminate XSS via DOM-safe rendering Replace innerHTML rendering of API data with createElement/textContent, whitelist task states for KPI aggregation and CSS classes, and sanitize event type class names to close CodeQL alerts #1-#3. --- src/dashboard/public/index.html | 418 +++++++++++++++++--------------- 1 file changed, 228 insertions(+), 190 deletions(-) diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index ea85d2b..9abf9d2 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -799,14 +799,41 @@ "use strict"; const $ = (id) => document.getElementById(id); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - const esc = (s) => - String(s ?? "").replace( - /[&<>"']/g, - (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], - ); - const stateLabel = (s) => s.replace(/_/g, " "); + const stateLabel = (s) => String(s ?? "").replace(/_/g, " "); const MAX_EVENTS = 200; + const KNOWN_TASK_STATES = new Set([ + "pending", + "assigned", + "running", + "awaiting_approval", + "succeeded", + "failed", + "cancelled", + "unknown", + ]); + + function safeTaskState(state) { + const s = String(state ?? "unknown"); + return KNOWN_TASK_STATES.has(s) ? s : "unknown"; + } + + function safeEventTypeClass(type) { + const slug = String(type ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_"); + return "ev t-" + slug; + } + + function clearEl(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + function textEl(tag, text, className) { + const el = document.createElement(tag); + if (className) el.className = className; + el.textContent = String(text ?? ""); + return el; + } + const AVATAR_COLORS = [ "#5b9bff", "#7c5bff", @@ -833,11 +860,19 @@ const sock = new WebSocket(wsUrl()); sock.onopen = () => { $("conn").className = "badge-pill ok"; - $("conn").innerHTML = 'connected'; + clearEl($("conn")); + const dot = document.createElement("span"); + dot.className = "dot"; + $("conn").appendChild(dot); + $("conn").appendChild(document.createTextNode("connected")); }; sock.onclose = () => { $("conn").className = "badge-pill bad"; - $("conn").innerHTML = 'reconnecting…'; + clearEl($("conn")); + const dot = document.createElement("span"); + dot.className = "dot"; + $("conn").appendChild(dot); + $("conn").appendChild(document.createTextNode("reconnecting…")); setTimeout(connect, 1500); }; sock.onerror = () => { @@ -902,12 +937,15 @@ } function renderKpis() { - const byState = {}; - for (const t of pendingTasks) byState[t.state] = (byState[t.state] || 0) + 1; + const byState = new Map(); + for (const t of pendingTasks) { + const st = safeTaskState(t.state); + byState.set(st, (byState.get(st) || 0) + 1); + } $("k-tasks").textContent = pendingTasks.length; - $("k-running").textContent = (byState["running"] || 0) + (byState["assigned"] || 0); - $("k-awaiting").textContent = byState["awaiting_approval"] || 0; - $("k-succeeded").textContent = byState["succeeded"] || 0; + $("k-running").textContent = (byState.get("running") || 0) + (byState.get("assigned") || 0); + $("k-awaiting").textContent = byState.get("awaiting_approval") || 0; + $("k-succeeded").textContent = byState.get("succeeded") || 0; $("k-approvals").textContent = pendingApprovals.length; $("k-agents").textContent = agents.length; $("agentCount").textContent = agents.length; @@ -918,166 +956,170 @@ function renderAgentSelect() { const sel = $("f-agent"); const cur = sel.value; - const opts = - '' + - agents - .map( - (a) => - '", - ) - .join(""); - if (sel.innerHTML !== opts) sel.innerHTML = opts; - sel.value = cur; + clearEl(sel); + const autoOpt = document.createElement("option"); + autoOpt.value = ""; + autoOpt.textContent = "(auto)"; + sel.appendChild(autoOpt); + for (const a of agents) { + const opt = document.createElement("option"); + opt.value = String(a.id ?? ""); + opt.textContent = String(a.name ?? "") + " · " + String(a.role ?? ""); + sel.appendChild(opt); + } + if ([...sel.options].some((o) => o.value === cur)) sel.value = cur; } function busyAgentIds() { const set = new Set(); for (const t of pendingTasks) { - if (t.assignedTo && !TERMINAL.includes(t.state)) set.add(t.assignedTo); + if (t.assignedTo && !TERMINAL.includes(safeTaskState(t.state))) set.add(t.assignedTo); } return set; } function renderAgents() { + const root = $("agents"); if (!agents.length) { - $("agents").innerHTML = '
no agents registered
'; + renderEmpty(root, "no agents registered"); return; } + clearEl(root); const busy = busyAgentIds(); - $("agents").innerHTML = agents - .map((a, i) => { - const initial = esc((a.name || a.id || "?").trim().charAt(0).toUpperCase()); - const isBusy = busy.has(a.id); - return ( - '
' + - '
' + - '
' + - initial + - "
" + - '
' + - esc(a.name) + - "
" + - '
' + - esc(a.role) + - "
" + - '
' + - (isBusy ? "busy" : "idle") + - "
" + - "
" + - '
' + - (a.tools || []).map((t) => '' + esc(t) + "").join("") + - "
" + - "
" - ); - }) - .join(""); + agents.forEach((a, i) => { + const card = document.createElement("div"); + card.className = "agent" + (busy.has(a.id) ? " busy" : ""); + const head = document.createElement("div"); + head.className = "head"; + const avatar = document.createElement("div"); + avatar.className = "avatar"; + avatar.style.background = AVATAR_COLORS[i % AVATAR_COLORS.length]; + avatar.textContent = String(a.name || a.id || "?") + .trim() + .charAt(0) + .toUpperCase(); + const meta = document.createElement("div"); + meta.appendChild(textEl("div", a.name, "name")); + meta.appendChild(textEl("div", a.role, "role")); + const status = document.createElement("div"); + status.className = "status"; + const statusDot = document.createElement("span"); + statusDot.className = "status-dot"; + status.appendChild(statusDot); + status.appendChild(document.createTextNode(busy.has(a.id) ? "busy" : "idle")); + head.appendChild(avatar); + head.appendChild(meta); + head.appendChild(status); + const chips = document.createElement("div"); + chips.className = "chips"; + for (const tool of a.tools || []) { + chips.appendChild(textEl("span", tool, "chip")); + } + card.appendChild(head); + card.appendChild(chips); + root.appendChild(card); + }); + } + + function renderEmpty(parent, message, className, style) { + clearEl(parent); + const el = textEl("div", message, className || "empty"); + if (style) el.setAttribute("style", style); + parent.appendChild(el); } function renderTasks() { + const root = $("tasks"); if (!pendingTasks.length) { - $("tasks").innerHTML = - '
no tasks yet
'; + renderEmpty(root, "no tasks yet", "empty", "padding:0.85rem 0.95rem"); return; } + clearEl(root); const list = pendingTasks.slice().reverse(); - $("tasks").innerHTML = list - .map((t) => { - const out = - t.output && t.output.summary - ? '
' + - esc(t.output.summary.slice(0, 200)) + - (t.output.summary.length > 200 ? "…" : "") + - "
" - : t.error - ? '
' + esc(t.error) + "
" - : ""; - return ( - '
' + - '
' + - '' + - stateLabel(t.state) + - "" + - '
' + - esc(t.title) + - "
" + - "
" + - '
' + - (t.assignedTo - ? "→ " + esc(t.assignedTo) + "" - : "unassigned") + - '·prio ' + - esc(t.priority) + - "" + - '·' + - esc((t.createdAt || "").slice(11, 19)) + - "" + - (t.attempts > 1 - ? '·try ' + esc(t.attempts) + "" - : "") + - "
" + - out + - "
" - ); - }) - .join(""); + for (const t of list) { + const state = safeTaskState(t.state); + const card = document.createElement("div"); + card.className = "task" + (state === "failed" ? " failed" : ""); + const top = document.createElement("div"); + top.className = "top"; + top.appendChild(textEl("span", stateLabel(state), "badge s-" + state)); + top.appendChild(textEl("div", t.title, "title")); + card.appendChild(top); + const meta = document.createElement("div"); + meta.className = "meta"; + if (t.assignedTo) { + meta.appendChild(document.createTextNode("→ " + String(t.assignedTo))); + } else { + meta.appendChild(textEl("span", "unassigned")); + } + meta.appendChild(textEl("span", "·", "sep")); + meta.appendChild(textEl("span", "prio " + String(t.priority ?? ""))); + meta.appendChild(textEl("span", "·", "sep")); + meta.appendChild(textEl("span", String((t.createdAt || "").slice(11, 19)))); + if (t.attempts > 1) { + meta.appendChild(textEl("span", "·", "sep")); + meta.appendChild(textEl("span", "try " + String(t.attempts))); + } + card.appendChild(meta); + if (t.output && t.output.summary) { + const summary = String(t.output.summary); + const clipped = summary.slice(0, 200) + (summary.length > 200 ? "…" : ""); + card.appendChild(textEl("div", clipped, "output")); + } else if (t.error) { + card.appendChild(textEl("div", t.error, "output")); + } + root.appendChild(card); + } } function renderApprovals() { + const root = $("approvals"); if (!pendingApprovals.length) { - $("approvals").innerHTML = - '
nothing awaiting approval
'; + renderEmpty(root, "nothing awaiting approval", "empty", "padding:0.85rem 0.95rem"); return; } - $("approvals").innerHTML = pendingApprovals - .map( - (a) => - '
' + - '
' + - '' + - esc(a.tool) + - "" + - (a.cost - ? '$' + - esc(Number(a.cost.amount).toFixed(2)) + - " " + - esc(a.cost.currency) + - "" - : "") + - "
" + - '
agent ' + - esc(a.agentId) + - " · task " + - esc((a.taskId || "").slice(-6)) + - "
" + - '
' + - esc(JSON.stringify(a.args)) + - "
" + - '
' + - esc(a.reason) + - "
" + - '
' + - '' + - '' + - "
" + - "
", - ) - .join(""); + clearEl(root); + for (const a of pendingApprovals) { + const card = document.createElement("div"); + card.className = "approval"; + card.dataset.id = String(a.id ?? ""); + const top = document.createElement("div"); + top.className = "top"; + top.appendChild(textEl("span", a.tool, "tool")); + if (a.cost) { + top.appendChild( + textEl( + "span", + "$" + Number(a.cost.amount).toFixed(2) + " " + String(a.cost.currency ?? ""), + "cost", + ), + ); + } + card.appendChild(top); + card.appendChild( + textEl( + "div", + "agent " + String(a.agentId ?? "") + " · task " + String((a.taskId || "").slice(-6)), + "meta", + ), + ); + card.appendChild(textEl("div", JSON.stringify(a.args ?? {}), "args")); + card.appendChild(textEl("div", a.reason, "reason")); + const actions = document.createElement("div"); + actions.className = "actions"; + const approveBtn = document.createElement("button"); + approveBtn.className = "approve"; + approveBtn.dataset.decision = "approved"; + approveBtn.textContent = "Approve"; + const rejectBtn = document.createElement("button"); + rejectBtn.className = "reject"; + rejectBtn.dataset.decision = "rejected"; + rejectBtn.textContent = "Reject"; + actions.appendChild(approveBtn); + actions.appendChild(rejectBtn); + card.appendChild(actions); + root.appendChild(card); + } } $("approvals").addEventListener("click", async (e) => { @@ -1123,22 +1165,14 @@ const placeholder = feed.querySelector(".empty"); if (placeholder) placeholder.remove(); const node = document.createElement("div"); - const cls = "ev t-" + String(msg.type || "").replace(/\./g, "\\."); - node.className = cls; - const ts = (msg.ts || "").slice(11, 19); - const payload = JSON.stringify(msg.payload); - node.innerHTML = - "" + - '' + - esc(msg.type) + - "" + - '' + - esc(msg.source || "") + - " " + - esc(payload) + - ""; + node.className = safeEventTypeClass(msg.type); + node.appendChild(textEl("time", (msg.ts || "").slice(11, 19))); + node.appendChild(textEl("span", msg.type, "type")); + const payloadWrap = document.createElement("span"); + payloadWrap.className = "payload"; + payloadWrap.appendChild(textEl("span", msg.source || "", "src")); + payloadWrap.appendChild(document.createTextNode(" " + JSON.stringify(msg.payload ?? {}))); + node.appendChild(payloadWrap); feed.insertBefore(node, feed.firstChild); while (feed.children.length > MAX_EVENTS) feed.removeChild(feed.lastChild); } @@ -1147,7 +1181,10 @@ try { const s = await fetch("/api/spend").then((r) => r.json()); const total = Number(s.total || 0).toFixed(2); - $("spendBadge").innerHTML = 'spend $' + esc(total) + ""; + const badge = $("spendBadge"); + clearEl(badge); + badge.appendChild(document.createTextNode("spend ")); + badge.appendChild(textEl("span", "$" + total, "spend-total")); } catch (e) {} } @@ -1193,12 +1230,21 @@ }, ]; - function setBanner(html, done) { - $("demoBanner").innerHTML = - '
' + html + "
"; + function setBanner(message, done) { + const host = $("demoBanner"); + clearEl(host); + const div = document.createElement("div"); + div.className = "banner" + (done ? " done" : ""); + if (!done) { + const spinner = document.createElement("span"); + spinner.className = "spinner"; + div.appendChild(spinner); + } + div.appendChild(document.createTextNode(message)); + host.appendChild(div); } function clearBanner() { - $("demoBanner").innerHTML = ""; + clearEl($("demoBanner")); } async function postTask(t) { @@ -1225,20 +1271,16 @@ const live = providerId !== "simulation"; try { setBanner( - 'Starting demo — enqueuing ' + + "Starting demo — enqueuing " + DEMO_TASKS.length + - " tasks across all agents (" + - (live ? "live · " + esc(providerId) : "simulation") + - "). Approvals will be auto-resolved.", + " tasks across all agents (" + + (live ? "live · " + providerId : "simulation") + + "). Approvals will be auto-resolved.", ); for (let i = 0; i < DEMO_TASKS.length; i++) { await postTask(DEMO_TASKS[i]); setBanner( - 'Enqueued ' + - (i + 1) + - "/" + - DEMO_TASKS.length + - " tasks. Watching agents reason…", + "Enqueued " + (i + 1) + "/" + DEMO_TASKS.length + " tasks. Watching agents reason…", ); await sleep(900); } @@ -1250,12 +1292,12 @@ if (pending.length) { for (const a of pending) { setBanner( - 'Auto-approving ' + - esc(a.tool) + + "Auto-approving " + + String(a.tool ?? "") + (a.cost ? " ($" + Number(a.cost.amount).toFixed(0) + ")" : "") + - " requested by " + - esc(a.agentId) + - "…", + " requested by " + + String(a.agentId ?? "") + + "…", ); await sleep(1100); await approve(a.id); @@ -1264,11 +1306,7 @@ } else { const active = pendingTasks.filter((t) => !TERMINAL.includes(t.state)); if (active.length === 0) break; - setBanner( - '' + - active.length + - " task(s) still in progress…", - ); + setBanner(active.length + " task(s) still in progress…"); } await sleep(900); } @@ -1277,17 +1315,17 @@ .then((r) => r.json()) .catch(() => ({ total: 0 })); setBanner( - 'Demo complete — ' + + "Demo complete — " + ok + "/" + DEMO_TASKS.length + - " tasks succeeded, $" + + " tasks succeeded, $" + Number(spend.total || 0).toFixed(2) + - " spend recorded. Review the activity feed for the full event trace.", + " spend recorded. Review the activity feed for the full event trace.", true, ); } catch (e) { - setBanner("Demo stopped: " + esc(e.message || e) + "", true); + setBanner("Demo stopped: " + String(e.message || e), true); } finally { demoRunning = false; btn.disabled = false;