Summary
Local-only writes have no CSRF token. Locality is inferred from the absence of a header that browsers never set cross-origin, so a page in the operator's browser can POST JSON to these routes using a simple-request content type, and the request is indistinguishable from a deliberate operator action.
Evidence
Unprotected local-only writes:
fleet/bin/fleet.py:1086 — /api/signatures/judge, rewrites the whole signatures file (fleet.py:1112).
fleet/bin/fleet.py:1057 — /api/convene, subprocess.Popen at fleet.py:1074.
- Every
require_local cockpit write: approvals approve/revoke (main.py:556, 595), handoffs (main.py:655), touch (main.py:672), horizons (main.py:718), triage (main.py:1130), promote (main.py:1211).
Already protected, and the model to follow: /api/kill (fleet.py:1151) and /api/paste-image (fleet.py:1132) require KILL_TOKEN in the body. That is what the token is actually good for — /api/kill-token (fleet.py:868) hands it to any same-origin page, so it is a CSRF token, not an authN credential.
Impact
Includes /api/signals/{id}/promote, which is the human airlock crossing from inbox.json into life.json. A CSRF against it means a stranger's signal is promoted without the operator ever reading it.
Composes with the stored-XSS issue: same-origin XSS reads the token, so a CSRF token alone is insufficient while that is open. Both need fixing.
Acceptance criteria
- Local-only writes require
KILL_TOKEN, matching /api/kill.
- Tests cover a cross-origin simple-request POST to at least
/api/convene and /api/signals/{id}/promote.
Summary
Local-only writes have no CSRF token. Locality is inferred from the absence of a header that browsers never set cross-origin, so a page in the operator's browser can POST JSON to these routes using a simple-request content type, and the request is indistinguishable from a deliberate operator action.
Evidence
Unprotected local-only writes:
fleet/bin/fleet.py:1086—/api/signatures/judge, rewrites the whole signatures file (fleet.py:1112).fleet/bin/fleet.py:1057—/api/convene,subprocess.Popenatfleet.py:1074.require_localcockpit write: approvals approve/revoke (main.py:556,595), handoffs (main.py:655), touch (main.py:672), horizons (main.py:718), triage (main.py:1130), promote (main.py:1211).Already protected, and the model to follow:
/api/kill(fleet.py:1151) and/api/paste-image(fleet.py:1132) requireKILL_TOKENin the body. That is what the token is actually good for —/api/kill-token(fleet.py:868) hands it to any same-origin page, so it is a CSRF token, not an authN credential.Impact
Includes
/api/signals/{id}/promote, which is the human airlock crossing frominbox.jsonintolife.json. A CSRF against it means a stranger's signal is promoted without the operator ever reading it.Composes with the stored-XSS issue: same-origin XSS reads the token, so a CSRF token alone is insufficient while that is open. Both need fixing.
Acceptance criteria
KILL_TOKEN, matching/api/kill./api/conveneand/api/signals/{id}/promote.