Add phased-execution planning structure
Build and Push Container / build-and-push (push) Successful in 11s
Build and Push Container / build-and-push (push) Successful in 11s
Introduce .agents/ (PLAN.md with locked architectural anchors, phase roadmap under phases/todo/) and AGENTS.md rules for agents working in the repo. Queues the pending phases: fix history XSS, simulate fetch commands, and nginx security headers.
This commit is contained in:
+112
@@ -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).
|
||||
@@ -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 = '<span class="terminal-prompt">$</span> ' + 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 (`<b id="xss-marker">x</b>`), (b) a
|
||||
payload with an inline event handler (`<img src=x onerror=...`) asserting
|
||||
no script execution, and (c) a benign command (`whoami`) still recalled
|
||||
and executable.
|
||||
- Coverage: **>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)
|
||||
@@ -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 = '<span class="terminal-prompt">$</span> ' + 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)
|
||||
@@ -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, '<b id="xss-marker">pwned</b>');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(page.locator('#xss-marker')).toHaveCount(0);
|
||||
await expect(currentPromptLine(page)).toContainText('<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.
|
||||
await page.evaluate(() => { window.__xssFired = false; });
|
||||
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);
|
||||
await expect(currentPromptLine(page)).toContainText('<img src="data:," onerror="window.__xssFired=true">');
|
||||
|
||||
// 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 (`<img…>`, then `<b…>`, 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
|
||||
@@ -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 <url>`
|
||||
renders the simulated block and echoes the URL, (b) `wget <url>` 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = '<span class="terminal-prompt">$</span> ' + cmdLine + ' ';
|
||||
```
|
||||
`cmdLine` is untrusted input injected into `innerHTML`.
|
||||
- **PoC:** In the site's terminal type:
|
||||
```
|
||||
<img src=x onerror=alert(1)>
|
||||
```
|
||||
press Enter, then press ↑. The payload is injected as HTML.
|
||||
Mitigated *today* by the deployed CSP `script-src 'self'` (inline handlers and inline `<script>` are blocked), so impact on the production nginx container is limited to DOM tampering and click-based `javascript:` links. However: (a) it is fully exploitable if the site is ever served without this CSP (e.g. a Caddy static-file mount or dev server — see M-2/M-3 for how easily that happens), (b) any future CSP relaxation (e.g. adding `'unsafe-inline'` for styles) re-arms it, and (c) a co-browsing victim who is talked into typing a "magic command" gets exploited.
|
||||
- **Remediation:** Render recalled history exactly like `updateDisplay()` does — never `innerHTML` with user data:
|
||||
```js
|
||||
const lastLine = content.lastElementChild;
|
||||
lastLine.textContent = '';
|
||||
const p = document.createElement('span');
|
||||
p.className = 'terminal-prompt';
|
||||
p.textContent = isRoot ? '#' : '$';
|
||||
lastLine.append(p, document.createTextNode(' ' + cmdLine + ' '));
|
||||
const c = document.createElement('span');
|
||||
c.className = 'terminal-cursor';
|
||||
lastLine.appendChild(c);
|
||||
```
|
||||
Apply to both ArrowUp and ArrowDown branches (3 occurrences).
|
||||
|
||||
---
|
||||
|
||||
### M-2. Arbitrary client-side `fetch(url)` via `curl`/`wget` commands (SSRF-style primitive + broken in prod)
|
||||
- **Severity:** Medium
|
||||
- **Type:** CWE-918 (client-side request forgery), CWE-601-adjacent (unvalidated URL)
|
||||
- **Location:** `src/terminal.js`, the `curl ` / `wget ` branches of the Enter handler (~lines 884–920)
|
||||
- **Description:** `fetch(url)` is called with a visitor-supplied, unvalidated URL, from the visitor's own browser, and the response body is displayed. Consequences:
|
||||
1. **Broken in production:** the CSP is `default-src 'self'` with no `connect-src`, so `connect-src` falls back to `'self'` — every external fetch is silently blocked by the browser. The `curl`/`wget` feature (and the `web_navigator` achievement) only works on a CSP-less dev server. When this is noticed and "fixed" by adding `connect-src *`, the next problem appears:
|
||||
2. **Cookie-attached requests:** `fetch()` defaults to `credentials: 'same-origin'`. Once the site ever has *any* authenticated/cookie endpoint, a visitor can run `curl /internal-path` (or the site origin) and read the response text in-page — a self-service exfiltration/CSRF primitive.
|
||||
- **PoC:** In a dev server (no CSP): `curl https://api.example.com/secret` → the browser fetches it and prints the body. After a future `connect-src *`: `curl https://<site>/admin` with the visitor's cookies attached.
|
||||
- **Remediation (pick one):**
|
||||
- *Preferred:* this is a fake terminal — make `curl`/`wget` simulated like every other command (canned output). Zero risk, feature "works" everywhere.
|
||||
- *If real fetches are wanted:* validate URL (`new URL(url)`, require `https:`, require hostname in an explicit allowlist), pass `{ credentials: 'omit' }`, and set an explicit `connect-src` in the CSP listing only those origins.
|
||||
|
||||
---
|
||||
|
||||
### M-3. `podman run -p 0.0.0.0:8080:8080` in README — plain HTTP exposed on all interfaces
|
||||
- **Status:** ❌ **DISMISSED — false positive** (project owner, 2026-09-18).
|
||||
The host firewall restricts access to the container port and the site is
|
||||
served through the Caddy TLS edge in production. No change made.
|
||||
- ~~Severity: Medium~~ (original assessment kept for the record)
|
||||
- **Type:** CWE-319 (cleartext exposure), CWE-614
|
||||
- **Location:** `README.md` ("Run the container" section)
|
||||
|
||||
---
|
||||
|
||||
### M-4. Security headers lost on `/index.html` and all static assets (nginx `add_header` inheritance trap)
|
||||
- **Severity:** Medium (defense-in-depth gap; main `/` URL is fine)
|
||||
- **Type:** CWE-693 (protection mechanism failure — misconfiguration)
|
||||
- **Location:** `nginx.conf`, server block vs. `location = /index.html` and `location ~* \.(css|js|jpeg|...)$`
|
||||
- **Description:** nginx only inherits `add_header` directives from an outer level if the current level defines **none**. Both of those locations define their own `add_header` (cache headers), so they serve responses with **no CSP, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy**. A direct request to `/index.html` or any `.css`/`.js` asset is served without security headers — including CSP, which is the mitigation for M-1.
|
||||
- **PoC:** `curl -sI https://<site>/index.html` → no `Content-Security-Policy` header (vs. `curl -sI https://<site>/` which has it).
|
||||
- **Remediation:** put the shared security headers in a snippet, e.g. `headers.inc`:
|
||||
```
|
||||
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;
|
||||
```
|
||||
and `include /etc/nginx/headers.inc;` in **every** location block (alongside the cache headers).
|
||||
|
||||
---
|
||||
|
||||
### L-1. No HSTS emitted by this service (verify at the edge)
|
||||
- **Severity:** Low
|
||||
- **Type:** CWE-319
|
||||
- **Location:** `nginx.conf` (absent), assumed Caddy edge
|
||||
- **Description:** No `Strict-Transport-Security` anywhere in this repo. If Caddy isn't sending it, first-visit users are downgrade-attack-exposed.
|
||||
- **Remediation:** Verify `curl -sI https://reeseapps.com/ | grep -i strict-transport`. If absent, add at the Caddy edge (`header Strict-Transport-Security "max-age=31536000; includeSubDomains"`). (Don't add it inside the 8080 container — it must be set by the TLS terminator.)
|
||||
|
||||
### L-2. `server_tokens` not disabled
|
||||
- **Severity:** Low
|
||||
- **Type:** CWE-200 (version disclosure)
|
||||
- **Location:** `nginx.conf`
|
||||
- **Description:** Default nginx advertises its version in the `Server` header.
|
||||
- **Remediation:** Add `server_tokens off;` (server or http level — in a conf.d snippet it must go in the `server` block).
|
||||
|
||||
### L-3. `style-src 'unsafe-inline'` in CSP
|
||||
- **Severity:** Low
|
||||
- **Type:** CWE-79 (reduced CSP strength)
|
||||
- **Location:** `nginx.conf` CSP
|
||||
- **Description:** Required today because `index.html` and the JS use dozens of inline `style="..."` attributes and `el.style.x = ...` (note: JS-sets `el.style` are **not** blocked by CSP; only inline style *attributes* are). `script-src 'self'` is clean — no inline scripts, no inline handlers in HTML, which is the important half.
|
||||
- **Remediation (optional):** move inline `style="..."` attributes in `index.html` into classes; then drop `'unsafe-inline'`. Low priority; do together with other CSS cleanup.
|
||||
|
||||
### L-4. README documents a nonexistent `homepage.container` and a conflicting `./src` mount
|
||||
- **Severity:** Low (doc/ops correctness)
|
||||
- **Location:** `README.md` (Deploy with Quadlet, Run the container)
|
||||
- **Description:** `cp homepage.container ~/.config/containers/systemd/` references a file that is not in the repo (verified: not tracked, not present). Also `-v ./src:/usr/share/nginx/html` mounts *unhashed* source, contradicting the hashed `dist/` build the image produces — following the README yields a working-but-unhashed deployment that bypasses the cache-busting pipeline.
|
||||
- **Remediation:** Either commit a `homepage.container` unit file, or rewrite the README to describe the actual image build (`podman build` + run without the volume mount).
|
||||
|
||||
### L-5. Fake terminal shows a syntactically-valid SSH public key
|
||||
- **Severity:** Low / Informational
|
||||
- **Type:** CWE-540 (inclusion of functionality from untrusted control plane — informational only)
|
||||
- **Location:** `src/terminal.js`, `commands['cat ~/.ssh/id_ed25519.pub']`
|
||||
- **Description:** `ssh-keygen -l` confirms the displayed key parses as a real ED25519 key (fingerprint `SHA256:A1/bjtrxqkXFPXawKfVD2nHW4LBElmQ37+r55YFCJmo`). Public keys are public, so exposure is not a leak. Two notes: (a) if this is a *real* homelab key, publishing it bootstraps trust — an attacker who can MITM the site (or its GPG verification chain) could substitute their key for a visitor adding it to `known_hosts`; keep the page's TLS chain strong and consider displaying only the fingerprint. (b) If it's fabricated, add a comment or make it obviously fictitious so nobody mistakes it for a trust anchor.
|
||||
- **Remediation:** Decide intent; if real, show fingerprint-only; if fake, mark it clearly.
|
||||
|
||||
### L-6. Easter eggs wipe the entire page (`document.body.innerHTML = ''`)
|
||||
- **Severity:** Low (intended behavior, noted for quality)
|
||||
- **Location:** `src/terminal.js` — `exit` (non-root) and `rm -rf /` (root) branches
|
||||
- **Description:** Both destroy the whole DOM (nav, styles references, script state). Recovery is a full reload; the `exit` login screen has no way back without refresh. Fine as an easter egg, but fragile to future edits (e.g. any service worker or analytics would also be nuked).
|
||||
- **Remediation:** Optional — scope the wipe to a dedicated overlay instead of `document.body`, or add a "reload" hint on the login screen.
|
||||
|
||||
### L-7. CI supply-chain hardening (optional for a personal repo)
|
||||
- **Severity:** Low / Informational
|
||||
- **Location:** `.gitea/workflows/build-push.yml`
|
||||
- **Description:** Actions pinned to major versions only (`actions/checkout@v4`, `docker/build-push-action@v6`), image not signed, `push: true` on every push to `main`. Acceptable for a self-hosted personal Gitea; noted for completeness.
|
||||
- **Remediation (optional):** pin to full commit SHAs, sign the image with cosign + Sigstore keyless, restrict the workflow to `main` only (drop the `release` trigger or guard it).
|
||||
|
||||
### L-8. No tests; `build.sh` is untested and fragile
|
||||
- **Severity:** Low (quality)
|
||||
- **Location:** `tests/` (empty), `build.sh`
|
||||
- **Description:** `build.sh` uses `sed` string-rewrites to hash asset references — it silently no-ops if an asset is referenced in any other form (e.g. `url(...)` in CSS, single quotes, CSS `@import`). Nothing verifies dist integrity.
|
||||
- **Remediation:** Add a small test (shell + `grep` is enough): run `build.sh`, then assert (1) every asset file in `src` exists in `dist` with a hash, (2) no unhashed `*.css|*.js|*.jpeg|*.ico|*.svg` references remain in `dist/index.html`, (3) `dist/index.html` differs from `src/index.html`. Wire into the Gitea workflow before the Docker build.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Findings (non-security)
|
||||
|
||||
| # | Issue | Location |
|
||||
|---|-------|----------|
|
||||
| Q-1 | `terminal.js` is 1353 lines; `createTerminal()` is ~700 lines with a full vim simulator (~350 lines) nested inside the keydown handler. Split into modules: terminal core / command registry / vim / achievements. | `src/terminal.js` |
|
||||
| Q-2 | **Dead code:** an outer `const commands = {...}` (~line 120) is shadowed by a second, near-identical `const commands = {...}` inside the Enter handler (~line 850). The outer map is never used and has already drifted (different `help` text, missing `apt`/`dnf` entries). Delete the outer one; keep a single registry (also fixes the `date` snapshot bug below). | `src/terminal.js` |
|
||||
| Q-3 | `let isLoginScreen` is written, never read. | `src/terminal.js` |
|
||||
| Q-4 | Copy-paste bug (harmless): `cmdText.startsWith('wget ') \|\| cmdText.startsWith('wget ')` — second condition should probably have been the `curl ` check that the first branch already handles; as written it's redundant. | `src/terminal.js` curl/wget branch |
|
||||
| Q-5 | Grown/expanded terminal state is toggled in **4 places** with duplicated logic (terminal `focus`, terminal `blur`, `mobileInput` `blur`, hero `click`). Extract `expandTerminal()` / `collapseTerminal()` helpers. | `src/terminal.js`, `src/script.js` |
|
||||
| Q-6 | `const brands = ['Framework']` — random selection from a single-element array. | `src/script.js` `createServerRack()` |
|
||||
| Q-7 | Repeated inline `el.style.position/left/top/transform` boilerplate for rack decorations — move to CSS classes (also enables L-3 cleanup). | `src/script.js` |
|
||||
| Q-8 | `revealAchievements()` sets `section.style.display = 'block'` but never clears the `hidden` attribute. Works only because inline style beats the UA `[hidden]` rule — fragile. Use `section.hidden = false`. | `src/terminal.js` |
|
||||
| Q-9 | **Accessibility:** the 1px invisible `mobileInput` (the real key source) has no `aria-label`/role; the `tabindex="0"` terminal div has no keyboard handler of its own. Screen-reader users cannot operate the terminal. Add a labelled input (visually-hidden pattern) and/or an `aria-hidden` toggle; also `#achievements` nav is injected client-side only. | `src/terminal.js`, `src/index.html` |
|
||||
| Q-10 | `date` command is a page-load snapshot (the commands map is built once) — reports the time the page loaded, not "now". Make it a function or compute at dispatch. | `src/terminal.js` |
|
||||
| Q-11 | `error_page 404 /index.html` + `try_files ... /index.html` → every unknown path returns the homepage body with a 404 status. Acceptable for a one-pager, but be aware all soft-404s serve full content. | `nginx.conf` |
|
||||
| Q-12 | Local `dist/` is stale (contains an older `index.html` with different classes) — untracked, so no repo harm, but rebuild before deploying to avoid confusion. | `dist/` |
|
||||
|
||||
## What's already good (verified, not assumed)
|
||||
|
||||
- No secrets in the repo or git history (secret-pattern grep across tracked files + `git log`); CI uses Gitea `secrets.*` correctly.
|
||||
- No inline `<script>` anywhere; JS loaded from local hashed files with `defer`; CSP `script-src 'self'` is meaningful.
|
||||
- All user *typing* paths in the terminal render via `textContent` / `insertAdjacentText`; the vim module correctly uses `escapeHtml()` before its only `innerHTML` sinks. (The history-recall path, M-1, is the exception.)
|
||||
- `rel="noopener"` on every `target="_blank"` link.
|
||||
- No external CDNs or third-party runtime dependencies — small, auditable supply chain.
|
||||
- `.gitignore` correctly excludes `dist/` (verified via `git ls-files`); Dockerfile removes build context after hashing; container has a healthcheck.
|
||||
- GPG key block is public material (expected exposure, correctly presented with fingerprints).
|
||||
- `style.css`: 0 `!important`, sane z-index usage.
|
||||
|
||||
## Remediation Task List (final state, 2026-09-18)
|
||||
|
||||
**Phased (run via `phased-execution` skill, in order):**
|
||||
1. **[M-1]** → `01_fix_history_xss` — replace the 3 `lastLine.innerHTML = ... + cmdLine` history-recall writes with the safe `updateDisplay()` path + XSS regression test.
|
||||
2. **[M-2]** → `02_simulate_fetch_commands` — replace `curl`/`wget` real `fetch(url)` with deterministic simulated output (`simulatedCurlOutput`/`simulatedWgetOutput`) + no-network regression test.
|
||||
3. **[M-4] (+L-2)** → `03_nginx_security_headers` — extract shared `add_header` snippet, `include` in every `location`, `server_tokens off;` + container header-check script.
|
||||
|
||||
**Dismissed:**
|
||||
4. **[M-3]** — false positive per owner (firewall + Caddy edge already cover it). No change.
|
||||
|
||||
**Executed directly in-session (owner instruction; not phased):**
|
||||
10. **[Q-2, Q-3, Q-4, Q-10]** done — outer dead `commands` map deleted, `isLoginScreen` removed, duplicated wget condition fixed, `date` dynamic.
|
||||
11. **[Q-5, Q-7, Q-8]** done — `expandTerminal`/`collapseTerminal` helpers, rack-decoration CSS classes, `section.hidden = false`.
|
||||
12. **[Q-1, Q-9]** done — `terminal.js` split into `terminal-commands.js` / `terminal-vim.js` / `terminal-achievements.js` (1353 → 652 lines), `aria-label` on the hidden input.
|
||||
15. **[vim `dd`]** done — dead Shift+D key check fixed, last-line deletion guard fixed (buffer keeps one empty line, so insert mode can't index a missing line).
|
||||
16. **[tests]** done — 44-test Playwright E2E regression suite (`tests/e2e/`), green.
|
||||
|
||||
**Remaining (unphased Low items):**
|
||||
5. **[L-1]** Verify (or add at Caddy edge) `Strict-Transport-Security` — folded into Phase 03 verification notes (edge-only, never in the container).
|
||||
7. **[L-4]** Fix README quadlet section (add `homepage.container` or remove reference); remove the `./src` volume-mount instruction.
|
||||
8. **[L-5]** Decide real/fake for the displayed SSH key; fingerprint-only if real, mark fictitious if not.
|
||||
9. **[L-8]** Add a `build.sh` verification test + a "run build" step to the Gitea workflow before image build.
|
||||
13. **[L-3]** (optional) Remove inline `style="..."` attributes from `index.html`, then drop `style-src 'unsafe-inline'`.
|
||||
14. **[L-7]** (optional) Pin CI actions to SHAs; consider image signing.
|
||||
|
||||
## Verification Commands (after remediation)
|
||||
|
||||
```sh
|
||||
# Rebuild and check dist integrity
|
||||
./build.sh && grep -Eno '(href|src)="[a-z]+\.(css|js|jpeg|ico|svg)"' dist/index.html # expect: no unhashed refs
|
||||
|
||||
# Header checks on the running container
|
||||
curl -sI http://localhost:8080/ | grep -Ei 'content-security|x-frame|x-content-type|referrer'
|
||||
curl -sI http://localhost:8080/index.html | grep -Ei 'content-security|x-frame|x-content-type|referrer' # must match after M-4
|
||||
curl -sI http://localhost:8080/style.*.css | grep -Ei 'x-content-type'
|
||||
|
||||
# Live edge (run from a network that can reach it)
|
||||
curl -sI https://reeseapps.com/ | grep -Ei 'strict-transport|x-content-type|content-security'
|
||||
|
||||
# Secret re-scan
|
||||
git grep -iEn 'password|secret|api[_-]?key|BEGIN (RSA|OPENSSH|PRIVATE)' -- ':!dist'
|
||||
|
||||
# M-1 regression: type payload, Enter, Up-arrow in the terminal → no script/DOM injection
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# AGENTS.md
|
||||
|
||||
Rules for any agent working in this repository.
|
||||
|
||||
## Always
|
||||
1. **Read `.agents/PLAN.md` first** — project goals, architecture, and the
|
||||
**LOCKED DECISIONS** (Architectural Anchors) are binding.
|
||||
2. **Follow the phased protocol** in `.agents/phases/`:
|
||||
- `todo/NN_name/` — pending phases (`00_phase.md` overview + `NN_task.md`
|
||||
task files; task sort order = execution order).
|
||||
- `complete/` — finished phases (read-only history).
|
||||
- New work is captured as a phase directory via the `phase-authoring`
|
||||
skill; code changes flow through the `phased-execution` skill (each task
|
||||
runs in a fresh subprocess behind the validation gate).
|
||||
3. **Test before you ship:**
|
||||
- `./build.sh` — rebuild `dist/` (tests run against `dist/`, the
|
||||
deployed artifact — never test `src/` directly).
|
||||
- `npm test` — the Playwright E2E suite (pinned `@playwright/test`
|
||||
1.62.0; do not bump without re-checking the cached browser revision).
|
||||
- For any nginx/Dockerfile change: `./scripts/check-headers.sh`
|
||||
(Phase 03+).
|
||||
|
||||
## Never
|
||||
- Modify `.agents/PLAN.md` except via an explicit user-approved change to the
|
||||
LOCKED DECISIONS (record the approval in the PLAN's review history).
|
||||
- Modify anything in `.agents/phases/complete/`.
|
||||
- Edit files in `.agents/phases/todo/` without asking the user first
|
||||
(phase rewrites are a `phase-authoring` action).
|
||||
- Add runtime dependencies, CDNs, or a JS build toolchain (LOCKED: vanilla
|
||||
HTML/CSS/JS, flat `src/*.js`, `build.sh` hashing).
|
||||
- Add real network I/O to the fake terminal (LOCKED: simulated output only —
|
||||
see Phase 02 and PLAN §2.5).
|
||||
- Add `Strict-Transport-Security` inside the nginx container — TLS/HSTS are
|
||||
the Caddy edge's job (the container serves plain HTTP on 8080).
|
||||
|
||||
## Conventions
|
||||
- Static site: edits in `src/` → `./build.sh` → `dist/` (hash-cached names).
|
||||
New root-level assets are picked up automatically by `build.sh`; nested
|
||||
directories are **not** (keep JS files flat in `src/`).
|
||||
- Terminal modules: `terminal-commands.js` (canned data),
|
||||
`terminal-achievements.js`, `terminal-vim.js`, `terminal.js` (core) — load
|
||||
order is fixed in `index.html` (all `defer`); keep dependencies pointing
|
||||
only at earlier files.
|
||||
- Commits: small, imperative subject; one logical change per commit.
|
||||
- `dist/`, `node_modules/`, `test-results/`, `playwright-report/` are
|
||||
build/test artifacts — never edit them by hand.
|
||||
Reference in New Issue
Block a user