From 4424cbbb3bfe6e26fc3f8110930826e41acafa2d Mon Sep 17 00:00:00 2001 From: Jack Stuart <42309435+JackStuart@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:30:49 +0800 Subject: [PATCH] fix(security): prevent lifecycle log stored XSS --- src/web/public/settings-ui.js | 33 ++++++++++----- test/lifecycle-log-xss.test.ts | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 test/lifecycle-log-xss.test.ts diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 83864b7f1..0fc0e6667 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1945,7 +1945,7 @@ Object.assign(CodemanApp.prototype, { const empty = document.getElementById('lifecycleEmpty'); if (!data.entries || data.entries.length === 0) { - tbody.innerHTML = ''; + tbody.replaceChildren(); empty.style.display = ''; return; } @@ -1957,21 +1957,34 @@ Object.assign(CodemanApp.prototype, { server_started: '#666', server_stopped: '#666', }; - tbody.innerHTML = data.entries.map(e => { + const makeCell = (text, style) => { + const cell = document.createElement('td'); + cell.style.cssText = style; + cell.textContent = String(text ?? ''); + return cell; + }; + + tbody.replaceChildren(...data.entries.map(e => { const time = new Date(e.ts).toLocaleString(); const color = eventColors[e.event] || '#888'; const name = e.name || (e.sessionId === '*' ? '—' : this.getShortId(e.sessionId)); const extra = []; if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`); if (e.mode) extra.push(e.mode); - return ` - ${time} - ${e.event} - ${name} - ${e.reason || ''} - ${extra.join(', ')} - `; - }).join(''); + + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid #1a1a2e'; + const sessionCell = makeCell(name, 'padding:3px 8px;color:#e0e0e0'); + sessionCell.title = String(e.sessionId); + row.append( + makeCell(time, 'padding:3px 8px;color:#888;white-space:nowrap'), + makeCell(e.event, `padding:3px 8px;color:${color};font-weight:600`), + sessionCell, + makeCell(e.reason, 'padding:3px 8px;color:#aaa'), + makeCell(extra.join(', '), 'padding:3px 8px;color:#666') + ); + return row; + })); } catch (err) { console.error('Failed to load lifecycle log:', err); } diff --git a/test/lifecycle-log-xss.test.ts b/test/lifecycle-log-xss.test.ts new file mode 100644 index 000000000..370aa8720 --- /dev/null +++ b/test/lifecycle-log-xss.test.ts @@ -0,0 +1,77 @@ +/** + * @fileoverview Regression coverage for lifecycle-log DOM rendering. + * Port: N/A (JSDOM exercises the real settings-ui.js renderer). + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; + +const SETTINGS_UI = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf8'); + +interface LifecycleApp { + loadLifecycleLog(): Promise; +} + +function boot(entries: Record[]) { + const dom = new JSDOM( + ` + + +
+
+ `, + { runScripts: 'outside-only', url: 'http://localhost/' } + ); + const win = dom.window as unknown as Window & + typeof globalThis & { + __CodemanApp: new () => LifecycleApp; + fetch: ReturnType; + }; + + win.fetch = vi.fn(async () => ({ + json: async () => ({ success: true, data: { entries } }), + })); + (win as unknown as { eval: (source: string) => void }).eval( + `class CodemanApp { getShortId(id) { return id.slice(0, 8); } } +${SETTINGS_UI} +window.__CodemanApp = CodemanApp;` + ); + + return { dom, app: Object.create(win.__CodemanApp.prototype) as LifecycleApp }; +} + +describe('lifecycle log rendering', () => { + it('renders persisted lifecycle fields as text instead of executable markup', async () => { + const payload = ``; + const sessionId = `session-">`; + const reason = ``; + const mode = ``; + const { dom, app } = boot([ + { + ts: Date.now(), + event: 'exit', + sessionId, + name: payload, + reason, + mode, + exitCode: 1, + }, + ]); + + await app.loadLifecycleLog(); + + const cells = dom.window.document.querySelectorAll('#lifecycleTableBody td'); + expect(cells[2]?.textContent).toBe(payload); + expect(cells[2]?.getAttribute('title')).toBe(sessionId); + expect(cells[3]?.textContent).toBe(reason); + expect(cells[4]?.textContent).toBe(`code=1, ${mode}`); + expect( + dom.window.document.querySelector( + '#lifecycleTableBody img, #lifecycleTableBody svg, #lifecycleTableBody script, #lifecycleTableBody iframe' + ) + ).toBeNull(); + expect(dom.window.document.body.dataset.xss).toBeUndefined(); + dom.window.close(); + }); +});