From 41c568796c75376111847be3e1d76d1cf2931b7a Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 13:28:56 +0200 Subject: [PATCH] Cut the security-model threat table down to what the code cannot show --- README.md | 63 +++++++++++++------------------------------------------ todo.md | 2 +- 2 files changed, 16 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index fa41009..b230b0f 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,8 @@ reference `examples/plugins/scheduling/views/overview.ejs` does; a value you for shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the -secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.) +secret and sets the cookie; the plugin never touches it. It is **opt-in per handler** — a route +that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.) The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the login/registration/front pages), so the menu looks identical signed in or out — it just role-filters. @@ -1139,64 +1140,30 @@ 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. +Everything above is *how* auth works. These are the few things the code won't tell you quickly, +and that get a deployment wrong if you don't know them. -**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 (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 authorization 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 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). -- **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 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 authorization 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 a browser must reach. Never +expose one, and never front one with a proxy that lacks its own auth. **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 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`) — 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; 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) | -| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` | -| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call | - **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** 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 (on the instance that handled it) and deactivating or deleting the identity ends the -session; with it off, both land within one token TTL. +**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session, +re-reading roles from Keto — so a revoked role, or a deactivated identity, lands within one +token TTL rather than immediately. With the +[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at +once, on the instance that handled the change. **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 diff --git a/todo.md b/todo.md index ba07cd4..4fe60d7 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 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". Follow-up: the *threat* model turned out not to be the part that was unclear — the **authorization** model was. README gained a top-level [Users, groups & roles](README.md#users-groups--roles) section (entity table, worked graph, per-route can/cannot walkthrough, the "a per-row grant never widens a coarse gate" trap), placed before Building plugins because a manifest's `role:` gate is unreadable without it.) +- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & roles](README.md#users-groups--roles) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `role:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.) - [ ] Add i18n support. - [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:`, and the session type `User` → `SessionIdentity` with `ctx.user` → `ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, roles }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.) - [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.