Skip to content

fix(proxy): allow settings routes for trusted gateway/dashboard clients#2491

Open
Parideboy wants to merge 2 commits into
headroomlabs-ai:mainfrom
Parideboy:fix/settings-behind-gateway-2466
Open

fix(proxy): allow settings routes for trusted gateway/dashboard clients#2491
Parideboy wants to merge 2 commits into
headroomlabs-ai:mainfrom
Parideboy:fix/settings-behind-gateway-2466

Conversation

@Parideboy

@Parideboy Parideboy commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Description

/settings, /settings/schema, /settings/apply, and /dashboard/settings were gated by _require_loopback, which checks request.client.host directly and 404s for any non-loopback caller. When headroom-proxy runs behind a reverse-proxy/gateway (e.g. in a container), request.client.host is the gateway's IP, so these routes 404 unconditionally — even with HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS/HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS configured, a trust chain /stats and /stats-lifetime already use.

Fixes #2466.

Type of Change

  • Bug fix

Changes Made

  • Added _require_loopback_or_trusted_dashboard_client dependency in headroom/proxy/server.py, reusing the existing _request_can_view_dashboard_metadata trust chain (loopback check, IP-literal Host header check, same-origin check, trusted-gateway CIDR check).
  • Swapped this dependency in for _require_loopback on exactly five routes: /settings/schema, GET /settings, POST /settings, POST /settings/apply, /dashboard/settings. All other loopback-only admin/debug routes (/admin/*, /debug/*, /cache/clear, /v1/retrieve*) are untouched.
  • Added test coverage in tests/test_proxy_loopback_gating.py: non-loopback without trusted CIDR still 404s, loopback still allowed, trusted-gateway dashboard client is now allowed, and CIDR mismatch still 404s.

Testing

  • Added/updated tests
  • Ran full test suite locally
$ python -m pytest tests/test_proxy_loopback_gating.py tests/test_proxy_settings_endpoints.py -q
73 passed, 1 warning in 28.80s

$ ruff check headroom/proxy/server.py tests/test_proxy_loopback_gating.py
All checks passed!

$ ruff format --check headroom/proxy/server.py tests/test_proxy_loopback_gating.py
1 file already formatted, 1 file already formatted

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

Real Behavior Proof

  • Environment: Windows 11, Python 3.13.11, pytest 9.1.1, headroom repo local checkout
  • Exact command / steps: python -m pytest tests/test_proxy_loopback_gating.py -q after adding parametrized tests that set HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS and hit /settings, /settings/schema, /dashboard/settings from a simulated gateway-forwarded peer IP
  • Observed result: all 51 tests in the file pass, including new cases confirming trusted-gateway clients get 200 (previously 404) while unlisted/mismatched clients still get 404
  • Not tested: did not manually deploy a real Docker container behind an actual reverse-proxy (e.g. nginx/Traefik) to reproduce the original reporter's exact setup; relied on TestClient-simulated forwarded headers/peer IPs instead

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

/settings, /settings/schema, /settings/apply, and /dashboard/settings
were gated by _require_loopback, which checks request.client.host
directly and 404s for any non-loopback peer. Behind a reverse-proxy or
container gateway, request.client.host is the gateway's IP, so the
settings UI 404ed unconditionally even with
HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS/HEADROOM_PROXY_TRUSTED_DASHBOARD_CLIENT_CIDRS
configured — a trust chain /stats and /stats-lifetime already use via
_request_can_view_dashboard_metadata.

Add _require_loopback_or_trusted_dashboard_client, which reuses that
same trust chain, and swap it in for _require_loopback on the five
settings/dashboard routes only. All other loopback-only admin/debug
routes are untouched.

Fixes headroomlabs-ai#2466

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added status: needs author action Pull request body or readiness checklist still needs author updates status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates labels Jul 22, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is close, but the mutating settings routes are still broken for the browser/dashboard case the PR is trying to enable.

The new _require_loopback_or_trusted_dashboard_client dependency lets a trusted non-loopback dashboard client reach POST /settings and POST /settings/apply, but those routes still also depend on require_same_origin. That helper only accepts loopback origins, so a real same-origin dashboard POST from the trusted remote/IP-literal dashboard host is rejected:

client = TestClient(
    app,
    base_url="http://100.82.0.2:8787",
    client=("100.90.0.5", 12345),
)
client.post("/settings", json={"values": {}}, headers={"Origin": "http://100.82.0.2:8787"})
# 403 {"detail":"cross-origin request rejected"}

I verified locally on this branch that the same trusted client gets 200 with no Origin header, but gets 403 with the same-origin Origin header above. Browsers commonly include Origin on POST/fetch requests, so the settings UI can still fail behind the trusted gateway even though the GET routes work.

Please make the CSRF/provenance check for these settings writes match the same trusted-dashboard origin policy used by _request_can_view_dashboard_metadata (while still rejecting foreign origins), and add regression coverage for a trusted dashboard client posting with Origin: http://<dashboard-host>:<port> to both /settings and /settings/apply.

Other checked state: PR is mergeable and visible CI is green. I did not run the full file after finding this blocker; the reproduction above is from the latest checked-out source.

require_same_origin only accepted an Origin naming a loopback host, so
a trusted-gateway dashboard client (allowed by the GET routes) still
got 403 on POST /settings and POST /settings/apply since browsers send
Origin on POST. Add _require_same_origin_or_trusted_dashboard_client,
which additionally accepts a non-loopback caller whose Origin matches
its own Host header and who is already a CIDR-trusted dashboard
client. Loopback callers keep the stricter loopback-only origin check.

Addresses JerrettDavis's review on headroomlabs-ai#2491.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Jul 22, 2026
@Parideboy

Parideboy commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@JerrettDavis Good catch, fixed in 2d14a8a.

Added _require_same_origin_or_trusted_dashboard_client: for non-loopback callers it accepts an Origin that matches the request's own Host header, provided the caller is already an IP-literal-Host, CIDR-trusted dashboard client (reusing _request_can_view_dashboard_metadata). Loopback callers are untouched, they keep the strict loopback-only origin check (including the Origin: null rejection).

Swapped this in for _require_same_origin on POST /settings and POST /settings/apply.

Added regression coverage in tests/test_proxy_loopback_gating.py:

  • trusted client + matching Origin → no longer 403 on either route
  • trusted client + foreign Origin → still rejected (403/404)
  • loopback caller + Origin: null → still 403 (confirms the carve-out didn't weaken the loopback path)

pytest tests/test_proxy_loopback_gating.py tests/test_proxy_settings_endpoints.py 78 passed. ruff check/ruff format --check clean, mypy headroom --ignore-missing-imports clean.

@Parideboy
Parideboy requested a review from JerrettDavis July 22, 2026 15:04
@github-actions github-actions Bot removed the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 22, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latest changes look good. I re-ran the targeted settings/loopback gating suite locally (python -m pytest tests/test_proxy_loopback_gating.py tests/test_proxy_settings_endpoints.py -q: 78 passed) and reviewed the trusted-dashboard same-origin path. The settings routes now match the existing dashboard metadata trust model without widening the unrelated admin/debug endpoints.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/proxy/server.py 92.30% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Cannot open headroom settings when running in a container as a proxy

3 participants