Cut the security-model threat table down to what the code cannot show
CI / full-gate (push) Successful in 2m32s
CI / full-gate (push) Successful in 2m32s
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user