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();
+ });
+});