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:
parent
ccccfbf879
commit
08febd56ca
@ -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"})
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user