Add phased-execution planning structure
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:
2026-09-18 17:23:41 -04:00
parent 010758b3e9
commit a2f331e526
13 changed files with 963 additions and 0 deletions
+112
View File
@@ -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).
View File
@@ -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
+238
View File
@@ -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
```
+46
View File
@@ -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.