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');