headroom/headroom
Tejas Chopra a6ab359a5d
fix(proxy): guard feedback endpoints and add CSRF checks to loopback writes (#3060)
## Description

`#2927` brought eight telemetry/TOIN routes under `require_loopback`.
Two structurally identical siblings 60 lines above them were missed:

```
GET /v1/feedback
GET /v1/feedback/{tool_name}
```

Neither is an aggregate-counter endpoint. Their `common_queries` /
`queried_fields` keys are built verbatim from agent search text —
`event.query.lower()` at `headroom/cache/compression_feedback.py:311` —
and up to 100 queries are retained per tool, keyed by real tool name.
Under the shipped Docker default (`--host 0.0.0.0`) a LAN peer gets a
404 from `/v1/toin/patterns` and the query corpus from `/v1/feedback`.

Separately, five mutating loopback-only routes had no CSRF guard.
`require_loopback` cannot stop that attack: a remote page POSTing to a
known `127.0.0.1` URL with `Content-Type: text/plain` is a CORS *simple*
request, so there is no preflight, and the browser still sends the real
loopback `Host` header — both of the guard's gates pass. Only `Origin`
betrays the caller, and only `require_same_origin` inspects it. That
guard already existed at `headroom/proxy/loopback_guard.py:219` and was
applied solely to `/settings`.

Closes #2927 (completes it — the original eight routes were already
done).

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Changes Made

- Added `Depends(_require_loopback)` to `/v1/feedback` and
`/v1/feedback/{tool_name}`.
- Stripped `common_queries` / `queried_fields` from both response bodies
even on the guarded path, matching the whitelist discipline #2930
applied at `server.py:4909-4916`.
- Added `_feedback_stats_without_query_text()` so the scrub happens at
the HTTP boundary; `get_stats()` is unchanged and in-process compression
decisions are untouched.
- Added `Depends(_require_same_origin)` to `POST /stats/reset`,
`/cache/clear`, `/v1/retrieve`, `/v1/telemetry/import`,
`/admin/runtime-env`.

## Testing

- [x] Unit tests pass
- [x] Linting passes (ruff check + format)
- [ ] Type checking passes (`uv run mypy headroom`) — not run
- [x] New tests added for new functionality

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_proxy_loopback_gating.py -q
99 passed, 1 warning in 4.18s

$ .venv/bin/python -m pytest tests/test_proxy_settings_endpoints.py tests/test_telemetry.py \
    tests/test_proxy_cache_telemetry.py tests/test_proxy_telemetry_env.py tests/test_telemetry_context.py -q
101 passed, 1 warning in 3.67s

$ .venv/bin/python -m pytest tests/test_critical_fixes.py tests/test_compression_store.py \
    tests/test_toin_full_integration.py tests/test_ccr_feedback.py tests/test_critical_gaps.py \
    tests/test_proxy_ccr.py tests/test_proxy_dashboard_stats_cache.py -q
168 passed, 4 skipped, 3 warnings in 13.18s

$ .venv/bin/python -m ruff check headroom/proxy/server.py tests/test_proxy_loopback_gating.py
All checks passed!
```

Against the parent commit (`git stash` of `server.py` only), all 14 new
tests fail:

```text
FAILED test_non_loopback_caller_gets_404[get-/v1/feedback]
FAILED test_non_loopback_caller_gets_404[get-/v1/feedback/example]
FAILED test_cross_origin_post_rejected[/stats/reset]
FAILED test_cross_origin_post_rejected[/cache/clear]
FAILED test_cross_origin_post_rejected[/v1/retrieve]
FAILED test_cross_origin_post_rejected[/v1/telemetry/import]
FAILED test_cross_origin_post_rejected[/admin/runtime-env]
FAILED test_sandboxed_null_origin_post_rejected[...]  (5 cases)
FAILED test_feedback_stats_exclude_agent_query_text
FAILED test_feedback_tool_detail_excludes_agent_query_text
14 failed, 85 passed
```

## Real Behavior Proof

- Environment: macOS 15 (darwin 25.4.0), Python 3.12.13, this branch,
FastAPI `TestClient` against the real `create_app` proxy.
- Exact command / steps: drive `/v1/feedback` with a feedback singleton
whose `common_queries` contains `"find the customer api key rotation
runbook"`, once from a non-loopback peer and once from a loopback peer;
POST each of the five mutating routes with `Origin:
https://attacker.example` and `Content-Type: text/plain`.
- Observed result: non-loopback callers now receive 404 where they
previously received 200 with the query corpus; on the loopback path the
response no longer contains `common_queries`, `queried_fields`, or the
substring `customer api key rotation`, while `retrieval_rate` still
resolves to `0.25`. All five cross-origin POSTs return 403; the same
requests with no `Origin`, or with `Origin: http://127.0.0.1`, are
unaffected.
- Not tested: a real browser issuing the cross-origin POST (the CORS
simple-request shape is reproduced at the header level, not in a
browser), and a live non-loopback deployment.

