Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 `<tr style="border-bottom:1px solid #1a1a2e">
<td style="padding:3px 8px;color:#888;white-space:nowrap">${time}</td>
<td style="padding:3px 8px;color:${color};font-weight:600">${e.event}</td>
<td style="padding:3px 8px;color:#e0e0e0" title="${e.sessionId}">${name}</td>
<td style="padding:3px 8px;color:#aaa">${e.reason || ''}</td>
<td style="padding:3px 8px;color:#666">${extra.join(', ')}</td>
</tr>`;
}).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);
}
Expand Down
77 changes: 77 additions & 0 deletions test/lifecycle-log-xss.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

function boot(entries: Record<string, unknown>[]) {
const dom = new JSDOM(
`<!doctype html><html><body>
<select id="lifecycleFilterEvent"><option value=""></option></select>
<input id="lifecycleFilterSession" value="">
<table><tbody id="lifecycleTableBody"></tbody></table>
<div id="lifecycleEmpty"></div>
</body></html>`,
{ runScripts: 'outside-only', url: 'http://localhost/' }
);
const win = dom.window as unknown as Window &
typeof globalThis & {
__CodemanApp: new () => LifecycleApp;
fetch: ReturnType<typeof vi.fn>;
};

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 = `<img src=x onerror="document.body.dataset.xss='executed'">`;
const sessionId = `session-"><svg id="session-id-xss" onload="alert(1)"></svg>`;
const reason = `<script>document.body.dataset.xss='reason'</script>`;
const mode = `<iframe srcdoc="<script>alert(1)</script>"></iframe>`;
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();
});
});
Loading