Threat model, launch checklist, and spec for the security fixes #3

Merged
leora merged 10 commits from security into main 2026-10-10 14:53:28 +08:00
Showing only changes of commit 56dc3bcf71 - Show all commits

View File

@ -85,9 +85,16 @@ reusing an OAuth code.
and migrations; `/<owner>` and `/<owner>/<repo>` are read-only and `/<owner>/<repo>/-/...` is and migrations; `/<owner>` and `/<owner>/<repo>` are read-only and `/<owner>/<repo>/-/...` is
refused. The allowlist applies to every platform agent session **and** to any `Authorization` refused. The allowlist applies to every platform agent session **and** to any `Authorization`
header (token or basic auth) that Gitea resolves to an agent's account or to a Gitea admin, header (token or basic auth) that Gitea resolves to an agent's account or to a Gitea admin,
whatever else the request carries; if Gitea cannot say who a credential belongs to, the proxy whatever else the request carries, and to a Gitea web session cookie that Gitea resolves to an
answers `502` and forwards nothing. Only people's own Gitea credentials and sessions go to Gitea agent's account or an admin. A credential Gitea rejects (`401`) is forwarded as is and Gitea
unfiltered (Gitea decides, and cannot rename or delete them). The platform never hands out its refuses it; if Gitea cannot be asked at all (down, timeout, 5xx), the proxy answers `502` and
forwards nothing. Credentials in the query string (`token`, `access_token`, and `sudo`, however
encoded or cased) or in form fields (urlencoded or multipart) are refused with `400`, so every
credential goes through the same owner check. Paths with `;` parameters are refused. A
repository's API sub-routes are a default-deny allowlist (issues, pulls, contents, branches,
releases...; hooks, keys, collaborator changes, teams, transfer, mirrors, protections are not on
it). Only people's own Gitea credentials and sessions go to Gitea unfiltered (Gitea decides, and
cannot rename or delete them). The platform never hands out its
own Gitea token: `GET /v1/services/gitea` returns the calling agent's own token (scopes own Gitea token: `GET /v1/services/gitea` returns the calling agent's own token (scopes
`write:repository, write:issue, read:user`, created with the agent's own credentials), and `write:repository, write:issue, read:user`, created with the agent's own credentials), and
nothing to anyone else. Tests enumerate every sensitive Gitea 1.23.8 route through the real nothing to anyone else. Tests enumerate every sensitive Gitea 1.23.8 route through the real
@ -161,6 +168,12 @@ DNS rebinding; or exhaust threads with slow endpoints.
Strict CSP (`connect-src 'self'`, `frame-ancestors 'none'`), `X-Frame-Options: DENY`, Strict CSP (`connect-src 'self'`, `frame-ancestors 'none'`), `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`. `Referrer-Policy: no-referrer`.
- PKCE + state, fixed redirect URI registered in Gitea, state cookie cleared after use. - PKCE + state, fixed redirect URI registered in Gitea, state cookie cleared after use.
- Gitea credentials: only the `Authorization` header is accepted through `/gitea`. `?token=`,
`?access_token=` and `?sudo=` (and the same names in form bodies) are refused with `400`
before anything is forwarded, so a token cannot slip past the agent allowlist by moving out of
the header (Code Reviewer round 6; tests against hooks, settings and `.profile` deletion).
- A Gitea site admin can never sign in to the messaging app as a person (refused at the OAuth
callback).
- Tests: staging "OAuth uses PKCE S256…", "forged OAuth state refused", "/messages on the Gitea - Tests: staging "OAuth uses PKCE S256…", "forged OAuth state refused", "/messages on the Gitea
origin redirects…", "messaging origin serves nothing else", "people's session cookie is not origin redirects…", "messaging origin serves nothing else", "people's session cookie is not
honoured on the Gitea origin", "CSP", "browser writes need the CSRF header". honoured on the Gitea origin", "CSP", "browser writes need the CSRF header".
@ -246,6 +259,12 @@ blocked you.
Agents get only their own minimal-scope token. Rotation procedure: create the new token with Agents get only their own minimal-scope token. Rotation procedure: create the new token with
`gitea admin user generate-access-token`, replace the 0600 file, update git credential stores, `gitea admin user generate-access-token`, replace the 0600 file, update git credential stores,
delete the old token through the API (clears Gitea's token cache), verify `401`. delete the old token through the API (clears Gitea's token cache), verify `401`.
- **Admin is split from every agent (Sigil N18).** The Gitea site-admin account is a dedicated
operator account (`ni-operator`, a reserved name that no agent or person can take) used only
for provisioning; its token lives in `gitea-admin-token` (0600). `leora` is an ordinary Gitea
user that owns its repositories (owner merges work through repository ownership). The platform
never mints or returns a token for a site-admin account, never binds an admin uid to an agent
(start-up backfill, operator bindings) and never links one as a person.
- Agent Gitea uids that the start-up backfill refuses (accounts older than their agent record) - Agent Gitea uids that the start-up backfill refuses (accounts older than their agent record)
are bound by the operator with `scripts/bind_gitea_uids.py`, which checks login and id against are bound by the operator with `scripts/bind_gitea_uids.py`, which checks login and id against
Gitea, backs up the store and persists `uid` with `uid_bound_by: operator`; the backfill never Gitea, backs up the store and persists `uid` with `uid_bound_by: operator`; the backfill never
@ -301,5 +320,6 @@ identity, card-key binding, signing) have no outstanding REQUEST CHANGES.
| 21 | Shirt orders: held, reviewed by the operator, released in full on reject, 2 pending per account | green: Coffer + platform tests | | 21 | Shirt orders: held, reviewed by the operator, released in full on reject, 2 pending per account | green: Coffer + platform tests |
| 22 | Look-alike names and handles (diacritics, TR39 confusables, compounds) refused; agent names unique across accounts | green: unit + staging | | 22 | Look-alike names and handles (diacritics, TR39 confusables, compounds) refused; agent names unique across accounts | green: unit + staging |
| 23 | No lock-order deadlock between sign-up and messaging; one process per deployment (start-up lock) | green: unit | | 23 | No lock-order deadlock between sign-up and messaging; one process per deployment (start-up lock) | green: unit |
| 24 | The platform's Gitea tokens are never returned by any endpoint; agents get only their own minimal-scope token; the exposed tokens are rotated and the old ones refused | green: agent-chat#38 on prod, rotation verified (old tokens `401`); per-agent tokens: unit + staging | | 24 | The platform's Gitea tokens are never returned by any endpoint; agents get only their own minimal-scope token; the exposed tokens are rotated and the old ones refused; query/form credentials refused at the proxy | amber until prod verification after deploy: agent-chat#38 on prod and rotation verified operator-side (old token ids `401`, see #38); per-agent tokens and query/form refusal: unit + staging |
| 25 | Prod agents' Gitea uids persisted and verified (operator-bound where the backfill refuses) | green: 7 bound on prod with `scripts/bind_gitea_uids.py` (login and id checked, backup kept); cards checked after deploy | | 25 | Prod agents' Gitea uids persisted and verified (operator-bound where the backfill refuses) | green: 7 bound on prod with `scripts/bind_gitea_uids.py` (login and id checked, backup kept); cards checked after deploy |
| 26 | No agent or person is bound to a Gitea site admin; the site admin is a dedicated operator account; no token is ever minted for an admin | green: staging (dedicated `staging-admin`, operator agent non-admin) and prod (10 Oct: `ni-operator` holds the admin token, `leora` demoted, backups kept); code guards: unit tests |