# Phase 10 — Security hardening and defence in depth **Date:** 24 August 2026 **Baseline:** `1074ba2` (Phase 9) --- ## 1. Executive summary Phase 10 attacked the application rather than reading it. `scripts/verify-security.mjs` sends the requests an attacker would send — forged sessions, cross-origin posts, traversal payloads, SSRF probes, manipulated identifiers, hostile search terms — against a production build, and asserts on what comes back. It found **three real vulnerabilities**, all confirmed by exploiting them: 1. **A session survived logout.** Capture the cookie, sign out, replay the cookie, reach the dashboard. There was no server-side session record, so there was nothing to revoke. 2. **The sign-in throttle was bypassable with one header.** Rotating `X-Forwarded-For` got **12 of 12** password attempts through untouched — and each one cost the server a memory-hard scrypt hash, including for accounts that do not exist. 3. **Logout was CSRF-able.** A cross-origin `POST` to `/admin/logout` returned 303 and cleared the admin's session. Next.js gives Server Actions an Origin/Host check automatically; Route Handlers get nothing. All three are fixed, each with a regression test that fails if the fix is reverted. Five lower-severity findings were fixed alongside them. **The CSP question Phase 9 left open is settled, and the answer was not sitewide**: `/admin` is already `force-dynamic`, so nonces cost it nothing — strict nonce + `strict-dynamic` there, the documented baseline on the public site, with the baseline also acting as a fail-safe fallback for admin. **Security checks: 0 → 243.** Total automated checks **697 → 969**. Performance is unchanged: 189.6 KB JS, 72/72 budget checks, Lighthouse 100/100/100 with zero console errors — including on the nonce-protected admin. --- ## 2. Threat model **The attacker is assumed to know** the public site, every route, every byte of shipped JavaScript, every rendered HTML attribute, that `/admin` exists, and that database ids may be guessable. Everything obtainable by reading the site. **What they are attacking is not a blog.** The database holds, or will hold, student names, marks, subject-level results, photographs, personal stories, and enquiry details for families who asked about a course. **Many of the students are minors.** ### Assets, worst case, and current standing | Asset | Worst case | Standing | | --- | --- | --- | | Admin credentials | Full control of published student data | scrypt N=2^17, unique salts, generic errors, throttled before hashing | | Admin session | Impersonation for 8 hours | Signed, HttpOnly, Secure, SameSite, **revocable since Phase 10** | | Unpublished student records | A result published without consent | Filtered in SQL, never leaves the server unresolved | | Student photographs | A minor's photograph published unconsented | Independent `consentPhoto`, DB CHECK constraint, path allowlist | | Consent references | Paperwork identifiers exposed | Never in any DTO, verified absent from HTML and every public chunk | | Enquiry details | Parents' names and phone numbers leaked | No public data function reads them; verified across 9 surfaces | | `ipHash` | Re-identification of an enquirer | Keyed HMAC, never the raw address, **cleared after 30 days** | | Audit log | Loss of accountability | Retained 3 years, no personal data by construction | ### Attack surface Public pages · the enquiry form (the only unauthenticated write) · sign-in · the session cookie · 11 admin pages · 11 Server Actions · 1 Route Handler · the image optimiser · the sitemap · query parameters on `/results` and `/stories` · the proxy. --- ## 3. Vulnerabilities discovered and fixed ### 🔴 V1 — a captured session survived logout (session replay) **Confirmed by exploiting it.** Sign in, keep the cookie, `POST /admin/logout`, replay the cookie: **HTTP 200, dashboard rendered.** The session cookie is a signed bearer token with no server-side record — cheap and stateless, with one serious consequence: nothing can be revoked. Signing out cleared the cookie in the browser that asked and did nothing to a copy held anywhere else. A token that leaked from a shared machine, a proxy log or a backup stayed valid for its full eight hours whatever the admin did. **Fixed** with one column instead of a session table. The token now carries its issue time; `AdminUser.sessionsValidFrom` is the account's cut-off; a token issued before it is refused. Signing out moves the cut-off to now, invalidating every outstanding token for the account — which is the correct meaning of "sign me out" for a single-owner admin panel. `issuedAt` is inside the signed payload, so it cannot be rewritten to step around the check. Regression tests cover that, and the equality boundary (a token issued at exactly the cut-off must survive, or signing out would sign the admin out of the session they just created). ### 🔴 V2 — sign-in throttle bypassed by one header **Confirmed by exploiting it.** Twelve password attempts with a rotating `X-Forwarded-For`: **12 of 12 went through unthrottled.** The only throttle was keyed on a hashed client IP taken from a header the client sets. Two things were wrong, not one: - **Unlimited guesses** against a real password. - **Unlimited scrypt.** Each attempt cost the *server* an N=2^17 hash — memory-hard by design, ~128 MB — and one ran even for accounts that do not exist, to equalise timing. That made the sign-in form a memory-exhaustion amplifier for anyone able to set a header. **Fixed with three layers**, each cheaper than the next: 1. **Per-instance ceiling** on total sign-in work (60/minute), before any database round trip. Bounds the amplification regardless of account. 2. **Per-account failure counter in the database** (10 failures / 15 minutes), checked **before hashing**. Survives header rotation, spread load and process restarts. 3. **Per-IP burst limiter** (unchanged) for the naive case. ⚠ **The trade, stated plainly.** Someone who knows the admin's email can keep the account throttled with wrong passwords. That is a real availability cost and it is the accepted one: an attacker who can annoy the owner for fifteen minutes is a far smaller problem than one who can grind the password forever. Recovery is automatic — no manual unlock to get wrong, nothing to support over the phone. The threshold is generous so ordinary mistyping never reaches it. A second bug surfaced while fixing this: the new `throttled` outcome initially fell through to *"That email or password is not correct"*. Telling the owner their password is wrong when it is merely rate-limited sends them to reset a password that works, **and hides from them that something is hammering their account.** It now says so. ### 🟠 V3 — logout was CSRF-able **Confirmed by exploiting it.** `POST /admin/logout` with `Origin: https://evil.example` → **303, session cleared.** Route Handlers do not get the automatic Origin/Host comparison Next.js applies to Server Actions. Forced logout is a nuisance rather than a takeover — but it is a state change triggered by a third-party page, and the next Route Handler this project adds might not be a nuisance. **Fixed** with `src/lib/request-guard.ts`, a reusable same-origin guard, applied to the logout handler. `Origin` is authoritative; `Referer` is a fallback for browsers old enough not to send `Origin` on a POST (without it those clients cannot sign out at all, and a security fix that breaks logout gets reverted); neither present means a non-browser client and is refused. **Fails closed.** ### 🟡 V4 — `JSON.stringify` into `dangerouslySetInnerHTML` `JSON.stringify` escapes what JSON needs and nothing more, so a value containing `` closes the block early and the rest is parsed as HTML. Every field we emit comes from static configuration today, so it was not reachable — it was one edit to `src/config/institute.ts` away from being reachable. **Fixed:** `jsonLdScript()` escapes `<`, `>` and `&` to unicode escapes. Still valid JSON, identical parsed value. Asserted on every rendered JSON-LD block. ### 🟡 V5 — unbounded public pagination `?page=` had a lower bound and no upper one. `?page=999999999` became `OFFSET 23999999976`, which Postgres answers by walking the index to a row that does not exist — one cheap request buying an expensive scan, repeatable free, on an **unauthenticated** endpoint. **Fixed:** clamped twice — to a ceiling before querying, and to the real page count once known, so a request for page nine million renders "Page 1 of 1" rather than an empty page pretending otherwise. Admin pagination clamped too. ### 🟡 V6 — unbounded credential input An unauthenticated endpoint accepted an unbounded password and email, both of which reached `normalize('NFKC')` and (for the password) scrypt. **Fixed:** 200 characters for a password, 254 for an email, enforced in `signIn` *and* independently inside `verifyPassword`, so a caller that skips its own check cannot hand an unbounded string to the hash. ### 🟡 V7 — record ids reached the database unvalidated Prisma parameterises, so this was never injectable. What it was is unbounded attacker-controlled input handed to Postgres: a 5,000-character id and a JSON object literal both reached the database before being rejected there. **Fixed:** `isValidRecordId()` on every mutation that takes an id — delete, unpublish, status change, notes, and the update branch of every save action. A malformed id on a save now **refuses** rather than falling through to the create branch, which would have silently duplicated the record. ### 🟡 V8 — no retention policy Every phase before this added data and none removed any. `ipHash` — a per-person identifier — was retained forever to support a check that only looks back 24 hours. **Fixed:** `src/lib/retention-policy.ts` states the policy and the reasoning; `scripts/retention.mjs` applies it. See §12. ### ℹ️ V9 — deprecated `middleware.ts` Next 16 deprecated the convention. Migrated to `src/proxy.ts`, which is where the admin CSP now lives too. The deprecation warning is gone from the build. --- ## 4. Authentication architecture | Property | Implementation | Verified | | --- | --- | --- | | Hashing | scrypt (RFC 7914), N=2^17, r=8, p=1, 64-byte key | ✅ parameters asserted from a stored hash | | Salt | 16 random bytes, unique per hash | ✅ two hashes of one password differ | | Parameters | Encoded with the hash, so they can be raised later | ✅ | | Comparison | `timingSafeEqual` on equal-length buffers | ✅ | | Storage | Never plaintext, never logged, never in a URL | ✅ | | Enumeration | Unknown account and wrong password return the **identical** message | ✅ byte-compared | | Timing | A dummy hash runs when no account exists | ✅ | | Self-registration | None. Accounts are seeded deliberately | ✅ | | Minimum length | 12 characters | ✅ | | Maximum length | 200 characters, checked before any hashing | ✅ (Phase 10) | | Failure record | Counted per account; **emails never written** | ✅ | **No password appears** in the repository, git history, any client bundle, any HTML, any source map, or any log. The suite's own admin password is generated at runtime and never written to disk. --- ## 5. Session architecture ``` sign in -> adminId . issuedAt . expiresAt . HMAC-SHA256(secret, payload) stored in an HttpOnly, Secure, SameSite=Lax, Path=/ cookie 8-hour absolute expiry read -> length bound -> shape -> SIGNATURE -> lifetime sanity -> expiry -> account exists -> account active -> NOT REVOKED ``` | Property | Standing | | --- | --- | | Unpredictable | HMAC-SHA256 over a server secret ≥32 chars; absent in production the app refuses to start | | HttpOnly / Secure / SameSite / Path / Expiry | ✅ all asserted on the real Set-Cookie | | Contains no credential | ✅ asserted | | Absolute expiry | 8 hours, signed, and a token claiming a longer span is refused | | Signature checked **before** expiry | ✅ probing with an unsigned token reveals nothing about lifetimes | | Deactivation | Immediate — the account is re-read on every request | | **Revocation** | ✅ **new** — `sessionsValidFrom`; logout invalidates every outstanding token | | Fixation | Not applicable: the token is server-minted and carries a server timestamp; nothing client-supplied is adopted | | Replay after logout | ✅ **closed** | Nine forged-token shapes are rejected (garbage, empty, wrong signature, altered id, extended expiry, expired-but-signed, no signature, extra segment, missing segment), plus a cross-account forgery: one admin's signature cannot authenticate another admin's id. **No session table was added.** One column achieved revocation; a table would have been complexity without a matching benefit for a single-owner panel. --- ## 6. Authorization model **Every admin mutation independently calls `requireAdminOrNull()` inside the action.** The proxy is a redirect for a signed-out browser, not the boundary — a Server Action is an HTTP endpoint reachable without any page ever rendering. Verified by calling **11 admin routes × 4 credential states** and the student creation mutation directly: | Operation | Public | Unauthenticated | Admin | | --- | :-: | :-: | :-: | | View public pages | ✅ | ✅ | ✅ | | Submit enquiry | ✅ | ✅ | ✅ | | View dashboard / enquiries / preview | ❌ | ❌ | ✅ | | Create / edit / delete student | ❌ | ❌ | ✅ | | Publish result / story / photograph | ❌ | ❌ | ✅ | | Unpublish | ❌ | ❌ | ✅ | | Create batch / announcement | ❌ | ❌ | ✅ | | Change enquiry status / notes | ❌ | ❌ | ✅ | Every non-admin case **failed closed** — 307 to sign-in, and **zero rows created** by unauthenticated mutation attempts. **IDOR.** With one admin account there is no cross-tenant boundary to cross today. What is enforced now is that ids are validated and scoped at the query, so adding roles later is a change to `requireAdmin`, not a rewrite. Ten hostile id shapes were substituted into real mutation forms: none produced a server error, and none altered an unrelated record. --- ## 7. CSRF decision **Server Actions** — Next compares `Origin` against `Host` and aborts on mismatch. Verified rather than assumed: a cross-origin POST carrying a **valid session cookie** created **zero rows**. No token layer was added on top; a second mechanism duplicating a working one is appearance, not defence. **Route Handlers** — get none of that, which is V3. Now guarded explicitly. **The enquiry form** is deliberately *not* CSRF-protected in the classic sense, and that is correct: it is an unauthenticated public endpoint, so there is no victim's authority to borrow. It carries a signed timing token and a honeypot against automation, which is the threat that actually applies to it. --- ## 8. CSP decision Phase 9 found `script-src 'self'` broke the site — Next streams the RSC payload as inline `