diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js index 1bf0a7a7..f7b0e069 100644 --- a/scripts/lib/control-pane/ui.js +++ b/scripts/lib/control-pane/ui.js @@ -519,14 +519,16 @@ function renderControlPaneHtml() { const blocker = item.blocker || (item.metadata && item.metadata.blocker) || ''; const assigneeKind = item.assigneeKind || 'unassigned'; const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned'; - const idJs = "'" + String(item.id).replace(/'/g, "\\'") + "'"; - const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane => { - const call = 'eccMoveItem(' + idJs + ", '" + lane + "')"; - return ''; - }).join(''); + // Ids/lanes go into HTML-escaped data-* attributes and are read back via + // dataset in delegated listeners below — never concatenated into inline + // JS handlers — so a crafted work-item id cannot inject script (XSS). + const idAttr = escapeHtml(item.id); + const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane => + '' + ).join(''); const controls = state.allowActions ? '
' - + (assigneeKind === 'unassigned' ? '' : '') + + (assigneeKind === 'unassigned' ? '' : '') + moveButtons + '
' : ''; @@ -539,6 +541,17 @@ function renderControlPaneHtml() { controls + ''; }).join(''); + + document.querySelectorAll('#work-items [data-wi-action]').forEach(button => { + button.addEventListener('click', () => { + const id = button.getAttribute('data-wi-id'); + if (button.getAttribute('data-wi-action') === 'claim') { + eccClaimItem(id); + } else { + eccMoveItem(id, button.getAttribute('data-wi-lane')); + } + }); + }); } function renderKnowledge(knowledge) { diff --git a/tests/scripts/control-pane.test.js b/tests/scripts/control-pane.test.js index d209e367..7e6df807 100644 --- a/tests/scripts/control-pane.test.js +++ b/tests/scripts/control-pane.test.js @@ -205,6 +205,10 @@ async function runTests() { assert.ok(html.includes('function renderWorkItems')); assert.ok(html.includes('function showError')); assert.ok(html.includes('response.ok')); + // Board controls must use escaped data-* attributes + delegated + // listeners, never ids concatenated into inline onclick JS (XSS). + assert.ok(html.includes('data-wi-action')); + assert.ok(!/onclick="ecc(Claim|Move)Item\(/.test(html), 'no inline onclick handlers with interpolated ids'); const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json()); assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');