phase: 01_fix_history_xss
Build and Push Container / build-and-push (push) Successful in 26s

**Phase 01 — final verification pass (all criteria green)**

- Verified M-1 fix in `src/terminal.js`: ArrowUp / ArrowDown(`>0`) / ArrowDown-else now route through `updateDisplay()` (`textContent`); no `innerHTML` sink touches `cmdLine`/`commandHistory`.
- `grep -n innerHTML src/terminal.js` → 12 hits, all allowed (static prompt literals, `clear`/prank wipes, `outLine.innerHTML = output` gated on the static canned `neofetch` string). **C1 ✓**
- XSS regression test at `tests/e2e/02-terminal.spec.js:156` present + passing; re-proved it is a real guard: re-injected the old `innerHTML` recall into `dist/terminal.*.js` → test failed (`#xss-marker` received 1); rebuild → byte-identical to `src/`. **C2 ✓**
- `./build.sh` → `dist/` OK; `npm test` → **45 passed (0 failed)**; `bash .agents/validate.sh` → **exit 0** (suite + repo-hygiene gates). **C3 ✓**
- Branch coverage of modified code: all 3 recall arms asserted (ArrowUp ×2 specs, ArrowDown `>0` in the existing history spec, ArrowDown-else → empty prompt in the new spec) → >90% gate met; no JS coverage/lint toolchain exists in the locked stack, so lint = `node --check src/*.js tests/e2e/*.js playwright.config.js` + `bash -n build.sh .agents/validate.sh` — clean.
- C4 ✓ terminal/vim/achievements/menu specs all green; only the three recall branches changed.
- Docs (step 6, full README/AGENTS review): README — run section now states the image already contains the built `dist/` (mount `dist/`, never `src/`); new “Serving & configuration” section for `nginx.conf`, incl. **empirically measured** (built image on 127.0.0.1:8124) that `/`, `/index.html` and hashed assets currently return *only* cache headers — the `add_header` non-merge gap = finding M-4/Phase 03; TLS/HSTS-at-edge rule; Quadlet section fixed (unit is not in this repo; Quadlet generates `homepage.service`, so `… start homepage.service`, and `systemctl enable` is a no-op for transient Quadlet units → `[Install]` + `daemon-reload` + linger); Development gained the Node 20+ floor, `npx playwright install chromium`, and `.agents/validate.sh`. AGENTS.md — added the validate.sh gate, flagged `./scripts/check-headers.sh` as not yet in the repo (Phase 03), and scoped the M-1 rule with the allowed-`innerHTML` list.
- Notable: `.agents/PLAN.md` still says “44 tests” (3 places) and its §7 roadmap omits phases 04/05 — left untouched per the no-PLAN-edit rule; flag for the next PLAN review. No live-infra touched (temp podman image built/removed for the header measurement only).
- Next pending phase: `02_simulate_fetch_commands`.
This commit is contained in:
2026-10-22 22:45:40 -04:00
parent a2f331e526
commit dcd066d953
30 changed files with 2343 additions and 27 deletions
+42
View File
@@ -150,6 +150,48 @@ test.describe('Fake terminal — state and special commands', () => {
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
});
// Regression guard for audit finding M-1: history recall rebuilt the prompt
// line with innerHTML, so a markup payload came back to life as DOM on
// ArrowUp. Recall must stay on the textContent path.
test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => {
// Prompt text minus the leading '$ '/'# ' and the trailing cursor.
const typed = async () =>
(await currentPromptLine(page).textContent()).replace(/^[#$] /, '').trim();
// 1) A payload that would create a DOM node if innerHTML-injected.
await typeCommand(page, '<b id="xss-marker">pwned</b>');
await page.keyboard.press('ArrowUp');
await expect(page.locator('#xss-marker')).toHaveCount(0);
await expect(page.locator('.terminal-content b')).toHaveCount(0);
expect(await typed()).toBe('<b id="xss-marker">pwned</b>');
// 2) An event-handler payload must not execute. `data:,` is an empty
// data URI so the <img> fails to decode deterministically (no
// network dependency) and onerror would fire if injected as HTML.
// ArrowDown first returns to an empty prompt (historyIndex -1).
await page.evaluate(() => { window.__xssFired = false; });
await page.keyboard.press('ArrowDown');
expect(await typed()).toBe('');
await typeCommand(page, '<img src="data:," onerror="window.__xssFired=true">');
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(200); // allow onerror to fire if vulnerable
expect(await page.evaluate(() => window.__xssFired)).toBe(false);
await expect(page.locator('img[onerror]')).toHaveCount(0);
expect(await typed()).toBe('<img src="data:," onerror="window.__xssFired=true">');
// 3) Benign recall + execute still works after the payloads: back to an
// empty prompt, type a real command, run it, recall it, run it again.
await page.keyboard.press('ArrowDown');
expect(await typed()).toBe('');
await typeCommand(page, 'whoami');
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
await page.keyboard.press('ArrowUp');
expect(await typed()).toBe('whoami');
await page.keyboard.press('Enter');
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
await expect(page.locator('#xss-marker')).toHaveCount(0);
});
test('Tab completes a command', async ({ page }) => {
await page.keyboard.type('host');
await page.keyboard.press('Tab');