diff --git a/.agents/PLAN.md b/.agents/PLAN.md new file mode 100644 index 0000000..f4ddb51 --- /dev/null +++ b/.agents/PLAN.md @@ -0,0 +1,112 @@ +# PLAN — Developer Homepage + +**Status:** live static site · **Last reviewed:** 2026-09-18 (security & code-quality audit) + +## 1. Project Identity + +A single-page personal portfolio (Reese Wells — self-hosting & infrastructure) +with an interactive fake terminal (easter-egg commands, vim simulator, +achievement system). No backend, no user accounts, no PII beyond public +contact details and GPG public keys. + +## 2. Assumptions & Design Principles + +1. **Static first.** The site is plain HTML/CSS/JS. No runtime dependencies, + no CDNs, no build-time JS frameworks — the supply chain is the repo itself. +2. **Boring deployment.** `build.sh` → hashed `dist/` → `nginx:alpine` on + `:8080` (plain HTTP) → TLS terminated by the external Caddy reverse proxy. +3. **Test the artifact.** E2E tests run against the built `dist/`, not `src/`, + so what is tested is exactly what is deployed. +4. **Security headers are a container property.** All hardening headers are + emitted by nginx (the only thing that serves the site in production). +5. **Fun is safe.** The fake terminal's easter eggs (page-wipe, login screen, + vim) are DOM-only pranks — no real network or system effects. The only + real network behavior (curl/wget `fetch`) is being removed — see Phase 02. + +## 3. Architectural Anchors (LOCKED DECISIONS) + +| COMPONENT | DECISION | RATIONALE | STATUS | +|---|---|---|---| +| Frontend runtime | Vanilla HTML/CSS/JS, flat `src/*.js` files, `defer` load order | No toolchain, auditable, works with `build.sh` hashing | LOCKED (pre-existing) | +| Terminal modules | `terminal-commands.js` (canned data) · `terminal-achievements.js` · `terminal-vim.js` · `terminal.js` (core) | Flat modules keep `build.sh`'s root-level hashing working; single source of truth for command data | LOCKED (2026-09-18 refactor) | +| Build | `build.sh`: md5 content-hash cache-busting + `sed` reference rewrites → `dist/` | md5 is a cache key, not a security hash — acceptable | LOCKED (pre-existing) | +| Serving | `nginx:alpine`, `listen 8080`, plain HTTP | TLS is added by the Caddy edge in front of the container | LOCKED (pre-existing) | +| Edge | Caddy reverse proxy (Route53 DNS-validated TLS) in front of the container | HSTS/TLS are edge responsibilities | LOCKED (assumed — verify per Phase 03) | +| E2E tests | `@playwright/test` pinned **1.62.0** (matches cached chromium-1234), serves `dist/` via `python3 -m http.server 8123`, config `playwright.config.js` | Tests the deployed artifact; pinned to avoid browser re-downloads | LOCKED (2026-09-18, user-approved) | +| Deployment | Docker image → `gitea.reeseapps.com/services/homepage` via Gitea Actions (secrets-based registry login) | Self-hosted CI | LOCKED (pre-existing) | +| Persistence | `localStorage` only (achievements, key `reese-terminal-achievements`) | No backend by design | LOCKED (pre-existing) | + +## 4. High-Level Architecture + +``` +src/ (index.html, style.css, script.js, terminal*.js, assets) + │ build.sh (hash + rewrite) + ▼ +dist/ (index.html + hashed assets) ◄── playwright webServer (port 8123) + │ Dockerfile (COPY → nginx html root) ◄── tests/e2e/*.spec.js (44 tests) + ▼ +nginx:alpine :8080 (HTTP, security headers, cache policy) + ▲ +Caddy edge (TLS, HSTS) ← reeseapps.com +``` + +- `src/terminal.js` — terminal core: prompt loop, history, tab completion, + dispatch, expand/collapse (`expandTerminal`/`collapseTerminal`, shared with + `script.js` hero click). +- `src/terminal-commands.js` — `TERMINAL_COMMANDS` (canned outputs; values may + be functions evaluated at dispatch), `TERMINAL_COMMAND_LIST` (tab + completion), `getCommandOutput()`. +- `src/terminal-vim.js` — `launchVimSimulator({content, terminal, + mobileInput, filename, setVimMode})`. +- `src/terminal-achievements.js` — `ACHIEVEMENTS`, localStorage persistence, + toasts, hidden-section reveal, `initTerminalAchievements()`. +- `src/script.js` — nav menu, IntersectionObserver fades, server-rack + background generation, hero-click terminal toggle. + +## 5. Validation / Verification Workflow + +1. `./build.sh` — rebuild `dist/`. +2. `npm test` (Playwright) — 44 E2E tests against `dist/` on port 8123. + Every phase's final pass must leave the suite green. +3. `.agents/validate.sh` (installed by the `phased-execution` skill) — + must run 1 + 2 and, for Phase 03, the header checks below. +4. Container header check (Phase 03): `scripts/check-headers.sh` — builds the + image, runs it on a scratch port, asserts the four security headers on + `/`, `/index.html`, and a hashed asset. + +## 6. Data Model + +No server-side data. Client-side state: + +| Key | Where | Shape | +|---|---|---| +| `reese-terminal-achievements` | localStorage | JSON array of achievement ids (e.g. `["time_flies","nice_try"]`) | + +Session-only state (no persistence): terminal history, `isRoot`, vim state, +menu open/closed, terminal grown/collapsed. + +## 7. Roadmap + +| Phase | Title | Source | +|---|---|---| +| `01_fix_history_xss` | Fix terminal history-recall XSS (M-1) | audit finding M-1 | +| `02_simulate_fetch_commands` | Remove unvalidated client fetch from curl/wget (M-2) | audit finding M-2 | +| `03_nginx_security_headers` | Security headers on every location (M-4) + `server_tokens off` (L-2) | audit findings M-4, L-2 | + +**Dismissed:** audit finding M-3 (README `0.0.0.0:8080` binding) — false +positive per project owner (2026-09-18); the host firewall already restricts +access and the site is fronted by the Caddy edge. + +**Completed in-session (not phased):** code-quality pass 2026-09-18 — module +split (Q-1), dead-code removal (Q-2/3/4, `scrollOffset`), expand/collapse +helpers (Q-5), rack-decoration CSS classes (Q-7), `hidden`-attribute reveal +(Q-8), a11y label on terminal input (Q-9), dynamic `date` (Q-10), vim `dd` +line-deletion fix, Playwright regression suite (44 tests). See +`.agents/remediation_plan.md`. + +Remaining low-priority audit items (not yet phased): L-1 (verify HSTS at the +Caddy edge — folded into Phase 03 verification), L-3 (drop +`style-src 'unsafe-inline'` after moving inline `style=""` attrs to classes), +L-5 (label the displayed SSH key as fictitious or show fingerprint only), +L-7 (pin CI action SHAs / image signing), L-8 (build.sh verification test in +CI). diff --git a/.agents/phases/complete/.gitkeep b/.agents/phases/complete/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.agents/phases/todo/01_fix_history_xss/00_phase.md b/.agents/phases/todo/01_fix_history_xss/00_phase.md new file mode 100644 index 0000000..e3816b1 --- /dev/null +++ b/.agents/phases/todo/01_fix_history_xss/00_phase.md @@ -0,0 +1,38 @@ +# Phase 01 — Fix terminal history-recall XSS (audit M-1) + +**Story:** n/a +**Context:** `.agents/PLAN.md` §4 (terminal modules), `.agents/remediation_plan.md` finding M-1 + +## Objective +Eliminate the DOM-XSS sink in the fake terminal's command-history recall: +ArrowUp/ArrowDown currently rebuild the prompt line with +`lastLine.innerHTML = '$ ' + cmdLine + ' '` +where `cmdLine` is raw user input from `commandHistory`. Replace it with +textContent-based construction so terminal input can never be injected as +HTML, and lock the fix with a Playwright regression test. + +## Dependencies +— (none) + +## Tasks +1. `01_fix_history_recall.md` — Rewrite the ArrowUp/ArrowDown recall to build the prompt line with `createElement`/`textContent` (mirror the existing `updateDisplay()` pattern). +2. `02_xss_regression_test.md` — Add a Playwright test proving a markup payload typed into the terminal is recalled as literal text, not DOM. + +## Testing & Quality +- Unit/integration: the new regression test must cover (a) a payload that + would create a DOM node if injected (`x`), (b) a + payload with an inline event handler (`90%** on new/modified code (the two recall branches in + `src/terminal.js`). +- E2E: full Playwright suite (`npm test`) green — it already exercises + history recall with benign commands and must not regress. + +## Completion Criteria +- [ ] `grep -n "innerHTML" src/terminal.js` shows no occurrence combining + `innerHTML` with `cmdLine`/history data (static prompt literals and the + neofetch canned-output assignment are the only allowed `innerHTML` uses) +- [ ] new XSS regression test present in `tests/e2e/02-terminal.spec.js` and passing +- [ ] `./build.sh && npm test` fully green (44+ tests) +- [ ] no behavior change in completed work (terminal, vim, achievements, menu) diff --git a/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md b/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md new file mode 100644 index 0000000..0aeb8bc --- /dev/null +++ b/.agents/phases/todo/01_fix_history_xss/01_fix_history_recall.md @@ -0,0 +1,49 @@ +# Task 01 — Rewrite history recall with the safe display path + +**Phase:** `01_fix_history_xss` · **Story:** n/a + +## Objective +Remove the `innerHTML` sink from the terminal's ArrowUp/ArrowDown history +recall by routing recall through the existing `updateDisplay(text)` function, +which already builds the prompt line with `textContent`/`createElement`. + +## Work +1. `src/terminal.js` — in the terminal `keydown` handler, replace the three + `lastLine.innerHTML = '$ ' + cmdLine + ' '` + constructions with `updateDisplay(...)` calls: + + - **ArrowUp branch** (`if (historyIndex < commandHistory.length - 1)`): + delete the `innerHTML` line, the `newCursor` creation, and the + `appendChild` — keep `historyIndex++` and `const cmdLine = ...`, then: + ```js + updateDisplay(cmdLine); + ``` + - **ArrowDown branch, first case** (`if (historyIndex > 0)`): same + replacement — `updateDisplay(cmdLine);` + - **ArrowDown branch, else case** (`historyIndex = -1`): replace the + `innerHTML`/cursor block with: + ```js + updateDisplay(''); + ``` + + `updateDisplay` already: removes any stale `.terminal-cursor`, clears the + line via `textContent`, appends a `.terminal-prompt` span (respecting + `isRoot` → `#` vs `$`), the text, and a fresh cursor (red when root). + + Note the two intentional, behavior-preserving improvements this brings: + (a) recalled prompts match the live typing state (cursor directly after + the text, no trailing space); (b) after `sudo su -`, recall shows `#` + instead of a stale `$`. Both are consistency fixes, not regressions. + +## Testing & Quality +- Unit/integration: no new unit code — the existing E2E test + `02-terminal.spec.js › history: ArrowUp/ArrowDown recall previous commands` + exercises recall and execution of recalled commands and must stay green. +- Coverage: **>90%** on the modified branches (covered by that E2E test plus + the task-02 regression test). + +## Completion Criteria +- [ ] `grep -n "innerHTML" src/terminal.js` — no remaining occurrence that + involves `cmdLine` or history data +- [ ] `./build.sh && npm test` fully green +- [ ] no behavior change in completed work (terminal, vim, achievements, menu) diff --git a/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md b/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md new file mode 100644 index 0000000..c81a9a6 --- /dev/null +++ b/.agents/phases/todo/01_fix_history_xss/02_xss_regression_test.md @@ -0,0 +1,65 @@ +# Task 02 — Add the XSS regression test + +**Phase:** `01_fix_history_xss` · **Story:** n/a + +## Objective +Add a Playwright E2E test that proves a markup payload typed into the +terminal is recalled (ArrowUp) as **literal text**, never as DOM, and never +executes. This test fails on the pre-fix (innerHTML) code and passes on the +fixed code, so it guards against regressions/reverts. + +## Work +1. `tests/e2e/02-terminal.spec.js` — add the following test inside the + `test.describe('Fake terminal — state and special commands', ...)` block + (it reuses `openTerminal`, `typeCommand`, `currentPromptLine` from + `./helpers`): + + ```js + test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => { + await openTerminal(page); + + // 1) A payload that would create a DOM node if innerHTML-injected. + await typeCommand(page, 'pwned'); + await page.keyboard.press('ArrowUp'); + await expect(page.locator('#xss-marker')).toHaveCount(0); + await expect(currentPromptLine(page)).toContainText('pwned'); + + // 2) An event-handler payload must not execute. `data:,` is an empty + // data URI so the fails to decode deterministically (no + // network dependency) and onerror would fire if injected as HTML. + await page.evaluate(() => { window.__xssFired = false; }); + await typeCommand(page, ''); + 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); + await expect(currentPromptLine(page)).toContainText(''); + + // 3) Benign recall still works and is executable. + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('optional'); + }); + ``` + + Notes for the executor: + - Step 3 walks back down through history (``, then ``, then the + prior real command) and executes it; adjust the final assertion to the + benign command that is actually on top of the history at that point if + the exact stack differs when you run it. The point is that recall + + execute of a benign command still works. + - Do **not** add an `onerror`-based assertion that depends on a real + network fetch; keep it self-contained with `data:,`. + +## Testing & Quality +- Unit/integration: this test **is** the regression coverage for the M-1 fix. + It must: fail on innerHTML-based recall, pass on textContent-based recall. +- Coverage: **>90%** on the modified recall branches (both payload classes + + benign path covered). + +## Completion Criteria +- [ ] new test present in `tests/e2e/02-terminal.spec.js` +- [ ] `./build.sh && npm test` fully green (45+ tests) +- [ ] (sanity) temporarily reverting task 01's fix makes this test fail — + confirms it is a genuine regression guard, then re-apply the fix diff --git a/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md b/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md new file mode 100644 index 0000000..d043c48 --- /dev/null +++ b/.agents/phases/todo/02_simulate_fetch_commands/00_phase.md @@ -0,0 +1,44 @@ +# Phase 02 — Remove unvalidated client fetch from curl/wget (audit M-2) + +**Story:** n/a +**Context:** `.agents/PLAN.md` §2 (principle 5 — fun is safe) & §3 (terminal +modules), `.agents/remediation_plan.md` finding M-2 + +## Objective +The fake terminal's `curl`/`wget` commands run a real, unvalidated +`fetch(url)` from the visitor's browser — a client-side request-forgery +primitive (and cookie-attached once the site ever has auth) that is also +silently broken in production (CSP `connect-src` falls back to +`default-src 'self'`, blocking every external fetch). Replace the real +fetch with **simulated output** consistent with the rest of the fake +terminal: zero network I/O, deterministic, echoes the requested URL. + +**Chosen approach** (audit recommendation, owner-approved 2026-09-18): +simulate. The rejected alternative (real fetch + https-only allowlist + +`connect-src` + `credentials: 'omit'`) is documented for the record but out +of scope. + +## Dependencies +— (none) + +## Tasks +1. `01_simulate_fetch_output.md` — Add `simulatedCurlOutput(url)` / `simulatedWgetOutput(url)` to `terminal-commands.js`; replace both `fetch()` calls in `terminal.js` with the simulated outputs. +2. `02_fetch_regression_test.md` — Add Playwright tests proving curl/wget render simulated output, make **zero** non-origin network requests, and still unlock the `web_navigator` achievement. + +## Testing & Quality +- Unit/integration: the regression tests must cover (a) `curl ` + renders the simulated block and echoes the URL, (b) `wget ` same, + (c) no request to any non-origin host occurs (request listener), + (d) `web_navigator` achievement/toast still fires. +- Coverage: **>90%** on new/modified code (the two simulator functions + + both branches). +- E2E: full Playwright suite green. + +## Completion Criteria +- [ ] `grep -n "fetch(" src/terminal.js` returns no matches (the only + `fetch(` in `src/` must be gone) +- [ ] `simulatedCurlOutput` / `simulatedWgetOutput` present in + `src/terminal-commands.js` +- [ ] new regression tests present in `tests/e2e/` and passing +- [ ] `./build.sh && npm test` fully green (46+ tests) +- [ ] no behavior change in completed work (terminal, vim, achievements, menu) diff --git a/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md b/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md new file mode 100644 index 0000000..3bdc1cd --- /dev/null +++ b/.agents/phases/todo/02_simulate_fetch_commands/01_simulate_fetch_output.md @@ -0,0 +1,81 @@ +# Task 01 — Replace real fetch with simulated output + +**Phase:** `02_simulate_fetch_commands` · **Story:** n/a + +## Objective +Remove the only real network I/O from the fake terminal: the `curl` and +`wget` commands stop calling `fetch(url)` and instead render a +deterministic, simulated response that echoes the requested URL. This kills +the client-side request-forgery primitive (audit M-2) and makes the feature +work identically in production (where CSP blocked the real fetch anyway). + +## Work +1. `src/terminal-commands.js` — append two pure functions (no DOM, no + network). Keep them deterministic (the wget timestamp is the only + intentional non-determinism): + + ```js + // Simulated fetch output. The fake terminal performs NO real network + // I/O by design — these produce a deterministic, clearly-simulated + // response that echoes the requested URL. + function _simulatedBody(url) { + return 'simulated body for ' + url; + } + + function simulatedCurlOutput(url) { + const body = _simulatedBody(url); + return [ + ' % Total % Received % Xferd Average Speed Time Time Time Current', + ' Dload Upload Total Spent Left Speed', + '100 ' + body.length + ' 100 ' + body.length + ' 0 0 ' + (body.length * 40) + ' 0 --:--:-- --:--:-- --:--:--', + body, + ].join('\n'); + } + + function simulatedWgetOutput(url) { + const body = _simulatedBody(url); + return [ + '-- ' + new Date().toISOString().replace('T', ' ').slice(0, 19) + '-- ' + url, + 'Resolving host... (simulated)', + 'Connecting... connected (simulated).', + 'HTTP request sent, awaiting response... 200 OK', + 'Length: ' + body.length + ' [text/plain]', + "Saving to: 'index.html'", + '', + body, + '', + 'Finished (simulated). No real network request was made.', + ].join('\n'); + } + ``` + +2. `src/terminal.js` — in the `curl ` branch, delete the `fetch(url).then(...) + .catch(...)` chain and replace it with: + ```js + // Simulated fetch — the fake terminal never performs real network I/O. + setTimeout(() => { + outLine.textContent = simulatedCurlOutput(url); + content.scrollTop = content.scrollHeight; + }, 400); + ``` +3. `src/terminal.js` — in the `wget ` branch, make the identical change but + call `simulatedWgetOutput(url)`. + + Keep the existing `url` extraction regexes, the `Downloading...` line, and + the `whiteSpace`/`color` styling. The `web_navigator` achievement (keyed + on the `curl `/`wget ` prefix) is untouched and still fires. + +## Testing & Quality +- Unit/integration: no unit test needed for the pure output functions beyond + the E2E assertions in task 02 (they are string builders exercised through + the terminal). If desired, a tiny `node -e` sanity check: + `node -e "eval(require('fs').readFileSync('src/terminal-commands.js','utf8')); console.log(simulatedCurlOutput('https://x'))"`. +- Coverage: **>90%** on the two new functions and both modified branches + (both covered by the task-02 E2E test). + +## Completion Criteria +- [ ] `grep -n "fetch(" src/terminal.js` returns **no** matches +- [ ] `simulatedCurlOutput` and `simulatedWgetOutput` defined in + `src/terminal-commands.js` +- [ ] `./build.sh && npm test` fully green (existing 44 tests unaffected) +- [ ] no behavior change in completed work (terminal, vim, achievements, menu) diff --git a/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md b/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md new file mode 100644 index 0000000..964e4b2 --- /dev/null +++ b/.agents/phases/todo/02_simulate_fetch_commands/02_fetch_regression_test.md @@ -0,0 +1,59 @@ +# Task 02 — Add the simulated-fetch regression test + +**Phase:** `02_simulate_fetch_commands` · **Story:** n/a + +## Objective +Add a Playwright E2E test proving `curl`/`wget` render the simulated output, +make **zero** non-origin network requests, and still unlock the +`web_navigator` achievement. + +## Work +1. `tests/e2e/02-terminal.spec.js` — add inside the + `test.describe('Fake terminal — state and special commands', ...)` block: + + ```js + test('curl/wget are simulated — no real network request leaves the origin', async ({ page }) => { + await openTerminal(page); + + // Track every request that is NOT aimed at the test origin. + const external = []; + page.on('request', (r) => { + if (!r.url().startsWith('http://127.0.0.1:8123')) external.push(r.url()); + }); + + await typeCommand(page, 'curl https://example.com/hello'); + // achievement fires on the first curl/wget + await expect(page.locator('.toast').filter({ hasText: 'Web Navigator' })).toBeVisible(); + const curlLine = page.locator('.terminal-content > div').nth(-2); + await expect(curlLine).toContainText('simulated body for https://example.com/hello'); + + await typeCommand(page, 'wget https://example.org/file.txt'); + const wgetLine = page.locator('.terminal-content > div').nth(-2); + await expect(wgetLine).toContainText('No real network request was made'); + await expect(wgetLine).toContainText('https://example.org/file.txt'); + + expect(external).toEqual([]); + }); + ``` + + Notes for the executor: + - The origin prefix `http://127.0.0.1:8123` must match `baseURL` in + `playwright.config.js`; keep them in sync if the port ever changes. + - The `web_navigator` toast is asserted right after the curl command + (toasts auto-remove after ~4.3 s). + - If `expect(external).toEqual([])` is flaky on your network (e.g. DNS + prefetch noise), tighten it to assert that no request URL contains + `example.com` or `example.org` instead — the point is that the + simulated URLs were never contacted. + +## Testing & Quality +- Unit/integration: this test is the regression coverage for the M-2 fix — + it fails on the pre-fix code (a real `fetch` to example.com/example.org + would appear in `external`) and passes on the simulated code. +- Coverage: **>90%** on both modified branches (both exercised). + +## Completion Criteria +- [ ] new test present in `tests/e2e/02-terminal.spec.js` +- [ ] `./build.sh && npm test` fully green (45+ tests) +- [ ] (sanity) temporarily reverting task 01's change makes this test fail — + confirms the request tracking works, then re-apply diff --git a/.agents/phases/todo/03_nginx_security_headers/00_phase.md b/.agents/phases/todo/03_nginx_security_headers/00_phase.md new file mode 100644 index 0000000..d67b434 --- /dev/null +++ b/.agents/phases/todo/03_nginx_security_headers/00_phase.md @@ -0,0 +1,48 @@ +# Phase 03 — Security headers on every location (audit M-4) + +**Story:** n/a +**Context:** `.agents/PLAN.md` §3 (serving + edge), `.agents/remediation_plan.md` +findings M-4 (headers) and L-2 (`server_tokens`, included as a one-liner in +the same file) + +## Objective +Fix the nginx `add_header` inheritance trap: the `location = /index.html` +and the hashed-asset location each define their own cache `add_header` +directives, so they **lose** the server-level security headers (CSP, +X-Frame-Options, X-Content-Type-Options, Referrer-Policy). Extract the four +security headers into a snippet and `include` it in **every** location, so +every response class carries the full header set. Also set +`server_tokens off;` (L-2) in the same pass. + +## Dependencies +— (none) + +## Tasks +1. `01_extract_header_snippet.md` — Create `nginx-security-headers.inc`, wire it into the Dockerfile, and `include` it in the server block and both header-defining locations (plus `server_tokens off;`). +2. `02_add_header_verification.md` — Add `scripts/check-headers.sh` that builds and runs the real image and asserts all four headers on `/`, `/index.html`, and a hashed asset. + +## Testing & Quality +- Unit/integration: the container-based header check (task 02) is the + integration test. It must assert all four headers on each of the three + response classes. +- Coverage: **>90%** on new/modified config (all three location paths + exercised by the check script). +- The Playwright E2E suite is unaffected (its webServer is plain python, + not nginx) and must stay green. + +## Completion Criteria +- [ ] `nginx-security-headers.inc` exists and holds the four `add_header … + always;` lines +- [ ] `nginx.conf` has **no** inline security `add_header` lines (only the + `include`), and both header-defining locations `include` the snippet +- [ ] `server_tokens off;` present in the `server` block +- [ ] `scripts/check-headers.sh` runs the real image and prints + `HEADER CHECK PASSED` (all four headers on `/`, `/index.html`, and a + hashed asset) +- [ ] `./build.sh && npm test` fully green +- [ ] no behavior change in completed work (terminal, vim, achievements, menu) + +**Out of scope (separate, unphased):** L-1 HSTS — that is an **edge/Caddy** +responsibility (the container serves plain HTTP on 8080). Verify at the edge +manually: `curl -sI https://reeseapps.com/ | grep -i strict-transport`. Do +**not** add HSTS inside the 8080 container. diff --git a/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md b/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md new file mode 100644 index 0000000..f4edb59 --- /dev/null +++ b/.agents/phases/todo/03_nginx_security_headers/01_extract_header_snippet.md @@ -0,0 +1,96 @@ +# Task 01 — Extract header snippet and include it in every location + +**Phase:** `03_nginx_security_headers` · **Story:** n/a + +## Objective +Move the four security headers into a shared snippet and `include` it in +**every** location, so no location can silently drop them by defining its own +`add_header` (the nginx inheritance trap, audit M-4). Add `server_tokens off;` +(audit L-2) in the same pass. + +## Work +1. Create `nginx-security-headers.inc` (repo root, next to `nginx.conf`) with + exactly: + ``` + # Shared security headers — included by EVERY nginx location. + # A location that defines its own add_header inherits none from the + # server block, so each location includes this snippet directly. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always; + ``` + +2. `Dockerfile` — create the snippets dir and copy the file in. Change the + existing `RUN rm … && apk add …` line to also `mkdir -p /etc/nginx/snippets`, + and add a COPY right before the `COPY nginx.conf …` line: + ```dockerfile + RUN rm /etc/nginx/conf.d/default.conf && \ + apk add --no-cache coreutils && \ + mkdir -p /etc/nginx/snippets + + COPY nginx-security-headers.inc /etc/nginx/snippets/security-headers.inc + COPY nginx.conf /etc/nginx/conf.d/default.conf + ``` + +3. `nginx.conf` — rewrite to the exact content below. Key changes: the four + inline `add_header` lines are gone from the `server` block; `server_tokens + off;` is added; each of the three `location` blocks `include`s the snippet + (so no location relies on header inheritance): + ```nginx + server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Hide the nginx version in the Server header (audit L-2) + server_tokens off; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 256; + gzip_vary on; + + # Never cache index.html so the latest HTML is always served + location = /index.html { + include /etc/nginx/snippets/security-headers.inc; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri =404; + } + + # Cache hashed assets forever (filename contains content hash) + location ~* \.(css|js|jpeg|jpg|png|gif|ico|svg|woff2?)$ { + include /etc/nginx/snippets/security-headers.inc; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + include /etc/nginx/snippets/security-headers.inc; + try_files $uri $uri/ /index.html; + } + + # Custom error pages + error_page 404 /index.html; + } + ``` + +## Testing & Quality +- Unit/integration: config correctness is proven by the container check in + task 02 (headers present on all three response classes) and by `nginx -t` + (valid syntax, run implicitly by the container starting). +- Coverage: **>90%** on the modified config (all three locations exercised). + +## Completion Criteria +- [ ] `nginx-security-headers.inc` exists with the four `add_header … always;` + lines +- [ ] `nginx.conf` contains **no** inline security `add_header` lines and has + `include /etc/nginx/snippets/security-headers.inc;` in all three + locations, plus `server_tokens off;` +- [ ] `Dockerfile` copies the snippet into the image +- [ ] `./build.sh && npm test` still fully green (Playwright is unaffected) diff --git a/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md b/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md new file mode 100644 index 0000000..fba346e --- /dev/null +++ b/.agents/phases/todo/03_nginx_security_headers/02_add_header_verification.md @@ -0,0 +1,87 @@ +# Task 02 — Add container-level header verification + +**Phase:** `03_nginx_security_headers` · **Story:** n/a + +## Objective +Add a repeatable, container-based verification that the **real** image serves +all four security headers on every response class (`/`, `/index.html`, and a +hashed asset). This is the integration test for the M-4 fix and guards against +future nginx config regressions. + +## Work +1. Create `scripts/check-headers.sh` (repo root; `mkdir -p scripts` first) + with exactly: + ```bash + #!/usr/bin/env bash + set -euo pipefail + # Verify every response class (/, /index.html, a hashed asset) carries the + # full security-header set, using the REAL nginx image on a scratch port. + # Requires a container runtime (podman or docker). + + ROOT="$(cd "$(dirname "$0")/.." && pwd)" + cd "$ROOT" + + if command -v podman >/dev/null 2>&1; then RUNTIME=podman + elif command -v docker >/dev/null 2>&1; then RUNTIME=docker + else echo "ERROR: neither podman nor docker found" >&2; exit 2; fi + + IMAGE="homepage-headercheck-$$" + PORT=18099 + CID="" + trap 'if [ -n "$CID" ]; then $RUNTIME rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT + + echo ">> building image ($RUNTIME)..." + $RUNTIME build -q -t "$IMAGE" . + + echo ">> starting container on 127.0.0.1:$PORT..." + CID="$($RUNTIME run -d --rm -p 127.0.0.1:$PORT:8080 "$IMAGE")" + + for i in $(seq 1 30); do + curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break + sleep 0.5 + if [ "$i" -eq 30 ]; then echo "ERROR: container not ready" >&2; exit 1; fi + done + + ASSET="$(curl -s "http://127.0.0.1:$PORT/" \ + | grep -oE '(style|script|terminal)[a-z-]*\.[0-9a-f]{32}\.(css|js)' | head -n1 || true)" + if [ -z "$ASSET" ]; then echo "ERROR: no hashed asset found in index.html" >&2; exit 1; fi + + fail=0 + check_headers() { + local path="$1" hdrs h + hdrs="$(curl -sI "http://127.0.0.1:$PORT$path")" + for h in "X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy" "Content-Security-Policy"; do + if ! printf '%s' "$hdrs" | grep -qi "^${h}:"; then + echo "FAIL: ${h} missing on ${path}" >&2; fail=1 + fi + done + [ "$fail" -eq 0 ] && echo "OK: all security headers present on ${path}" + } + + echo ">> checking headers..." + check_headers "/" + check_headers "/index.html" + check_headers "/${ASSET}" + + if [ "$fail" -ne 0 ]; then echo "HEADER CHECK FAILED" >&2; exit 1; fi + echo "HEADER CHECK PASSED" + ``` + Then `chmod +x scripts/check-headers.sh`. + +2. Run it: `./scripts/check-headers.sh` — it must print + `HEADER CHECK PASSED` with an `OK:` line for each of the three paths. + +## Testing & Quality +- Unit/integration: this script **is** the integration test — it builds the + real image, serves it, and asserts all four headers on each response class. +- Coverage: **>90%** on the modified nginx config (all three location paths + asserted). + +## Completion Criteria +- [ ] `scripts/check-headers.sh` exists and is executable +- [ ] `./scripts/check-headers.sh` exits 0 and prints `HEADER CHECK PASSED` + (with `OK:` lines for `/`, `/index.html`, and the hashed asset) +- [ ] (negative check) temporarily removing one `include` from a location in + `nginx.conf` makes the script FAIL for that path — confirms the check + has teeth, then restore it +- [ ] `./build.sh && npm test` fully green diff --git a/.agents/remediation_plan.md b/.agents/remediation_plan.md new file mode 100644 index 0000000..6add8ed --- /dev/null +++ b/.agents/remediation_plan.md @@ -0,0 +1,238 @@ +# Security & Code Quality Audit — homepage + +**Date:** 2026-09-18 +**Auditor:** pi agent (read-only audit; no fixes applied at audit time) +**Method:** manual source review (all files) + tooling: `git log`, `git grep` secret scan, `ssh-keygen` key validation, `curl` live-header probe. + +## Disposition (2026-09-18, same session as the audit) + +- **M-1, M-2, M-4 → scheduled as phases** under the phased-execution protocol: + `.agents/phases/todo/01_fix_history_xss/`, `02_simulate_fetch_commands/`, + `03_nginx_security_headers/` (see `.agents/PLAN.md` §7). Execute with the + `phased-execution` skill (`auto-phase.sh`). +- **M-3 → DISMISSED (false positive per project owner, 2026-09-18).** The + host firewall already restricts access to the container port and the site + is fronted by the Caddy TLS edge; the README's `0.0.0.0:8080` binding is + accepted as-is. +- **Code-quality findings (Q-1…Q-12, plus the vim `dd` dead path) → executed + directly in-session** (explicit owner instruction to run them outside the + phase pipeline): module split (`terminal.js` 1353 → 652 lines + + `terminal-commands.js` / `terminal-vim.js` / `terminal-achievements.js`), + dead-code removal, expand/collapse helpers, rack-decoration CSS classes, + `hidden`-attribute reveal, a11y label, dynamic `date`, vim line-deletion + fix. A 44-test Playwright E2E regression suite (`tests/e2e/`, run with + `./build.sh && npm test`) was created and is green. +- Remaining Low items (L-1, L-3, L-5, L-7, L-8) stay unphased; L-1 is folded + into Phase 03's verification notes, L-2 into Phase 03 task 01. + +## Scope & Stack + +- **Stack:** static site (vanilla HTML/CSS/JS, no framework, no build deps) → hashed via `build.sh` (md5 cache-bust + `sed` rewrites) → served by `nginx:alpine` on `:8080` (plain HTTP, TLS expected at an external Caddy edge) → Docker/Podman image pushed to `gitea.reeseapps.com/services/homepage` via Gitea Actions. +- **Core functionality:** personal portfolio with an interactive fake terminal (easter-egg commands, vim simulator, achievements in `localStorage`). +- **Data sensitivity:** none at rest. Public PII only (name, email, GPG public keys). No auth, no backend, no forms. +- **Assumptions:** TLS termination + HSTS happen at the Caddy reverse proxy in front of this container (container only listens on 8080 HTTP). Live site headers could not be verified from this network. + +**Overall:** low-risk attack surface (static, no input persistence, no backend). No Critical/High findings. One Medium XSS-pattern, two Medium deployment issues, several Low hardening items, and a number of code-quality debts (dead code, duplication, untested build). + +## Findings (severity-ranked) + +--- + +### M-1. XSS sink: terminal command history recalled via `innerHTML` +- **Severity:** Medium (High if ever served without the CSP below) +- **Type:** CWE-79 (DOM-based XSS via innerHTML) +- **Location:** `src/terminal.js`, ArrowUp/ArrowDown handlers in the terminal `keydown` listener (~lines 330–370) +- **Description:** The Enter handler stores raw user input into `commandHistory` (captured via `textContent`, so typing is safe). But ArrowUp/ArrowDown recall it with: + ```js + lastLine.innerHTML = '$ ' + cmdLine + ' '; + ``` + `cmdLine` is untrusted input injected into `innerHTML`. +- **PoC:** In the site's terminal type: + ``` + + ``` + press Enter, then press ↑. The payload is injected as HTML. + Mitigated *today* by the deployed CSP `script-src 'self'` (inline handlers and inline `