fix(security): poll usage only with the operator's own credential - #3863
Conversation
The Claude subscription tracker adopted any caller's OAuth bearer as the account to poll, and the Codex /wham/usage refresher polled with any caller's bearer and ChatGPT account id. On a shared proxy that spent a network caller's credential on a request they never made and published their usage on the operator's dashboard (01-F16). New headroom.subscription.credential_policy decides who may drive a poll: the operator-configured credential (CLAUDE_CODE_OAUTH_TOKEN or the proxy user's Claude Code credentials file) always wins; a bearer learned from traffic is adopted only from the local operator - a direct loopback peer with no forwarding headers; HEADROOM_SUBSCRIPTION_TRAFFIC_TOKEN=off disables learning from traffic entirely. Network callers still mark the tracker active. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
JerrettDavis
left a comment
There was a problem hiding this comment.
The local-operator predicate does not fail closed for all forwarding evidence supported by this proxy. On the exact head, a loopback connection with only X-Forwarded-Proto: https returns True, and one with only X-Forwarded-Host: public.example also returns True. Both headers are first-class inputs in headroom/proxy/forwarded_headers.py; a same-host gateway can emit either while omitting X-Forwarded-For, so a tenant bearer can still be adopted as the operator credential. Please reject at least the complete supported X-Forwarded-* triple (and add proto-only/host-only tests), or centralize the decision on the existing forwarding-header policy so future supported forwarding headers cannot reopen this boundary. The direct reproductions are is_local_operator_connection(loopback_conn_with_x_forwarded_proto) is True and the same for x-forwarded-host.
…s-operator-credential
Adopting a bearer only from a direct loopback caller is the whole 01-F16 fix. HEADROOM_SUBSCRIPTION_TRAFFIC_TOKEN=off was a knob nobody asked for, and re-ordering _maybe_poll so a credentials file outranks the local operator's live session changed which account a single developer sees without closing anything: a network caller's bearer is no longer stored, and fetch(None) already falls back to the operator credential. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local-operator predicate only rejected Forwarded, X-Forwarded-For and X-Real-IP, so a same-host gateway sending only X-Forwarded-Proto or X-Forwarded-Host still let a tenant bearer be adopted as the polled credential. Reject the whole X-Forwarded-* family so future forwarding headers cannot reopen the boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-operator-credential
|
Thanks, good catch. Fixed in 739ce9b: |
JerrettDavis
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 2d764f3. Local-operator detection now fails closed for Forwarded, X-Real-IP, and the entire X-Forwarded-* family, including mixed-case Starlette headers; network/forwarded callers mark activity without donating their bearer to the poller. Local result: 66 focused tests passed and Ruff clean; one handler lifecycle test was blocked only by this workstation's unavailable Rust extension, while exact-head hosted test/CodeQL checks are green.
There was a problem hiding this comment.
Devin Review found 1 potential issue.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| client = getattr(conn, "client", None) | ||
| host = getattr(client, "host", None) if client is not None else None | ||
| if not isinstance(host, str) or not is_loopback_host(host): | ||
| return False | ||
| headers = getattr(conn, "headers", None) | ||
| if headers is not None: | ||
| try: | ||
| for name in headers.keys(): | ||
| name = name.lower() | ||
| if name in _FORWARDING_HEADERS or name.startswith(_FORWARDING_HEADER_PREFIX): | ||
| return False | ||
| except Exception: | ||
| return False | ||
| return True |
There was a problem hiding this comment.
🟥 Loopback callers can replace the operator account
A non-operator reaching loopback without forwarding headers passes is_local_operator_connection and supplies the token used for usage polling. Loopback and header absence do not establish caller identity, so another local process can replace the dashboard's account.
Was this helpful? React with 👍 or 👎 to provide feedback.
## Why `OAuth2Middleware` rewrote `Authorization` on every HTTP request (findings 03-F4 and 03-F5). Routes the proxy answers itself (`/health`, `/stats*`, `/metrics`, `/v1/compress*`, extension `/ext/*` routes and the rest of the management surface) never go upstream. Yet each one minted a token when the cache was cold, and an unreachable IdP turned health probes and management calls into 502. Separately, the startup log printed the full token URL, which can carry a query string or embedded credentials, and the mint log printed the scope list. ## What changes - The bearer is minted and injected only for requests that go upstream. `LOCAL_ROUTE_PATHS` and `LOCAL_ROUTE_PREFIXES` list what the proxy serves itself; a `/p/<project>/` prefix is stripped first with the core's `split_project_path`. Everything else, including the provider passthrough catch-all, is treated as upstream, so a new provider route needs no change here. Only a new local route does. - Startup log: `scheme://host` of the token URL instead of the full URL. Mint log: the scope count instead of the list. - Version 0.1.1 in both `pyproject.toml` and `__version__`. Plugin `CHANGELOG.md` and README updated. Inbound authentication is not this plugin's job. From headroom-ai 0.40, extension middleware runs inside the `HEADROOM_PROXY_TOKEN` gate (#3847), so an unauthenticated request never reaches it, and a client may authenticate with `Authorization: Bearer <proxy token>` before this plugin replaces that header with the upstream bearer. ## Behaviour change / upgrade notes - Requires headroom-ai >= 0.40. The README says so. - Requests to local routes no longer carry a minted bearer. Nothing used it, but a log line or metric keyed on `Authorization` for those paths will change. ## Tests `plugins/headroom-oauth2/tests/test_scope_and_proxy_token.py`: - Every local route, including a project-prefixed one, passes through with `Authorization` untouched and no mint. - Upstream routes, including a project-prefixed route, the passthrough catch-all and `/`, get the minted bearer. - The install log line carries only `scheme://host`. - End to end through `create_app` from a non-loopback client with an unreachable IdP: `/health` is 200; `/stats` with the proxy-token bearer is 200; `/stats` with no credential is 401; an upstream route with the proxy-token bearer gets 502 `upstream_auth_error` from oauth2, not 401 from the gate. - End to end with a local IdP: the proxy-token bearer passes the gate and is replaced by the minted bearer, `x-headroom-proxy-token` is not added, and one mint happens. A request with no credential is refused with 401 and costs no mint. This plugin has no CI job yet (only `plugins/headroom-agent-hooks` is path-filtered in), so these tests were run locally. ## Merge order Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. This PR is independent of the others; the plugin only needs a core >= 0.40. ## Follow-ups - Add a path-filtered CI job for `plugins/headroom-oauth2`. Revised 2026-10-01 after self-review: removed the plugin's own proxy-token handling (now done by the core gate) and the `HEADROOM_OAUTH2_LOCAL_PATHS` setting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…3860) ## Why The proxy's request and token rate limits could be walked around by any caller the proxy cannot authenticate (finding 01-F3). Each provider handler keyed its buckets on a client-supplied header: - **OpenAI:** an HMAC of the full `Authorization` / `api-key` value, else one shared `"default"` bucket. Rotating the header gave a fresh bucket per request, and all callers without a credential shared one bucket. - **Anthropic:** the first 16 characters of the key plus the peer. That is mostly the common `sk-ant-api03-` prefix. - **Gemini:** the first 20 characters of `x-goog-api-key`, with no peer at all. ## What changes - New `headroom/proxy/rate_limit_identity.py` with one rule for the Anthropic, OpenAI and Gemini handlers. `rate_limit_identity()` returns a plain string key: - Every key starts with the peer from `resolve_client_ip`, which honours `X-Forwarded-For` only from `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` peers. IPv6 peers are grouped by `/64`; an IPv4-mapped IPv6 address counts as the IPv4 peer. - **Trusted** requests get one bucket per provider credential: `peer:<addr>|cred:<hmac>`, an HMAC of the whole credential with a process-local key, so bucket names hold no credential material. A request is trusted if it presented a valid `HEADROOM_PROXY_TOKEN` or came directly from loopback. A trusted-gateway peer is trusted to supply the client address, not to vouch that the caller authenticated, so a forwarded request without the token is untrusted and keyed by the forwarded client address. A request from a gateway address that carries no `X-Forwarded-For` is judged as a direct request, so a loopback gateway CIDR does not change how direct localhost callers are keyed. Distinct principals behind one address keep separate limits, as #3364 intended. - **Untrusted** requests (no token, remote caller) are charged per peer (`peer:<addr>`). The credential header is ignored. - The security gate sets `request.state.proxy_authenticated = True` when a caller presents a valid proxy token. - The per-handler key code is removed (`_openai_rate_limit_key` and the Anthropic and Gemini inline keys). `rate_limiter.py` is unchanged, and so are token-limit semantics. ## Behaviour change Requests the proxy cannot authenticate (no `HEADROOM_PROXY_TOKEN`, remote caller) are now limited per client address. Changing the API-key header no longer gives a new allowance. Authenticated and direct-loopback callers keep one allowance per provider credential. Behind a gateway, set `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` so each forwarded client address gets its own limit; to keep one allowance per credential there, callers must also send the proxy token. The limiter is quota smoothing and abuse control for unauthenticated callers. It is not a security boundary for authenticated callers, and this PR does not make it one. ## Tests - `tests/test_proxy/test_rate_limit_identity.py`: untrusted keys ignore the credential; trusted keys are per credential and include the peer; the whole credential is used, not a prefix; bucket names carry no credential material; loopback is trusted; a request relayed by a trusted-gateway peer (including one on loopback) is not trusted without the proxy token; with `127.0.0.0/8` configured as a gateway, direct loopback callers stay per credential, and a relayed request with an unusable `X-Forwarded-For` still counts as relayed; IPv6 `/64` grouping; IPv4-mapped addresses. - `tests/test_proxy_openai_rate_limit_key.py`, end to end through `create_app` with `--rpm 1`: authenticated and loopback callers with distinct keys do not share a bucket; an unauthenticated remote caller who rotates `api-key` or `Authorization`, or drops the credential, gets 429 on the second request; two different unauthenticated peers do not share a bucket; through a trusted gateway (`10.0.0.2`, fixed `X-Forwarded-For: 203.0.113.9`) a caller rotating `api-key` gets 429, distinct forwarded clients do not share, and token-authenticated callers stay per credential; with `127.0.0.0/8` as the gateway CIDR, two direct loopback callers with different keys both pass while a caller relayed through it with a fixed forwarded address gets 429 on the second key. The unauthenticated cases fail on `main`. - Existing rate-limiter and TPM suites pass unchanged. Manual run: `headroom proxy --host 0.0.0.0 --rpm 2` with six requests from a LAN address, each with a different `Authorization` key. On `main` all six reach the upstream. On this branch without a proxy token, the third and later get 429. With the proxy token sent, all six pass, one bucket per credential. Not tested: the Rust `headroom-proxy` binary, which has its own limiter. ## Merge order / conflicts Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. #3852 makes the untrusted case rare by refusing token-less non-loopback binds; this PR makes it safe when an operator opts out of that. Textual conflict: #3862 adds an import next to the one this PR adds in `handlers/anthropic.py` and `handlers/openai.py`. Whichever merges second keeps both imports. Revised 2026-10-01 after self-review: `rate_limiter.py` is back to `main`; the separate trusted/untrusted pools, per-owner cap and overflow bucket were removed. Revised 2026-10-02 after review: gateway membership only drops the credential from the key when the gateway actually forwarded a client address, so direct localhost callers keep per-credential buckets under a loopback gateway CIDR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3860"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a> <!-- devin-review-badge-end --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
#3852) ## Why Four related gaps in the proxy's network gate (findings 01-F1, 01-F2, 01-F5, 01-F9): - **Open bind.** A non-loopback bind with no `HEADROOM_PROXY_TOKEN` only logged a warning, then served the `/v1/*` relay, with the operator's upstream credentials, to every peer that could reach the port. - **Missing peer address.** `is_loopback_host(None)` returned `True`. A Unix-domain socket, an ASGI adapter that leaves `scope["client"]` unset, or a hand-built request therefore turned off the token gate, the `/admin` and `/debug` guards and the WebSocket gate. - **Operator data routes.** `/stats-history` (including CSV export), `/quota`, `/subscription-window` and `/metrics` were served to any network caller. - **WebSocket loopback exemption** checked only the peer IP, not the `Host` header, so it had no DNS-rebinding defence. ## What changes - New `headroom/proxy/bind_policy.py`. A non-loopback bind (`0.0.0.0`, `::`, a LAN address or a hostname) with no token is refused at three places: `create_app` (embedders and the multi-worker factory), `run_server` (one exit with code 2 before uvicorn forks) and the `headroom proxy` CLI (a clear error before the banner, exit code 1). An absent host means uvicorn's default `127.0.0.1`. - `HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1` acknowledges an open bind when the runtime already restricts reachability, such as a container published on `127.0.0.1`. The `proxy_open_bind` warning is still logged on that path, and the banner says the bind is open. - `is_loopback_host(None)` now returns `False` (fail closed), matching `identity.py`. - `/stats-history`, `/quota` and `/subscription-window` answer only: a non-loopback caller that passed the token gate, a loopback caller (peer and `Host` header both loopback), a trusted dashboard client, or, under an acknowledged token-less bind, the container's own default-gateway TCP peer with a loopback `Host` (the host browser reaching a `127.0.0.1`-published container). Everyone else gets 404, as with `/admin`. Settings writes keep their stricter rule with no token short-cut. - `/metrics` answers a non-loopback caller that passed the token gate, a loopback caller, a connecting peer in `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` (whatever it forwards), or a resolved client in the dashboard CIDRs. For CIDR peers a hostname `Host` is fine, so Prometheus scrapers work; a cross-origin browser request is still refused. - The token short-cut applies only to non-loopback requests. The gate exempts loopback peers without checking `Host`, so a loopback request has not proven it holds the token and must pass the loopback `Host` check. This blocks a DNS-rebound browser on the operator's machine. - The `/dashboard` shell stays ungated. It is a static template, like the already public `/dashboard/static` assets. Gating it would break the dashboard for containers published on host loopback, where the peer is the bridge gateway and `Host` is `localhost`. The data routes behind it are gated. - The WebSocket loopback exemption requires a loopback peer and a loopback `Host` header. A loopback peer with a foreign `Host` can still connect with the token. - Launchers that always publish on `127.0.0.1` set the acknowledgement themselves: `headroom install` (docker preset), `scripts/install.sh` and `scripts/install.ps1` (through one helper that adds the acknowledgement only together with the `127.0.0.1` publication), and `docker/docker-compose.native.yml`. - Docs: `proxy.mdx` documents the new variable and which routes the token also gates; `docker-install` (docs and wiki) and the `docker-compose.yml` comment show the acknowledgement in the loopback-published example. `e2e/docker-bind-security.sh` sets it and checks that the same launch without it refuses to start. ## Behaviour change (breaking) - `headroom proxy --host 0.0.0.0`, or `HEADROOM_HOST` set to any non-loopback address, without `HEADROOM_PROXY_TOKEN` now refuses to start. The error names both remedies. Set a token, bind loopback, or set `HEADROOM_ALLOW_UNAUTHENTICATED_BIND=1` when the port is already confined. Anyone running the image by hand with `--host 0.0.0.0` and no token must add one of these. - Without a token, `/stats-history`, `/quota`, `/subscription-window` and `/metrics` return 404 to non-loopback callers outside a trusted CIDR. Prometheus scrapers on another host must be in `HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS` or send the token. - Code that relied on a request with no peer address being treated as loopback now gets the non-loopback path. - The default loopback run is unchanged. ## Tests - `tests/test_proxy_bind_policy.py`: the refuse/allow matrix (non-loopback, loopback, unset host, token, acknowledgement values), the warning on the acknowledged path, `create_app`, the `run_server` exit before uvicorn, and the CLI (refusal, `HEADROOM_HOST` from env, acknowledged start, offline banner, start with a token). - `tests/test_proxy_loopback_gating.py`: each data route against non-loopback, loopback, trusted-CIDR and token callers; the dashboard shell stays reachable; the host of an acknowledged, loopback-published container reads the data routes while another bridge peer, a forwarded gateway address and a foreign `Host` do not; CSV export is not served to network callers; `/metrics` across loopback, both CIDR lists, out-of-range peers, cross-origin browsers and token callers, including a trusted gateway relaying a scraper outside its own range. A regression test shows that a token on the proxy does not exempt a loopback peer from the `Host` check; it fails without the fix. - `tests/test_proxy_hardening.py`: the HTTP gate with no peer address, WebSocket `Host` cases, `require_loopback` and `/debug` with no peer. - Installer tests: `headroom install` adds the acknowledgement, and the shell and PowerShell wrappers add it only together with the loopback publication. Launcher-level tests start the proxy with the environment and `--host` that `build_runtime_command()` and `docker-compose.native.yml` produce and check that the host browser (bridge-gateway peer) gets `/stats-history` and another container does not. - Tests that bound `0.0.0.0` for unrelated reasons now set a token or the acknowledgement. Tests that read operator routes use an explicit loopback peer. On `main`, 14 of the gating and hardening tests fail and the bind-policy module does not exist. Manual runs: token-less `--host 0.0.0.0` exits with the refusal from both the CLI and `run_server`; with the acknowledgement, the data routes are 404 to a LAN peer and to a loopback peer with a foreign `Host`; with a token, a LAN peer gets 401 without it and 200 with it; the default loopback bind starts as before. Not tested: a real Unix-domain-socket deployment and a real Prometheus scrape (both covered by tests only). ## Merge order Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. This PR goes first: it is the gate the others assume, and it is the release-note event. ## Not in this PR - Enforcing the token for loopback callers when one is configured. Revised 2026-10-02 after review: `/metrics` matches gateway CIDRs against the TCP peer, and the operator data routes serve the host of a loopback-published container (bridge-gateway peer under the launchers' bind acknowledgement) so the default Docker dashboard works. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3852"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a> <!-- devin-review-badge-end --> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
…al (#3862) ## Why On a proxy shared by several callers, the semantic response cache was keyed only by request content and upstream (#3349). A second caller who sent the identical request with a different provider key was served the completion generated for the first caller, under the first caller's key, and never reached the upstream. That discloses completions across tenants (finding 01-F15). ## What changes - `compute_cache_partition()` in `headroom/proxy/semantic_cache_key_policy.py`: an HMAC-SHA256 under a per-process random key over the caller's credential and account-selector headers plus the authenticated principal. Headers counted: every header the shared credential-header rule (`internal_header_policy.is_credential_header`) matches, which covers `authorization`, `proxy-authorization`, `cookie` and `x-api-key`, and any header whose name ends in `api-key`, `key`, `token`, `secret`, `account`, `account-id`, `organization` or `project` (so `x-api-key`, `api-key`, `x-goog-api-key`, `chatgpt-account-id`, `openai-organization`, `openai-project` and so on). Matching by name shape keeps new providers partitioned by default. Cookie is used as a whole value; it is only ever HMAC input. `x-headroom-*` headers (including the proxy's own token, which the security gate has already removed) and idempotency keys are excluded. A request with no credential and no principal gets the shared `anon` partition. - The partition is an HMAC, not a plain hash, so it cannot be used to confirm a guessed credential. - `identity.resolve_authenticated_principal()` returns a principal only when an identity resolver is installed (`set_identity_resolver`). The OSS default identity is the same for every network caller and can be chosen by header on loopback, so it is not treated as a principal. - If an installed identity resolver raises or returns no principal, the request fails closed (`resolve_authenticated_principal()` raises `UnresolvedPrincipalError` for an empty result): `compute_request_cache_partition()` logs a warning and returns `None`, and both handlers skip the response cache for that request (no lookup, no store). It never falls back to the credential-only partition, which tenants on one operator key would share. With no resolver installed, the credential-only partition is unchanged. - Both handlers compute the partition only when the response cache can be used (cache enabled and non-streaming), so streaming and cache-disabled requests do not call the identity resolver. - `partition` is a required keyword on `SemanticCache.get`, `set` and `_compute_key`, and on `compute_semantic_cache_key`. A future call site that forgets it fails immediately instead of silently sharing. - The `/v1/messages` and `/v1/chat/completions` handlers compute the partition once, next to the existing cache-key snapshot, and reuse it at store time so lookup and store agree. Other caches in the request path are not changed. The OpenAI Responses unit cache and the CCR retrieval store are content-addressed, so a hit requires already holding the content. CCR entries are not bound to a caller; that is a separate issue. The `/v1/compress` pipeline cache holds no user content, and the SDK `SemanticCache` is in-process for one caller. ## Behaviour change The proxy response cache no longer shares entries between callers that present different provider credentials or, with an identity resolver installed, different principals. - One developer with one key: same hit rate. - Deployments where callers send no provider credential and the proxy supplies the operator's key: same hit rate; they share the `anon` partition. - Shared proxy with per-caller keys: entries are no longer shared between keys. That is the fix. - Compatible upstream gateways that authenticate by `Cookie` or `Proxy-Authorization`: entries are no longer shared between sessions. - Callers whose only credential is a proxy-token `Authorization` header, or deployments using oauth2, share one partition unless an identity resolver is installed, because core cannot tell them apart. ## Tests `tests/test_proxy_response_cache_partition.py`: - The partition function: differs per credential and is stable for one credential; every credential and account header shape partitions; other headers do not fragment the cache; no credential and no principal gives the shared partition; it is keyed, not a plain hash; a principal separates callers sharing one credential. - `SemanticCache` refuses a call without a partition and keeps partitions apart. - End to end through the real Anthropic and OpenAI handlers: a second caller is never served the first caller's cached response, whether the credential is `x-api-key`/`Authorization`, `Cookie` or `Proxy-Authorization`. The `Cookie` and `Proxy-Authorization` cases fail without the latest change. With an identity resolver installed, callers on one operator key are partitioned. These end-to-end tests fail on `main`. - A resolver that raises, or returns an empty principal, yields no partition, and end to end (both handlers) callers on one operator key never share a cached response and nothing is stored. These fail without the fail-closed change. With no resolver installed, the partition is the credential-only one. - With the response cache disabled, neither handler calls the identity resolver. Existing cache tests now pass a fixed partition. The buffered-stream CCR test derives the same partition the handler computes. Manual run: two callers send the identical `/v1/messages` request with different `x-api-key` values through a proxy in front of a capture upstream. On `main` the second caller gets the first caller's answer and only the first key reaches the upstream. On this branch each caller gets its own answer, and a repeat by the first caller is still a cache hit. Not tested: multi-worker deployments (the cache and its key are per process, as before). ## Merge order / conflicts Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. Textual conflict: #3860 adds an import next to the one this PR adds in `handlers/anthropic.py` and `handlers/openai.py`. Whichever merges second keeps both imports. Revised 2026-10-04 after review: forwarded `Cookie` and `Proxy-Authorization` headers are now partition input (via the shared credential-header rule), so cookie-authenticated callers no longer share the anonymous partition. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/headroomlabs-ai/headroom/pull/3862"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a> <!-- devin-review-badge-end --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
🤖 I have created a release *beep* *boop* --- ## [0.40.0](v0.39.1...v0.40.0) (2026-10-06) ### ⚠ BREAKING CHANGES * **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in ([#3857](#3857)) * drop the crewai extra to remove chromadb from the lockfile ([#3870](#3870)) ### Features * **compress:** add per-message compression diagnostics ([#3058](#3058)) ([fef99cc](fef99cc)) * **compress:** live-agent densify mode, lossless and cache-safe ([#1402](#1402)) ([84f0e84](84f0e84)) * **dashboard:** add CO₂ Saved card to dashboard and /stats API ([#1369](#1369)) ([2bc9419](2bc9419)) * **dashboard:** give savings metrics one canonical home ([#3320](#3320)) ([6af7efe](6af7efe)) * **learn:** add agy (Antigravity CLI) as an analysis backend ([#3939](#3939)) ([4ba231a](4ba231a)) * **live-zone:** wire the SourceCode and PlainText dispatch arms ([#3227](#3227)) ([49f69be](49f69be)) * **memory:** persist TrafficLearner pending evidence across restarts + expose learner stats ([#3104](#3104)) ([4257ed4](4257ed4)) * **proxy:** add x-headroom-keep-last-turns header for per-request context trimming (issue [#2858](#2858)) ([#3059](#3059)) ([246162d](246162d)) * **proxy:** route Gemini plugin traffic through native transforms ([#2697](#2697)) ([0ad996a](0ad996a)) * **proxy:** serve on a Unix domain socket (--uds) ([#3151](#3151)) ([e7b3baf](e7b3baf)) * **wrap:** add headroom wrap bob for IBM Bob CLI ([#3801](#3801)) ([0b7dab4](0b7dab4)) * **wrap:** extend reduce-at-source quiet defaults to telemetry/nag banners ([#2550](#2550)) ([3a3a465](3a3a465)) ### Bug Fixes * **anthropic:** preserve failed CCR continuations ([#3843](#3843)) ([94bb055](94bb055)) * **backends/anyllm:** map Anthropic tool_choice none to OpenAI none ([#3963](#3963)) ([d722a64](d722a64)) * **backends/litellm:** align streaming usage tokens with the non-streaming path ([#2688](#2688)) ([90a68e0](90a68e0)) * **backends/litellm:** keep the upstream 4xx status on send_message errors ([#3944](#3944)) ([e0e41cd](e0e41cd)) * **backends/litellm:** map Anthropic tool_choice none to OpenAI none ([#2689](#2689)) ([57a5708](57a5708)) * **backends/litellm:** match the caller-key Bearer scheme case-insensitively (RFC 7235) ([#3965](#3965)) ([848d7a0](848d7a0)) * **backends/litellm:** report requested model in OpenAI streaming chunks ([#2690](#2690)) ([8538a83](8538a83)) * **cache:** keep prefix lineage across a replaced system tail ([#3934](#3934)) ([16eaec1](16eaec1)) * **ccr:** answer headroom_retrieve on the direct chat path instead of forwarding it ([#3816](#3816)) ([126e144](126e144)) * **ccr:** inject headroom_retrieve before the prefix is warm, not after ([#3810](#3810)) ([bb8c285](bb8c285)) * **ccr:** only proactively expand compressions present in the requesting conversation ([#3924](#3924)) ([9b9a883](9b9a883)) * **ccr:** unwrap Hermes batched tool_call so headroom_retrieve stays exempt ([#3839](#3839)) ([2f07668](2f07668)) * **ci:** clear dependency audit and gate Docker publishing ([#3984](#3984)) ([67ce7d0](67ce7d0)) * **ci:** keep hard watchdog out of pytest shards ([#3845](#3845)) ([2527431](2527431)) * **ci:** tolerate missing Docker cache blobs ([#3945](#3945)) ([540f4da](540f4da)) * clarify CCR marker content preservation ([#3175](#3175)) ([005a4e1](005a4e1)) * **cli:** honor CODEX_HOME and align wrap/init/doctor with the live proxy port ([#3855](#3855)) ([5bf6612](5bf6612)) * **cli:** strip unmarked headroom_memory TOML table before injecting ([#3490](#3490)) ([08dda4c](08dda4c)) * **codex:** explain why wrapped Codex runs without the shared server ([#3899](#3899)) ([1982b3a](1982b3a)) * **codex:** preserve remote compaction in init config ([#3410](#3410)) ([6df4a96](6df4a96)) * **codex:** resolve per-turn project context ([#2636](#2636)) ([0396ea2](0396ea2)) * **codex:** strip stale compression framing on the Responses subpath passthrough ([#3792](#3792)) ([0eba65c](0eba65c)) * **copilot:** defer keychain auth lookup ([#2739](#2739)) ([81a8a28](81a8a28)) * **copilot:** read timezone-naive token expiry as UTC, not host local time ([#3216](#3216)) ([2b2dc1b](2b2dc1b)) * **copilot:** warn when a VS Code profile cannot see the proxy settings ([#3919](#3919)) ([7b68fc2](7b68fc2)) * **deps:** patch brace-expansion in wrap E2E lockfile ([#3873](#3873)) ([5e0435d](5e0435d)) * **deps:** patch urllib3 and Next.js advisories ([#3896](#3896)) ([573e385](573e385)) * **doctor:** recognize Azure Foundry routing for Claude Code ([#1339](#1339)) ([d5318ac](d5318ac)) * **grok:** route CLI traffic to api.x.ai on shared proxies ([#2693](#2693)) ([ef1c528](ef1c528)) * honor configured port in container startup and healthcheck ([#2436](#2436)) ([1b7977c](1b7977c)) * **install/apply:** add --no-rate-limit flag, persist in proxy_args ([#1350](#1350)) ([#1365](#1365)) ([31d0344](31d0344)) * **install:** parse Windows Task Scheduler XML output ([#3830](#3830)) ([c32a4f4](c32a4f4)) * **install:** replace the launchd wrapper with the proxy listener ([#3224](#3224)) ([44db755](44db755)) * **install:** use safe model backends for persistent services ([#3646](#3646)) ([7287589](7287589)) * **integrations:** record metric timestamps in UTC, not naive local time ([#3117](#3117)) ([76ef2c3](76ef2c3)) * **kompress:** degrade when a native dep is installed but unloadable ([#3133](#3133)) ([8f3d677](8f3d677)) * **kompress:** pin ModernBERT tokenizer and encoder revisions ([#3808](#3808)) ([2de5828](2de5828)) * **learn:** drop the echoed prompt from a failed cli's error ([#3928](#3928)) ([f00d425](f00d425)) * **learn:** keep CLAUDE.local.md out of git via .git/info/exclude ([#3108](#3108)) ([c072251](c072251)) * **learn:** keep transcript-derived content inside the managed block ([#3850](#3850)) ([c46e74d](c46e74d)) * **learn:** merge git worktree sessions into the repo's project ([#3854](#3854)) ([3fdf18e](3fdf18e)) * **learn:** preserve stable traffic pattern items ([#2293](#2293)) ([1631cde](1631cde)) * **learn:** recognize escaped persisted pattern IDs ([#3951](#3951)) ([2e74f06](2e74f06)) * **learn:** run the claude-cli analysis with hooks disabled ([#3926](#3926)) ([2a4d34c](2a4d34c)) * **learn:** run the claude-cli analysis with no tools ([#3892](#3892)) ([db90b93](db90b93)) * **log_compressor:** keep and name pytest short-summary failures ([#3828](#3828)) ([d90b320](d90b320)) * make Headroom work behind Zscaler and other TLS-inspecting networks ([#3831](#3831)) ([66258c4](66258c4)) * **mcp:** bound version-detection git subprocess ([#3038](#3038)) ([0712e04](0712e04)) * **mcp:** escape control characters when rendering TOML server blocks ([#3964](#3964)) ([a96154f](a96154f)) * **mcp:** keep other apps' tables when replacing the Codex/Grok MCP span ([#3877](#3877)) ([d0fd56e](d0fd56e)) * **mcp:** resolve opencode.jsonc for MCP registration ([#2496](#2496)) ([f824a27](f824a27)) * **memory:** align project routing with wrap headers ([#3603](#3603)) ([b1b005a](b1b005a)) * **memory:** avoid injecting tools into tool-free requests ([#3677](#3677)) ([46755b5](46755b5)) * **memory:** close SQLite connections in memory/fts5/graph adapters ([#3153](#3153)) ([231a627](231a627)) * **memory:** handle list-shaped system content in inline memory injection ([#3794](#3794)) ([717527b](717527b)) * **memory:** ignore leading cd prefix when pairing Bash error recoveries ([#3776](#3776)) ([143a38d](143a38d)) * **memory:** pin LF on memory writers and guard the learn-writer newline contract ([#3706](#3706)) ([b10dd8d](b10dd8d)) * **oauth2:** mint and inject only on requests that go upstream ([#3849](#3849)) ([f977d52](f977d52)) * **offline:** make HEADROOM_OFFLINE a real air-gap via one chokepoint ([#3729](#3729)) ([119d1a1](119d1a1)) * **opencode:** hide spawned Windows console windows ([#3743](#3743)) ([2157400](2157400)) * **opencode:** route only LLM traffic through Headroom ([#3884](#3884)) ([d75eecd](d75eecd)) * **output-savings:** seed the holdout key on the whole first user message ([#3209](#3209)) ([117ff72](117ff72)) * **parser:** stop counting HTML comments twice in waste signals ([#3942](#3942)) ([d0e9c4c](d0e9c4c)) * **parser:** whitespace waste signal always reported zero ([#1102](#1102)) ([f19bc9a](f19bc9a)) * **plugin:** resolve hook CLI through plugin-root launcher ([#3053](#3053)) ([ef7605f](ef7605f)) * **plugins/openclaw:** return messages the proxy did not change exactly as they came in ([#3826](#3826)) ([d1ad189](d1ad189)) * prevent HF tokenizer downloads in offline mode ([#3783](#3783)) ([d503c57](d503c57)) * **pricing:** add Claude Sonnet 5.5 / Opus 5.5 / Fable 5.1, correct Sonnet 5 rates ([#3841](#3841)) ([0d99c56](0d99c56)) * protect file reads in chained shell commands ([#2668](#2668)) ([ffc3599](ffc3599)) * **providers/vertex:** Vertex route multi-region locations ([#3802](#3802)) ([a9c1ac5](a9c1ac5)) * **proxy/anthropic:** keep tool_result blocks first when neutralizing headroom_retrieve history ([#3874](#3874)) ([ccd9fff](ccd9fff)) * **proxy/batch:** honor x-headroom-bypass on the batch paths ([#2570](#2570)) ([9b26a49](9b26a49)) * **proxy:** add same-origin check to /v1/retrieve/tool_call ([#3955](#3955)) ([2297b50](2297b50)) * **proxy:** bill the whole prompt on the gateway path so savings read true ([#3632](#3632)) ([befdb52](befdb52)) * **proxy:** carry safeguard_results through SSE resynthesis ([#3958](#3958)) ([f37ef59](f37ef59)) * **proxy:** classify Pi Codex Responses alias ([#2583](#2583)) ([a493f55](a493f55)) * **proxy:** do not cache error replies delivered as http 200 ([#3930](#3930)) ([1132a54](1132a54)) * **proxy:** drop a tool_reference naming the search tool itself ([#3172](#3172)) ([b73adaa](b73adaa)) * **proxy:** F3 — per-tenant TOIN learning key ([#404](#404)) ([f90a56b](f90a56b)) * **proxy:** forward operator-listed guarded upstreams through a proxy ([#3804](#3804)) ([4b7e5d2](4b7e5d2)) * **proxy:** freeze and replay the forwarded prefix on Gemini paths ([#3394](#3394)) ([#3865](#3865)) ([dd84321](dd84321)) * **proxy:** gate the raw request body, not just its Content-Length header ([#3338](#3338)) ([c946b6b](c946b6b)) * **proxy:** hard watchdog that dumps and exits when a native call seizes the GIL ([#3180](#3180)) ([79daeb5](79daeb5)) * **proxy:** inject memory context past a trailing system message ([#3948](#3948)) ([3948dbe](3948dbe)) * **proxy:** keep cache_control outside content blocks where the Chat Completions client put it ([#3895](#3895)) ([a6d6c14](a6d6c14)) * **proxy:** keep exception text and the proxy token out of client output ([#3851](#3851)) ([861e94d](861e94d)) * **proxy:** keep the newest user message verbatim in cache-mode delta compression ([#3923](#3923)) ([613ae92](613ae92)) * **proxy:** key rate limits by peer unless the caller authenticated ([#3860](#3860)) ([6fb7cad](6fb7cad)) * **proxy:** kill the image worker a timed-out call abandons ([#3940](#3940)) ([793bb85](793bb85)) * **proxy:** label plain OpenAI chat traffic openai, not custom ([#3912](#3912)) ([7df8bd8](7df8bd8)) * **proxy:** log response_content_length in proxy_inbound_response ([#2701](#2701)) ([cfa479a](cfa479a)) * **proxy:** preserve Bedrock body-limit error dialect ([#3871](#3871)) ([ffc6edb](ffc6edb)) * **proxy:** preserve Windows service when Rust core is blocked ([#2989](#2989)) ([eaa16d9](eaa16d9)) * **proxy:** quarantine compression only once half the pool is stuck ([#3932](#3932)) ([be2b205](be2b205)) * **proxy:** read HEADROOM_LICENSE and make usage reporting opt-in ([#3857](#3857)) ([7460389](7460389)) * **proxy:** reap wrap-spawned proxies once no wrap clients remain ([#3202](#3202)) ([4227bd2](4227bd2)) * **proxy:** redact upstream error detail and add opt-in /metrics loopback gate ([#2589](#2589)) ([a05717f](a05717f)) * **proxy:** refuse token-less non-loopback binds, gate operator routes ([#3852](#3852)) ([ee731d7](ee731d7)) * **proxy:** reset the cc-switch upstream when Claude Official is selected ([#3166](#3166)) ([fed7281](fed7281)) * **proxy:** run extension middleware inside the security gate and body ceiling ([#3847](#3847)) ([1854fd7](1854fd7)) * **proxy:** run injected memory tools server-side on streaming turns ([#3947](#3947)) ([1cf4966](1cf4966)) * **proxy:** share one compression deadline across a Responses request ([#3938](#3938)) ([4d27d02](4d27d02)) * **proxy:** skip pricing lookup for passthrough:* models ([#2585](#2585)) ([f87848c](f87848c)) * **proxy:** stop headroom logger from suppressing propagation to stdout/stderr ([#3096](#3096)) ([f78e66f](f78e66f)) * **relevance:** bound segment size by max_chars ([#2518](#2518)) ([0ef7b2a](0ef7b2a)) * **reporting:** price the savings tile instead of fabricating $0.00 ([#3821](#3821)) ([f519fa8](f519fa8)) * **router:** fall back to built-ins when an external compressor passes through ([#3893](#3893)) ([2c4b20f](2c4b20f)) * **router:** keep ccr_retrieve exemption through orchestrator wrappers ([#3915](#3915)) ([6151ed1](6151ed1)) * **rust-proxy:** forward request paths verbatim and pin the rustls provider ([#3853](#3853)) ([9d98ea5](9d98ea5)) * **savings:** record tool-schema dollars disjointly beside the folded headline ([#3170](#3170)) ([c719d4a](c719d4a)) * **savings:** stop scoring unobserved strata against the global mean ([#3128](#3128)) ([f864525](f864525)) * **sdk:** keep tool names on Vercel tool-result parts through the OpenAI round trip ([#3883](#3883)) ([b715671](b715671)) * **sdk:** preserve Gemini media parts in message conversion instead of dropping the turn ([#3882](#3882)) ([038c923](038c923)) * **sdk:** stop JSON-wrapping structured tool_result content in the Anthropic adapter ([#3797](#3797)) ([2e4a60a](2e4a60a)) * **search:** stop a context line's body from becoming its line marker ([#3788](#3788)) ([f3f2e00](f3f2e00)) * **security:** create memory stores and other state files owner-only ([#3848](#3848)) ([5119b6e](5119b6e)) * **security:** exempt only GET health probes from the proxy token ([#3921](#3921)) ([d99779d](d99779d)) * **security:** partition the response cache by credential and principal ([#3862](#3862)) ([ee6cfcc](ee6cfcc)) * **security:** poll usage only with the operator's own credential ([#3863](#3863)) ([65e63c0](65e63c0)) * **security:** stop forwarding the proxy token to upstream providers ([#3891](#3891)) ([0147cf0](0147cf0)) * **settings:** report live proxy configuration ([#3177](#3177)) ([58b1454](58b1454)) * **smart-crusher:** recurse at adaptive array limit ([#3770](#3770)) ([7790bde](7790bde)) * **storage:** page JSONL queries after sorting ([#3872](#3872)) ([91237ca](91237ca)) * **subscription:** match the Bearer scheme case-insensitively for the Codex usage poll (RFC 7235) ([#3966](#3966)) ([fe88461](fe88461)) * **subscription:** poll with the refreshed credentials-file OAuth token ([#3916](#3916)) ([2dc9fc2](2dc9fc2)) * surface Codex responses traffic in dashboard ([#399](#399)) ([ecc4967](ecc4967)) * **telemetry:** label custom-base chat upstreams from a fixed provider set ([#3759](#3759)) ([f0ec2bb](f0ec2bb)) * **thinking:** don't read the model's date suffix as its minor version ([#3791](#3791)) ([afaaaa8](afaaaa8)) * **transforms/kompress:** preserve line boundaries and tabular output in Kompress ([#3119](#3119)) ([fe2ed2b](fe2ed2b)) * **transforms:** judge a Codex exec envelope read by its output ([#3878](#3878)) ([922924e](922924e)) * **transforms:** judge the code_aware Kompress fallback in tokens ([#3881](#3881)) ([6326965](6326965)) * **transforms:** keep record-bearing JSON out of Kompress ([#3673](#3673)) ([#3880](#3880)) ([8dbbd1d](8dbbd1d)) * **wrap:** never reuse a non-Headroom listener on the proxy port ([#3799](#3799)) ([3ffa57e](3ffa57e)) * **wrap:** never reuse a proxy with incompatible routing config ([#3201](#3201)) ([f69e246](f69e246)) * **wrap:** preserve pre-set ANTHROPIC_BASE_URL as proxy upstream ([#1358](#1358)) ([bb1ab6a](bb1ab6a)) * **wrap:** scope Serena's MCP registration to the wrapped project ([#2787](#2787)) ([#2992](#2992)) ([8cfeb69](8cfeb69)) * **wrap:** set ANTHROPIC_HOST so `wrap goose` actually proxies Anthropic ([#2619](#2619)) ([c07fad0](c07fad0)) * **wrap:** strip -dev from running proxy version in restart check ([#3200](#3200)) ([bd0296b](bd0296b)) ### Performance Improvements * add savings audit output ([#1211](#1211)) ([9a72bc0](9a72bc0)) * **compression/code:** avoid a UTF-8 copy per code block in byte-to-char mapping ([#3169](#3169)) ([d5e5534](d5e5534)) * **image:** OCR and SigLIP each image once, not once per turn ([#3941](#3941)) ([a8561fb](a8561fb)) * **memory:** bound traffic-learner _persisted_ids with the dedup window ([#3341](#3341)) ([7e73438](7e73438)) * **metrics:** cap inbound request-path cardinality to bound memory ([#3340](#3340)) ([88a1f4e](88a1f4e)) * **proxy:** lighter request path: alias the message snapshot, keep the token cache on re-counts, decompress off the event loop ([#3909](#3909)) ([c873d13](c873d13)) * **tokenizer:** memoize OpenAI token counts like the Anthropic counter ([#3168](#3168)) ([0a2c80d](0a2c80d)) ### Dependencies * bump pyjwt 2.13.0 -> 2.15.1 for CVE-2026-102274 ([#3861](#3861)) ([37b9c46](37b9c46)) * Bump source-map-js from 1.2.1 to 1.2.2 in /docs ([#3986](#3986)) ([e06631a](e06631a)) * Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/openclaw ([#3988](#3988)) ([4be83b3](4be83b3)) * Bump source-map-js from 1.2.1 to 1.2.2 in /plugins/opencode ([#3987](#3987)) ([478dd9e](478dd9e)) * Bump source-map-js from 1.2.1 to 1.2.2 in /sdk/typescript ([#3985](#3985)) ([62cde35](62cde35)) * bump the npm-minor-patch group across 2 directories with 11 updates ([#3903](#3903)) ([afe4f4e](afe4f4e)) * bump the npm-minor-patch group across 3 directories with 11 updates ([#3910](#3910)) ([ff1a0d6](ff1a0d6)) * bump webpki-roots from 0.26.11 to 1.0.8 ([#3905](#3905)) ([fee53b4](fee53b4)) * drop the crewai extra to remove chromadb from the lockfile ([#3870](#3870)) ([59b8cef](59b8cef)) ### Code Refactoring * **ccr:** read the expansion query through extract_user_query ([#2707](#2707)) ([dcec845](dcec845)) * **wrap:** generate goose/openhands/openclaude from a WrapTarget registry ([#3800](#3800)) ([de4cf7e](de4cf7e)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Why
Two background usage pollers spent callers' credentials (finding 01-F16):
Authorization: Beareras the account to poll (notify_activestored it and_maybe_pollused it)./wham/usagerefresher polled with any caller's bearer andChatGPT-Account-Id.On a proxy shared by several callers, a network caller's credential was therefore sent on a request they never made, and their subscription usage appeared on the operator's dashboard.
What changes
headroom/subscription/credential_policy.pywithis_local_operator_connection(). It is true only for a direct loopback peer with noForwarded,X-Real-IPorX-Forwarded-*header (any of the family, including-Protoand-Hostalone). It fails closed on a missing peer. Trusted-gateway CIDRs do not count, because a gateway fronts other principals.SubscriptionTracker.notify_active(token, *, from_local_operator=False)always marks the tracker active, but stores the bearer for polling only whenfrom_local_operatoris true.maybe_schedule_usage_poll(headers, *, from_local_operator=False)returns without polling unlessfrom_local_operatoris true.is_local_operator_connection(...).Unchanged:
_maybe_pollordering, and the fallback to the operator's own credential (CLAUDE_CODE_OAUTH_TOKENor the Claude Code credentials file) when no bearer has been learned. The Copilot quota poller already uses only the host's own GitHub token.Behaviour change
CLAUDE_CODE_OAUTH_TOKENthere if you want the subscription window.Tests
tests/test_subscription_poll_credential_policy.py:X-Forwarded-Protoalone andX-Forwarded-Hostalone./v1/messageshandler: a LAN caller's bearer is never adopted.The tracker, foreign-bearer, Codex and handler tests fail on
main. Existing tracker and Codex tests that assert adoption now passfrom_local_operator=True, so they still test what they meant to.Manual run:
headroom proxy --host 0.0.0.0with no Claude credentials on the host and the usage URL pointed at a local capture server. Onmain, a LAN caller's bearer was used for polling. On this branch only the loopback operator's bearer was used.Not tested: a live Codex ChatGPT session end to end (the trigger is covered by unit tests).
Merge order
Part of a set. Suggested order: #3852 → #3891 → #3849 → #3860 → #3862 → #3863. This PR goes last. #3891 stops the proxy token being forwarded upstream; this PR stops the pollers adopting any foreign bearer. They touch different files.
Revised 2026-10-01 after self-review: removed the
HEADROOM_SUBSCRIPTION_TRAFFIC_TOKENsetting and reverted the poll re-ordering, so the change is limited to which callers' bearers are stored.Revised 2026-10-01 after review: the local-operator check now rejects every
X-Forwarded-*header, not justX-Forwarded-For, so a gateway sending only-Protoor-Hostfails closed.🤖 Generated with Claude Code