Summary
The Token Meter server binds 127.0.0.1:8722 with no authentication. Mutation POSTs are gated by X-Token-Meter-Action, but that token is generated once at process start and then returned in ordinary GET JSON (/state, /session, /updates/status, /menubar).
GET handlers do not check Host or Origin. POST origin checks are skipped when the Origin header is absent:
origin = self.headers.get("Origin") or ""
if origin and (urlparse(origin).hostname or "") not in ("localhost", "127.0.0.1", "::1"):
...
Impact
Anyone who can speak to loopback can read session metadata, costs, project names, and the live action token, then call:
POST /session/delete (move discovered .jsonl logs to Trash)
POST /agent-access/toggle (add/remove the tokenmeter MCP entry in Codex/Claude)
POST /capability/toggle and /capability/disable-unused (rewrite Claude/Codex plugin settings)
POST /updates/install (fetch + reinstall)
That includes every local UID on a shared machine (curl 127.0.0.1:8722/state). DNS rebinding is also in play because there is no Host allowlist: GET is enough to exfiltrate the token and dashboard JSON. Classic HTML-form CSRF is mostly blocked by the JSON content-type requirement; local processes and rebinding pages are not.
Where
token_meter/web/server.py (bind address)
token_meter/app.py (_ACTION_TOKEN, software_update_status, do_GET / do_POST)
menubar_software_update() also copies the token into /menubar
Suggested fix
- Reject any request whose
Host is not 127.0.0.1, localhost, or ::1 (with optional port).
- Do not put the action token on unauthenticated GET JSON. Prefer a
SameSite=Strict cookie, or a one-time bootstrap that is not readable cross-origin.
- Require a loopback
Origin on browser POSTs; do not treat a missing Origin as trusted.
- Add
Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY so the dashboard cannot be clickjacked.
Happy to send a patch against a fork if useful.
Summary
The Token Meter server binds
127.0.0.1:8722with no authentication. Mutation POSTs are gated byX-Token-Meter-Action, but that token is generated once at process start and then returned in ordinary GET JSON (/state,/session,/updates/status,/menubar).GET handlers do not check
HostorOrigin. POST origin checks are skipped when theOriginheader is absent:Impact
Anyone who can speak to loopback can read session metadata, costs, project names, and the live action token, then call:
POST /session/delete(move discovered.jsonllogs to Trash)POST /agent-access/toggle(add/remove thetokenmeterMCP entry in Codex/Claude)POST /capability/toggleand/capability/disable-unused(rewrite Claude/Codex plugin settings)POST /updates/install(fetch + reinstall)That includes every local UID on a shared machine (
curl 127.0.0.1:8722/state). DNS rebinding is also in play because there is noHostallowlist: GET is enough to exfiltrate the token and dashboard JSON. Classic HTML-form CSRF is mostly blocked by the JSON content-type requirement; local processes and rebinding pages are not.Where
token_meter/web/server.py(bind address)token_meter/app.py(_ACTION_TOKEN,software_update_status,do_GET/do_POST)menubar_software_update()also copies the token into/menubarSuggested fix
Hostis not127.0.0.1,localhost, or::1(with optional port).SameSite=Strictcookie, or a one-time bootstrap that is not readable cross-origin.Originon browser POSTs; do not treat a missingOriginas trusted.Content-Security-Policy: frame-ancestors 'none'andX-Frame-Options: DENYso the dashboard cannot be clickjacked.Happy to send a patch against a fork if useful.