Document the auth security model in README #40
@@ -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
|
||||
|
||||
@@ -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:<commit hash>` 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)
|
||||
|
||||
Reference in New Issue
Block a user