Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 702e42de09 | |||
| 98bdd2c1b5 | |||
| 12913402a6 | |||
| 7dee80a976 | |||
| c0fe8b0a82 | |||
| fb7d20a7db |
@@ -7,7 +7,7 @@ jobs:
|
|||||||
full-gate:
|
full-gate:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.4.0
|
- uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||||
- run: bash ci.sh
|
- run: bash ci.sh
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
github-mirror:
|
github-mirror:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.4.0
|
- uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
prune-stale-images:
|
prune-stale-images:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.4.0
|
- uses: actions/checkout@v4.2.2
|
||||||
- name: Delete hash images that are neither release-tagged nor a branch head
|
- name: Delete hash images that are neither release-tagged nor a branch head
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
retag-image:
|
retag-image:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.4.0
|
- uses: actions/checkout@v4.2.2
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
needs: renovate
|
needs: renovate
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.4.0
|
- uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Tag a release for what Renovate merged
|
- name: Tag a release for what Renovate merged
|
||||||
|
|||||||
@@ -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)
|
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||||
- [three tiers](#three-tiers-of-may-i)
|
- [three tiers](#three-tiers-of-may-i)
|
||||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||||
|
- [security model](#security-model)
|
||||||
- [Email](#email)
|
- [Email](#email)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Stateless](#stateless)
|
- [Stateless](#stateless)
|
||||||
@@ -836,14 +837,29 @@ 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
|
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
|
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
|
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||||
are production-only** — neither blocks a clean clone:
|
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`) |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
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.
|
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
|
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||||
@@ -997,6 +1013,76 @@ 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;
|
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||||
writes go only to Hydra.
|
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 (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 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 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.
|
||||||
|
|
||||||
|
**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`, `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.
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||||
@@ -1397,7 +1483,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`.
|
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
|
(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.)
|
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's JWT
|
||||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||||
present the new key.
|
present the new key.
|
||||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||||
|
|||||||
@@ -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"] });
|
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 };
|
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/);
|
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.
|
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
- [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 - 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] 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] 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.
|
- [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.)
|
||||||
- [ ] The human developer understands the security model in the auth in this project.
|
- [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.
|
- [ ] Add i18n support.
|
||||||
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` 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)
|
## Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user