Commit graph

3 commits

Author SHA1 Message Date
Tejas Chopra
bd55a426bc
fix(proxy): scope CORS to loopback + gate operator/content endpoints (#1226)
## Description

Locks down the proxy's browser- and network-facing attack surface, which
matters most under a `--host 0.0.0.0` bind (the Docker default). The
wildcard CORS policy (`allow_origins=["*"]` + `allow_credentials=True`)
let any web page the user had open read the proxy's content endpoints —
`/v1/retrieve` returns raw, uncompressed tool outputs (source, secrets)
— via a cross-origin fetch to `127.0.0.1` (CWE-346). Several operator
endpoints additionally leaked sensitive data or allowed unauthenticated
state mutation to any network-reachable client.

This PR scopes CORS to loopback origins and extends the project's
existing `require_loopback` trust boundary (already used for `/admin/*`
and `/debug/*`) to the remaining exposed endpoints.

Closes #863.

Supersedes #864 and #758 — see "Additional Notes".

## 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
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- **CORS**: replaced `allow_origins=["*"]` + `allow_credentials=True`
with a port-agnostic loopback origin regex
(`https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?`),
`allow_credentials=False`, and methods/headers narrowed to `GET/POST` +
`Content-Type/Authorization`. `HEADROOM_CORS_ORIGINS` (comma-separated)
pins an explicit allowlist for Docker/remote dashboards; `*` opts back
into the old wildcard.
- **`/transformations/feed`** and **`/cache/clear`** gated behind
`require_loopback` → 404 for non-loopback callers. The feed returns full
prompt/completion bodies when `log_full_messages` is on; `/cache/clear`
is unauthenticated state mutation (cache-eviction DoS / cost
amplification).
- **`/health`**: the `config` block (upstream API URLs, savings profile)
is now served only to loopback callers; network callers get the
`/readyz`-shape body (status/checks). `/livez` and `/readyz` remain
unauthenticated probes for orchestration.
- **`/stats`**: `recent_requests` / `request_logs` (per-request ids,
providers, models, errors) and `config` are served only to loopback
callers; aggregate counters stay public for remote monitoring.
- Added `_request_is_loopback()` helper mirroring `require_loopback`'s
two-gate check (loopback peer IP + loopback `Host` header, the
DNS-rebinding defence) but degrading the payload instead of returning
404, so monitors keep the non-sensitive fields.
- Tests: new `tests/test_proxy_cors.py` and
`tests/test_proxy_loopback_gating.py`; updated 4 existing tests that
assert the now-loopback-only data to use loopback clients.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/proxy/server.py tests/test_proxy_cors.py \
    tests/test_proxy_loopback_gating.py tests/test_proxy_healthchecks.py \
    tests/test_proxy_stats_recent_requests.py tests/test_proxy/test_transformations_feed.py
All checks passed!

$ mypy headroom --ignore-missing-imports
Success: no issues found in 380 source files

$ pytest tests/test_proxy_cors.py tests/test_proxy_loopback_gating.py \
    tests/test_proxy/test_transformations_feed.py tests/test_proxy_healthchecks.py \
    tests/test_proxy_stats_recent_requests.py tests/test_proxy_dashboard_stats_cache.py \
    tests/test_proxy_compression_executor.py tests/test_header_isolation.py -q
======================== 82 passed, 1 skipped in 17.75s ========================
```

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12 venv; FastAPI
`TestClient` driving the real `create_app()` ASGI app
- Exact command / steps: issued requests as a non-loopback caller
(`client.host=testclient`) vs a loopback caller
(`base_url=http://127.0.0.1`, `client=("127.0.0.1", 9999)`), plus CORS
preflights with varying `Origin` headers
- Observed result: CORS — `http://evil.com` → no
`access-control-allow-origin`; `http://localhost:8787` and
`http://localhost:9000` → echoed (loopback allowed on any port);
`access-control-allow-credentials` → absent. `/cache/clear` and
`/transformations/feed` → 404 (network) / 200 (loopback). `/health`
`config` block present for loopback only. `/stats` `recent_requests`
present for loopback only, while the aggregate `tokens` block stays
present for network callers.
- Not tested: a live `headroom proxy` process bound on `0.0.0.0` reached
from a second host (simulated via ASGI peer/Host instead); end-to-end
browser DNS-rebinding (covered by the `Host`-header gate and its unit
test)

## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — proxy/middleware change; behavior is captured under "Real Behavior
Proof".

## Additional Notes

**Supersedes two stale PRs that target the same issue but have drifted
from `main`:**

- **#864** (`fix(proxy): scope CORS to localhost`, @gabiudrescu) —
correct instinct and the source of the tighter `GET/POST` +
`Content-Type/Authorization` scoping kept here, but it derived the
allowlist from the `HEADROOM_PORT` env var (wrong when `--port` is
passed as a CLI flag), carried ~40 lines of unrelated punctuation churn,
and is ~125 commits behind `main`. The port-agnostic regex used here
resolves the reviewer's port concern.
- **#758** (`security: adversarial review`, @neogenix) — bundled these
same application-layer fixes with a large CI/CD + Docker supply-chain
pass. It is a ~160-commit-behind draft whose `server.py` no longer
merges cleanly (`main` independently adopted the same `require_loopback`
pattern). The application-layer fixes are rebased onto current `main`
here; the CI/Docker/supply-chain hardening from #758 is still valuable
and would be welcome as a separate, rebased PR.

Thanks to @gabiudrescu and @neogenix for the original analysis (#863).

**Deliberate scope / follow-ups (not in this PR):**
- `/stats` aggregate counters and the basic `/health` body remain
readable on a `0.0.0.0` bind by design, so remote monitoring keeps
working. Full lock-down is a one-line `Depends(require_loopback)` each
if preferred.
- The `/v1/retrieve*` family stays network-reachable; it can't be
loopback-gated without breaking legitimate remote/containerized agents
and needs auth instead — tracked separately.
- `ruff check .` is scoped to changed paths above because the dashboard
HTML template trips ruff's `invalid-syntax` (a known repo
false-positive); `mypy` is run over the full `headroom` package.
2026-06-21 00:50:55 -07:00
gglucass
2269e40bde
feat(proxy): log compressed messages alongside original request (#261)
## Description

Expose the post-compression message list that was actually sent upstream
as a new `compressed_messages` field on `RequestLog`, paired with the
existing (now consistently pre-compression) `request_messages`.
Consumers of `/transformations/feed` — dashboards and any downstream
observability — can now diff the two sides of a compression to see
exactly what the pipeline stripped, replaced, or kept. Turns an abstract
"saved N tokens" into a legible before/after.

Gated by the same `log_full_messages` flag as `request_messages` so the
two sides stay in sync; it's pointless to store one without the other.

Also fixes a latent correctness bug: today's `request_messages` field is
inconsistent across the four `RequestLog` construction sites — sometimes
it's the pre-compression snapshot, sometimes it's the mutated
`body["messages"]` (which is the compressed list, because the proxy
mutates `body` in place before the log call). After this change,
`request_messages` always means pre-compression and
`compressed_messages` always means what went upstream.

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

Note on "breaking": strictly speaking this is a semantic correction of
an inconsistently-populated field, not a schema break. The field name
`request_messages` is unchanged and the JSON shape is unchanged; what
changes is that the field now consistently holds the pre-compression
list. Consumers that treated it as "whatever messages we have" continue
to work. Consumers that depended on the accidental post-compression
value (if any existed) would shift to `compressed_messages`.

## Changes Made

- **`headroom/proxy/models.py`**: `RequestLog` gains
`compressed_messages: list[dict] | None = None`. Doc comment explains
it's paired with `request_messages` and gated by the same
`log_full_messages` flag.
- **`headroom/proxy/handlers/anthropic.py`** (2 sites — Bedrock
non-streaming and main non-streaming): `request_messages` now
consistently sources from `original_messages` (the pre-compression
snapshot at line 724), `compressed_messages` sources from
`body["messages"]` (the compressed list after in-place mutation at line
1189). Both gated symmetrically.
- **`headroom/proxy/handlers/streaming.py`** (2 sites — main streaming
in `_finalize_stream_response`, Bedrock streaming in
`_stream_response_bedrock`): same treatment. `_stream_response_bedrock`
gains a new `original_messages: list[dict] | None = None` parameter so
it has access to the pre-compression snapshot; the sole caller in
`anthropic.py` now threads it through.
- **`headroom/proxy/server.py`**: `/transformations/feed` adds
`compressed_messages` to the JSON payload alongside the existing
`request_messages` / `response_content`. *Split into a separate
preceding commit is a one-time EOL normalization to LF — the file blob
in history carries CRLF but `.gitattributes` declares `*.py text
eol=lf`, so any contributor editing `server.py` triggers the same
whole-file renormalization. Separating the two commits keeps this
feature commit's diff at a single line.*
- **`headroom/proxy/request_logger.py`**: `compressed_messages` is
stripped from the JSONL file log and from `get_recent()` alongside the
existing `request_messages` / `response_content` stripping.
`get_memory_stats()` also counts it toward the deque's byte budget.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .` and `ruff format --check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed (via the Headroom Desktop client that
consumes `/transformations/feed` — confirmed both fields arrive and
render)

Test coverage added/extended:

- `tests/test_proxy/test_request_logger.py` (new file): round-trip unit
tests for `RequestLogger`. Confirms `get_recent` strips both sides (pre
+ post), `get_recent_with_messages` exposes both, and the JSONL file log
drops both when `log_full_messages=False`.
- `tests/test_proxy/test_transformations_feed.py`: extended to assert
`compressed_messages` appears in the endpoint payload alongside
`request_messages` / `response_content`.
- `tests/test_proxy_streaming_request_logger.py`: existing include/omit
tests updated to assert both sides populate when the flag is on and both
are `None` when it's off.

## Test Output

```
$ uv run ruff check headroom tests
All checks passed!
$ uv run ruff format --check headroom tests
614 files already formatted
$ uv run pytest tests/test_proxy/test_request_logger.py tests/test_proxy_streaming_request_logger.py tests/test_proxy/test_transformations_feed.py -v
...
tests/test_proxy/test_request_logger.py::test_get_recent_strips_compressed_messages_alongside_request_and_response PASSED
tests/test_proxy/test_request_logger.py::test_get_recent_with_messages_returns_compressed_messages PASSED
tests/test_proxy/test_request_logger.py::test_jsonl_file_strips_both_sides_when_log_full_messages_disabled PASSED
tests/test_proxy_streaming_request_logger.py::test_finalize_stream_response_logs_original_and_compressed_messages PASSED
tests/test_proxy_streaming_request_logger.py::test_finalize_stream_response_omits_messages_when_log_full_messages_disabled PASSED
tests/test_proxy/test_transformations_feed.py::test_transformations_feed_returns_messages PASSED
...
============================== 11 passed in 5.36s ==============================
```

## 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
(the two-sided gating at each log site, the `_stream_response_bedrock`
parameter addition, and the `get_memory_stats` accounting)
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

### Non-Anthropic backends

`handlers/openai.py` and `handlers/gemini.py` do not currently emit
`RequestLog` entries at all — only Anthropic and the shared streaming
paths do. This PR therefore only populates `compressed_messages` on
Anthropic traffic (which is what `/transformations/feed` shows today).
Wiring OpenAI and Gemini into `RequestLogger` end-to-end is a separate,
larger gap worth its own PR.

### `server.py` EOL normalization

The feature change in `server.py` is a single line. To keep the diff
readable, the preceding commit is a whitespace-only `chore(proxy):
normalize server.py to LF per .gitattributes` — the file blob was stored
with CRLF terminators but `.gitattributes` declares `*.py text eol=lf`.
Any contributor touching `server.py` triggers this renormalization;
isolating it here keeps the feature commit reviewable. Happy to rebase /
drop / reshape as preferred.

### Downstream desktop compatibility

The Headroom Desktop client I work on now consumes `compressed_messages`
and renders the pre/post pair side-by-side on the "Recent large
compression" card. The desktop was updated to handle both shapes:
proxies without the field render the legacy single "Request" block;
proxies with the field render "Request (original, N tokens)" + "Request
(compressed, M tokens)" where N/M come from `input_tokens_original` /
`input_tokens_optimized`. No changes needed downstream if this PR lands.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:02:54 -05:00
JerrettDavis
0aae886f77 feat: add live transformations feed to dashboard
- New /transformations/feed endpoint returning message diffs
- Alpine.js drawer UI with virtual scrolling and auto-stream pause
- Live Feed button hidden when log_full_messages=false
- Added --log-messages CLI flag to enable full message logging
- Backend stores request/response messages when enabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 23:47:43 -05:00