fix(dashboard): require X-API-Key on /dashboard (fail-closed, #25)

The HTML operator page served the same clients/ledger/queue/review data as
the authenticated JSON summary, but with no auth at all. The loopback
requirement it relied on existed only as prose (page note + docstring); it
was never enforced by mount_dashboard, which does not own the app and
cannot constrain the bind address.

Put /dashboard behind the same _guard as /api/dashboard/summary:
- no X-API-Key -> 401, no billing data
- no configured key -> 503 (fail-closed)
- valid key -> 200 HTML (unchanged rendering)

Also drop the now-false 'loopback-only' security claims from the page note
and the mount_dashboard docstring, and update the tests:
- test_dashboard_page_open_and_html now sends a valid key
- new test_dashboard_page_guard_blocks_anonymous_access: no key -> 401,
  body contains no client data (mutation check: fails if guard removed)
- new test_dashboard_wrong_key_same_length_rejected: wrong same-length
  key -> 401 on HTML page too
- test_summary_fail_closed_without_configured_key now asserts the HTML
  page also fails closed (503), not open
- test_dashboard_mounts_onto_existing_app sends a valid key

/api/dashboard/summary behaviour unchanged. Suite: 160 passed, 1 skipped
(before: 158 passed, 1 skipped).
This commit is contained in:
Claude 2026-08-26 02:54:37 -05:00
parent ccccfbf879
commit 08febd56ca
2 changed files with 45 additions and 9 deletions

View File

@ -412,7 +412,7 @@ def render_html(summary: Dict[str, Any]) -> str:
<table><tr><th>Repo</th><th>PR</th><th>Verdict</th><th>Head</th><th>Reviewed at</th></tr>{rows_rg}</table>
<h2>5 · Ledger flash</h2>
<table><tr><th>Client</th><th>Kind</th><th>Amount</th><th>Ref</th><th>Time</th></tr>{rows_lf}</table>
<p class="note">Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.</p>
<p class="note">Operator page — requires the same API key as the JSON summary. Live numbers, no caching.</p>
</body></html>"""
@ -435,8 +435,9 @@ def mount_dashboard(app, core=None, db_path=None, api_key=None):
Registered routes:
* ``GET /dashboard`` — HTML operator page (NO auth; the page renders a
loopback-only warning — the server must bind to 127.0.0.1).
* ``GET /dashboard`` — HTML operator page (same ``X-API-Key`` auth and
fail-closed 401/503 shapes as the JSON summary; never billing data
without a valid key).
* ``GET /api/dashboard/summary`` — JSON payload (``X-API-Key`` auth;
401 on missing/wrong key, 503 when no key is configured — fail-closed).
* ``GET /healthz`` — liveness.
@ -483,9 +484,13 @@ def mount_dashboard(app, core=None, db_path=None, api_key=None):
return jsonify({"ok": True})
@app.get("/dashboard")
@_guard
def dashboard_page():
# No auth — this page is safe only because the server binds to
# loopback. Recompute from the DB at request time (no caching).
# Same guard as /api/dashboard/summary (issue #25): X-API-Key auth,
# fail-closed 401/503 shapes, never billing data without a valid key.
# Loopback binding is defense-in-depth only; it was never enforced, so
# it is NOT the authorization boundary. Recompute from the DB at
# request time (no caching).
summary = dashboard.summary()
return (render_html(summary), 200,
{"Content-Type": "text/html; charset=utf-8"})

View File

@ -291,7 +291,9 @@ class TestMountContract:
does NOT define or run a second Flask app (review finding: #2 owns
the app factory; three-way server.py conflict removed)."""
client = app.test_client()
assert client.get("/dashboard").status_code == 200
# /dashboard is key-protected (issue #25): open with a valid key.
assert client.get("/dashboard",
headers={API_KEY_HEADER: "sekret"}).status_code == 200
assert client.get("/healthz").status_code == 200
def test_no_second_server_module_in_this_issue(self):
@ -365,12 +367,38 @@ class TestServer:
def test_dashboard_page_open_and_html(self, app, seeded):
client = app.test_client()
r = client.get("/dashboard")
r = client.get("/dashboard", headers={API_KEY_HEADER: "sekret"})
assert r.status_code == 200
assert "text/html" in r.content_type
assert "SaaS dashboard" in r.get_data(as_text=True)
assert "Acme" in r.get_data(as_text=True)
def test_dashboard_page_guard_blocks_anonymous_access(self, app, seeded):
"""GET /dashboard with NO X-API-Key must NOT return billing data
(issue #25): same fail-closed guard as the JSON summary — 401 and no
client/ledger/queue content in the body."""
client = app.test_client()
r = client.get("/dashboard")
# no auth → 401, and no billing data leaks in the body
assert r.status_code == 401
body = r.get_data(as_text=True)
assert "Acme" not in body
assert "Poorco" not in body
assert "SaaS dashboard" not in body
def test_dashboard_wrong_key_same_length_rejected(self, app):
"""A wrong key of the SAME length as the configured key must be
rejected with 401 on the HTML page too (matches the JSON endpoint's
constant-time comparison contract, issue #25)."""
client = app.test_client()
configured = "sekret"
wrong_same_length = "hunter"
assert len(wrong_same_length) == len(configured)
r = client.get("/dashboard",
headers={API_KEY_HEADER: wrong_same_length})
assert r.status_code == 401
assert "Acme" not in r.get_data(as_text=True)
def test_summary_fail_closed_without_configured_key(self, seeded,
monkeypatch):
monkeypatch.delenv("SAAS_API_KEY", raising=False)
@ -380,8 +408,11 @@ class TestServer:
client = a.test_client()
r = client.get("/api/dashboard/summary")
assert r.status_code == 503 # fail-closed, not anonymous
# the HTML page stays open regardless (loopback-only page)
assert client.get("/dashboard").status_code == 200
# HTML page fails closed too (issue #25): no key configured → 503,
# and no billing data is returned either way.
r2 = client.get("/dashboard")
assert r2.status_code == 503
assert "Acme" not in r2.get_data(as_text=True)
def test_healthz(self, app):
r = app.test_client().get("/healthz")