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:
@@ -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
|
||||
Reference in New Issue
Block a user