From fb7d20a7db3a9357f8f3a53f69ea960bee961fca Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:03:09 +0200 Subject: [PATCH 1/6] Document the auth security model in README --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ todo.md | 4 ++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2827b8e..3e8c895 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ From here, render real pages against the app shell and fetch upstream data — s - [instant revoke](#instant-revoke-the-optional-denylist) - [three tiers](#three-tiers-of-may-i) - [OAuth2 (Hydra)](#oauth2-provider-hydra) + - [security model](#security-model) - [Email](#email) - [Architecture](#architecture) - [Stateless](#stateless) @@ -997,6 +998,60 @@ generated `client_secret` **once**, on the confirmation page — confidential cl delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra. +### Security model + +Everything above is *how* auth works. This is what to check a change against: who is trusted, +what defends what, and which guarantees are deliberately not offered. + +**Trust boundaries.** + +- **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled + until verified or escaped. Nothing is believed because of where it arrived from. +- **The session JWT is trusted only after verification** — signature against the JWKS key its + `kid` names, then `exp`/`nbf` and the optional `iss`/`aud`. Before that it is bytes. +- **The private container network is the *only* thing guarding the Ory admin APIs.** Kratos + admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching + them *is* full identity and permission control. `compose.yml` publishes none of them (guarded + by `src/compose.test.ts`); dev publishes only the two ports a browser must reach. Never + expose an admin port, and never front one with a proxy that lacks its own auth. +- **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host + can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a + deliberate non-goal). +- **Row-level rules belong upstream**, in the service that owns the data — see + [three tiers](#three-tiers-of-may-i). + +**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own +`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. +Never put anything in a claim you wouldn't show them. + +**What defends what.** + +| Threat | Defense | +| --- | --- | +| Forged or tampered token | signature verified against the `kid`'s JWKS key; the `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets an attacker-supplied key verify (`src/auth/jwt.ts`) | +| `alg` confusion | a key's own `alg` must match the header's, and its type must match the family | +| Replayed expired token | `exp`/`nbf`, `JWT_CLOCK_SKEW_SEC` leeway | +| Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | +| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`), ~10m TTL, plus the [denylist](#instant-revoke-the-optional-denylist) | +| CSRF on our own forms | signed double-submit token (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | +| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) | +| Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | +| Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | +| Privilege escalation | roles are authored **only** in Keto; the identity projection is a derived read-only cache, re-read from Keto at every mint | +| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` | +| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call | + +**Fail closed.** Every rejection converges on two outcomes: a missing, malformed, expired or +revoked token yields *anonymous* — never a partly-trusted user — and an anonymous or +under-privileged request is denied (`requireSession` bounces to `/login`; `can`/`check` return +`false`). No verification failure degrades into access. + +**Not guaranteed** — accepted, and stated where each mechanism is: role changes +[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the +denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist). +Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true` and `SECURE_COOKIES=true` over the +production secrets — see [what you must supply](#what-you-must-supply-the-only-manual-prep). + ## Email The only emails are the **recovery** and **verification** codes from Kratos' self-service diff --git a/todo.md b/todo.md index 1627ef4..970ba94 100644 --- a/todo.md +++ b/todo.md @@ -14,8 +14,8 @@ - [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.) - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) -- [ ] Build and publish docker image as CI/CD. -- [ ] The human developer understands the security model in the auth in this project. +- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim. Every claim it makes is already enforced by an existing test.) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From c0fe8b0a82994d46ba36a66e3a73169d22969dfb Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:14:20 +0200 Subject: [PATCH 2/6] Review fixes: re-mint path, real session lifetime, full secret checklist --- README.md | 80 +++++++++++++++++++++++++++++++++++-------------------- todo.md | 2 +- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3e8c895..f86f241 100644 --- a/README.md +++ b/README.md @@ -837,14 +837,23 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300 A clean clone needs **none** of the above — `docker compose up` brings up the whole stack with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see -[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both -are production-only** — neither blocks a clean clone: +[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it +blocks a clean clone: + +1. **Production secrets** — every value below ships as a committed dev throwaway that works + out of the box and **must** be replaced before a deploy faces the internet. Only the first + is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway + `CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist. + + | Secret | Where | Protects | + | --- | --- | --- | + | `CSRF_SECRET` | web env | signs our double-submit CSRF token | + | JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) | + | `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies | + | `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest | + | `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest | + | `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) | -1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus - the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see - [JWT signing key & rotation](#jwt-signing-key--rotation)). Set - `REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied - and differs from the throwaway. 2. **SSO provider client id/secret** — **optional**; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see [Social sign-in (SSO)](#social-sign-in-sso)). @@ -1008,17 +1017,19 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names, then `exp`/`nbf` and the optional `iss`/`aud`. Before that it is bytes. -- **The private container network is the *only* thing guarding the Ory admin APIs.** Kratos - admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching - them *is* full identity and permission control. `compose.yml` publishes none of them (guarded - by `src/compose.test.ts`); dev publishes only the two ports a browser must reach. Never - expose an admin port, and never front one with a proxy that lacks its own auth. + `kid` names (or the sole key, when the set has one), then a **mandatory** `exp`, plus `nbf` + and the optional `iss`/`aud`. Before that it is bytes. +- **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin + (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them + *is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses + the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six + (guarded by `src/compose.test.ts`); dev publishes only the two Ory ports a browser must reach. + Never expose one, and never front one with a proxy that lacks its own auth. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a deliberate non-goal). -- **Row-level rules belong upstream**, in the service that owns the data — see - [three tiers](#three-tiers-of-may-i). +- **Attribute-based row rules belong upstream**, in the service that owns the data; + relationship-based ones go to Keto — see [three tiers](#three-tiers-of-may-i). **The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own `sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user. @@ -1028,29 +1039,40 @@ Never put anything in a claim you wouldn't show them. | Threat | Defense | | --- | --- | -| Forged or tampered token | signature verified against the `kid`'s JWKS key; the `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets an attacker-supplied key verify (`src/auth/jwt.ts`) | -| `alg` confusion | a key's own `alg` must match the header's, and its type must match the family | -| Replayed expired token | `exp`/`nbf`, `JWT_CLOCK_SKEW_SEC` leeway | +| Forged or tampered token | signature verified by `kid`; `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets a forged token verify (`src/auth/jwt.ts`) | +| `alg` confusion | a key that pins an `alg` must match the header's; the key type must always match the family | +| Replayed expired token | `exp` is mandatory — a token without one is rejected, never treated as eternal; `JWT_CLOCK_SKEW_SEC` leeway | | Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | -| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`), ~10m TTL, plus the [denylist](#instant-revoke-the-optional-denylist) | -| CSRF on our own forms | signed double-submit token (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | -| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) | +| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below | +| CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | +| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) — the `*.html` slots stay [raw by contract](#routes--handlers) | | Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | | Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | -| Privilege escalation | roles are authored **only** in Keto; the identity projection is a derived read-only cache, re-read from Keto at every mint | +| Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) | | Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` | | A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call | -**Fail closed.** Every rejection converges on two outcomes: a missing, malformed, expired or -revoked token yields *anonymous* — never a partly-trusted user — and an anonymous or -under-privileged request is denied (`requireSession` bounces to `/login`; `can`/`check` return -`false`). No verification failure degrades into access. +**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos' +`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed +JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our +two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config. + +**Fail closed — with one deliberate exception.** A token that cannot be verified (missing, +malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user, +and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check` +return `false`). An **expired or revoked** token instead triggers a re-mint against the live +Kratos session — full re-authentication with roles re-read from Keto, or a cleared cookie if +that session is dead; Ory unreachable ⇒ anonymous. Revoking a role therefore *downgrades* a +user promptly without signing them out; **ending a session outright means deactivating or +deleting the identity.** **Not guaranteed** — accepted, and stated where each mechanism is: role changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist). -Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true` and `SECURE_COOKIES=true` over the -production secrets — see [what you must supply](#what-you-must-supply-the-only-manual-prep). +Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing +**every** committed dev secret — see +[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS` +guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's or Postgres' throwaways. ## Email @@ -1452,7 +1474,7 @@ container-relative; with the dev bind-mount they edit the real file). 2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a brief Kratos blip only touches login/re-mint.) -3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT +3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: diff --git a/todo.md b/todo.md index 970ba94..6a5640a 100644 --- a/todo.md +++ b/todo.md @@ -15,7 +15,7 @@ - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim. Every claim it makes is already enforced by an existing test.) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by an existing test; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From 7dee80a976c66c838903789834cde8630f0a4370 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:25:07 +0200 Subject: [PATCH 3/6] Review fixes: denylist-conditional revoke, Ory secret wiring, exp guard test --- README.md | 25 +++++++++++++++---------- src/auth/jwt-middleware.test.ts | 4 +++- todo.md | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f86f241..cdcf40a 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,10 @@ blocks a clean clone: | `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest | | `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) | + `CSRF_SECRET` and the Postgres pair are interpolated from the host environment. The three + Ory secrets are **not**: `compose.yml` passes only `DSN` to `kratos`/`hydra`, so add them to + those services' `environment:` (or an `env_file:`) or they silently stay on the throwaways. + 2. **SSO provider client id/secret** — **optional**; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see [Social sign-in (SSO)](#social-sign-in-sso)). @@ -1017,12 +1021,12 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names (or the sole key, when the set has one), then a **mandatory** `exp`, plus `nbf` + `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. - **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them *is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses - the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six + the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two Ory ports a browser must reach. Never expose one, and never front one with a proxy that lacks its own auth. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host @@ -1045,7 +1049,7 @@ Never put anything in a claim you wouldn't show them. | Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning | | Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below | | CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token | -| XSS | EJS `<%= %>` escapes; CSP `script-src 'self'` with no `'unsafe-inline'` (`src/http/security-headers.ts`) — the `*.html` slots stay [raw by contract](#routes--handlers) | +| XSS | EJS `<%= %>` escapes; the CSP blocks inline script ([headers](#production--deployment)) — the `*.html` slots stay [raw by contract](#escaping--the-trust-boundary) | | Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` | | Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) | | Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) | @@ -1060,11 +1064,13 @@ two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' o **Fail closed — with one deliberate exception.** A token that cannot be verified (missing, malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user, and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check` -return `false`). An **expired or revoked** token instead triggers a re-mint against the live -Kratos session — full re-authentication with roles re-read from Keto, or a cleared cookie if -that session is dead; Ory unreachable ⇒ anonymous. Revoking a role therefore *downgrades* a -user promptly without signing them out; **ending a session outright means deactivating or -deleting the identity.** +return `false`). An **expired** token instead triggers a re-mint: re-validation against the live +Kratos session, roles re-read from Keto, or a cleared cookie if that session is dead; Ory +unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the +[denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through +that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at +once and deactivating or deleting the identity ends the session; with it off, both land within +one token TTL. **Not guaranteed** — accepted, and stated where each mechanism is: role changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the @@ -1474,8 +1480,7 @@ container-relative; with the dev bind-mount they edit the real file). 2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a brief Kratos blip only touches login/re-mint.) -3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie - header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients +3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: ```bash diff --git a/src/auth/jwt-middleware.test.ts b/src/auth/jwt-middleware.test.ts index 94a20a1..cb01c5a 100644 --- a/src/auth/jwt-middleware.test.ts +++ b/src/auth/jwt-middleware.test.ts @@ -29,8 +29,10 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] }); }); -test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => { +test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => { const opts = { clockSkewSec: 60, now: NOW }; + // No exp ⇒ rejected outright: an exp-less token must never read as eternal. + await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/); await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/); // exp 30s in the past but inside the 60s skew → still accepted. await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts); diff --git a/todo.md b/todo.md index 6a5640a..7a3e01c 100644 --- a/todo.md +++ b/todo.md @@ -15,7 +15,7 @@ - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) -- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by an existing test; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) +- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. ## Architectural review findings (2026-07-02) -- 2.52.0 From 12913402a6f7933a0316f1e1681050e76ac142aa Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:33:04 +0200 Subject: [PATCH 4/6] Add the seeded admin login to the production secrets checklist --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cdcf40a..aa9a3d9 100644 --- a/README.md +++ b/README.md @@ -853,10 +853,12 @@ blocks a clean clone: | `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest | | `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest | | `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) | + | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) | - `CSRF_SECRET` and the Postgres pair are interpolated from the host environment. The three - Ory secrets are **not**: `compose.yml` passes only `DSN` to `kratos`/`hydra`, so add them to - those services' `environment:` (or an `env_file:`) or they silently stay on the throwaways. + `CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host + environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to + `kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they + silently stay on the throwaways. 2. **SSO provider client id/secret** — **optional**; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see @@ -1069,8 +1071,8 @@ Kratos session, roles re-read from Keto, or a cleared cookie if that session is unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the [denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at -once and deactivating or deleting the identity ends the session; with it off, both land within -one token TTL. +once (on the instance that handled it) and deactivating or deleting the identity ends the +session; with it off, both land within one token TTL. **Not guaranteed** — accepted, and stated where each mechanism is: role changes [lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the @@ -1078,7 +1080,7 @@ denylist is [single-instance and skips group changes](#instant-revoke-the-option Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing **every** committed dev secret — see [what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS` -guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's or Postgres' throwaways. +guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's throwaways. ## Email @@ -1480,7 +1482,8 @@ container-relative; with the dev bind-mount they edit the real file). 2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a brief Kratos blip only touches login/re-mint.) -3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients +3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT + header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients present the new key. 4. **Wait ~12 min**, then **prune** the superseded key: ```bash -- 2.52.0 From 98bdd2c1b536e4e2d731d739336bf30aa5f70967 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:33:44 +0200 Subject: [PATCH 5/6] Record the open CSRF token-binding decision as a todo item --- todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/todo.md b/todo.md index 7a3e01c..e24a18f 100644 --- a/todo.md +++ b/todo.md @@ -17,6 +17,7 @@ - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) - [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [ ] Add i18n support. +- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `.` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer. ## Architectural review findings (2026-07-02) -- 2.52.0 From 702e42de09d352f497eb21c70c5b3ad8019798f0 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 2 Aug 2026 17:34:57 +0200 Subject: [PATCH 6/6] Reflow the security-model prose to the file's wrap width --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa9a3d9..a095c5f 100644 --- a/README.md +++ b/README.md @@ -1023,13 +1023,13 @@ what defends what, and which guarantees are deliberately not offered. - **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled until verified or escaped. Nothing is believed because of where it arrived from. - **The session JWT is trusted only after verification** — signature against the JWKS key its - `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, plus `nbf` - and the optional `iss`/`aud`. Before that it is bytes. + `kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`, + plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. - **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them *is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses - the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six Ory ports - (guarded by `src/compose.test.ts`); dev publishes only the two Ory ports a browser must reach. + the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six + Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach. Never expose one, and never front one with a proxy that lacks its own auth. - **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a @@ -1080,7 +1080,8 @@ denylist is [single-instance and skips group changes](#instant-revoke-the-option Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing **every** committed dev secret — see [what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS` -guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's throwaways. +guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's +throwaways. ## Email -- 2.52.0