## Runtime Rollout Safety

- Rollout-managed feature(s): none.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: yes — `/v1/feedback*` now 404 for
non-loopback callers and no longer return query text; five POST routes
reject cross-origin browser callers.
- Kill switch / disable path: none; these are security guards and are
deliberately not configurable.
- Unsafe override required: none.
- Qualification impact: none.
- Rollback path: revert this commit.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes

## Additional Notes

`/stats` also calls `feedback.get_stats()` (`server.py:3896`) but only
reads aggregate counters at `:4303-4311` and never emits query text —
verified, and the reason the scrub is applied at the HTTP boundary
rather than inside `get_stats()`.

The five POST routes are strictly loopback-gated, so the
trusted-dashboard wrapper `/settings` uses is unnecessary here; for a
loopback caller that wrapper falls through to the same raw guard. No
dashboard asset calls them, and the TypeScript SDK
(`sdk/typescript/src/client.ts:322,443`) sends no `Origin` header, which
the guard passes through unchanged.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
2026-08-16 18:23:27 -07:00
..
audit fix: remove rtk and lean-ctx CLI context tools (#2677) 2026-07-30 22:59:41 -07:00
backends fix(proxy): pass through cross-region prefixed Bedrock model IDs directly (#2330) 2026-08-11 23:54:03 -05:00
cache fix(cache): mirror client cache_control positions instead of single-marker consolidation 2026-08-11 18:15:44 -07:00
capture fix(cli): harden all CLI surfaces + fix docs accuracy (#1491) 2026-06-27 14:48:43 -07:00
ccr fix(ccr): verify a scanned marker's hash before advertising it (#2908) 2026-08-13 11:46:21 -05:00
cli fix(cli): stop the macOS malloc re-exec replacing an embedder's process (#3064) 2026-08-16 17:56:10 -07:00
compression fix(code): parse-probe tree-sitter availability in code_handler (#1231) (#1300) 2026-07-09 14:06:29 -05:00
dashboard Unify savings attribution across stats, perf, metrics, and dashboard (#2976) 2026-08-13 17:13:23 -07:00
evals feat(evals): weekly HotpotQA answer-recall report on the prose path (#1188) 2026-07-15 21:40:55 +00:00
graph refactor(wrap): retire tokensave; Serena is the code-memory MCP (#2499) 2026-07-22 20:59:24 -07:00
image fix(image): decouple routing types from trained_router so importing the compressor doesn't import torch (#2513) (#2537) 2026-08-12 00:22:56 -05:00
install fix(wrap): set xAI upstream for grok-build proxy (#2772) 2026-08-16 15:04:59 -07:00
integrations fix(litellm): close shared cloud client 2026-08-11 10:13:10 -07:00
learn fix(learn): stop classifying a successful exit code 0 as an error (#2289) 2026-08-11 23:45:32 -05:00
mcp_registry fix(wrap/serena): install Serena from the serena-agent PyPI wheel, not the git source 2026-08-11 09:10:32 -07:00
memory fix(memory): sanitize entity_refs to prevent dict-shaped entries crashing search (#2951) 2026-08-13 11:46:44 -05:00
models fix(models): version-boundary longest-prefix match in ModelRegistry.get (#1658) 2026-07-10 23:07:31 -05:00
observability fix(ci): prevent native detector from hanging test shards (#2996) 2026-08-13 20:47:55 -05:00
perf fix(proxy): stop cached responses replaying the producing turn's wire framing (#3024) 2026-08-16 15:04:01 -07:00
pricing fix: Vertex model pricing shows $0.00 for versioned model names and vertex:anthropic provider (#2517) 2026-08-11 23:03:09 -05:00
providers fix(wrap): set xAI upstream for grok-build proxy (#2772) 2026-08-16 15:04:59 -07:00
proxy fix(proxy): guard feedback endpoints and add CSRF checks to loopback writes (#3060) 2026-08-16 18:23:27 -07:00
relevance feat(security): pilot hardening — stateless guarantee, model pinning, CI security gate (#1515) 2026-06-27 17:44:10 -07:00
reporting feat: attribute reread waste to over-compression via marker check (#901) 2026-06-13 10:43:35 -05:00
storage Fix 19 test failures: missing security attr, nosec inside f-strings, stale test mock 2026-04-07 18:30:29 -07:00
subscription fix(subscription): dedup transcript usage by message id (#2340 token inflation) (#2408) 2026-08-12 00:05:04 -05:00
telemetry fix(proxy/metrics): cap client-supplied model label cardinality (#2480) 2026-08-12 00:15:49 -05:00
testing deps: bump ruff from 0.15.22 to 0.16.2 in the pip-minor-patch group across 1 directory (#2962) 2026-08-14 16:38:08 -05:00
tokenizers perf: cut hot-path latency 27% (token-count memo, startup preloads, JSON scan memo) (#2838) 2026-08-06 17:47:40 -07:00
transforms fix(policy): price net-cost mutations with the 1h cache-write tier (#2780) 2026-08-16 15:09:50 -07:00
__init__.py Pin ORT dylib on Windows; init Python logging (#1010) 2026-06-23 07:46:24 -05:00
_ort.py fix(onnx): enforce Rust API-24 runtime compatibility (#2979) 2026-08-13 15:05:41 -05:00
_subprocess.py ci: repair mypy no-any-return in _win32_pid_alive (#1556 follow-up) (#2336) 2026-07-16 20:55:17 -07:00
_version.py fix(version): mark source-checkout builds as -dev (#2072) 2026-07-13 09:38:17 -04:00
agent_savings.py fix(savings): coding profile compresses the recent delta (protect_recent 2->0, min_tokens 25->10) (#2145) 2026-07-14 04:07:25 -04:00
binaries.py chore: release main (#2792) 2026-08-12 19:02:51 -05:00
cli.py Add Click-based CLI with memory management commands 2026-01-29 21:30:21 -08:00
client.py refactor: DRY cache logic, add thread safety, fix Bash exclusion (#704) 2026-06-16 14:50:04 -05:00
compress.py feat(compress): expose frozen_message_count in library-mode compress() (#2178) 2026-07-14 16:07:21 -04:00
config.py feat: add deterministic runtime rollout controls (#1490) 2026-08-12 23:16:54 -05:00
context_tool_cleanup.py fix(install): consolidate Windows fallback and cleanup safety (#2980) 2026-08-13 15:05:45 -05:00
copilot_auth.py feat(copilot): proxy VS Code models transparently (#2687) 2026-08-03 04:42:48 -07:00
copilot_linux_secret.py fix(windows): pin UTF-8 encoding on text-mode subprocess calls (#1311) 2026-06-23 12:52:49 -05:00
copilot_macos_keychain.py fix(windows): pin UTF-8 encoding on text-mode subprocess calls (#1311) 2026-06-23 12:52:49 -05:00
exceptions.py fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents 2026-05-02 12:23:17 -07:00
fsutil.py fix: remove rtk and lean-ctx CLI context tools (#2677) 2026-07-30 22:59:41 -07:00
hooks.py feat: introduce canonical pipeline lifecycle contract 2026-04-21 23:54:28 -05:00
offline.py feat(proxy): pilot hardening — inbound auth, security headers, audit log, air-gap switch (#1537) 2026-06-28 12:09:00 -07:00
onnx_runtime.py fix(onnx): stop ONNX thread pools from spinning idle cores (#2495) (#2540) 2026-08-08 01:33:57 -05:00
parser.py fix(agno): tolerate streaming tool-call SDK objects in parser (#1312) (#1336) 2026-06-24 09:52:15 -05:00
paths.py fix(wrap): serialize shared proxy startup (#2946) 2026-08-12 12:50:09 -07:00
pipeline.py feat: introduce canonical pipeline lifecycle contract 2026-04-21 23:54:28 -05:00
py.typed Prepare for OSS release v0.2.0 2026-01-07 11:36:44 -08:00
release_version.py ci(release): publish win_amd64 wheel so Windows installs need no Rust (#1328) (#1335) 2026-06-24 09:48:37 -05:00
rollout.py feat: add deterministic runtime rollout controls (#1490) 2026-08-12 23:16:54 -05:00
savings_ledger.py fix(savings): don't bill free models at the $3/M fallback in the ledger (#2147) 2026-07-14 12:19:30 -04:00
settings_store.py fix(ccr): resolve <<ccr:...>> markers inline when no retrieve-tool path exists (#2512) 2026-08-12 00:18:43 -05:00
shared_context.py fix(shared_context): don't evict an unrelated entry on an update at capacity (#2136) 2026-07-13 19:57:32 -04:00
tokenizer.py Initial commit: Headroom SDK - LLM context optimization toolkit 2026-01-06 23:16:58 -08:00
tools.json fix(review): address PR #210 feedback — race, query-params, SHA logging, frozen prefix, etc 2026-04-20 17:36:44 -07:00
update_check.py feat(proxy): pilot hardening — inbound auth, security headers, audit log, air-gap switch (#1537) 2026-06-28 12:09:00 -07:00
utils.py chore: add nosec B324 annotations to non-cryptographic MD5 usages and update temporary database path to use system temp directory 2026-04-07 13:07:26 +06:00