diff --git a/gitea_saas/dashboard.py b/gitea_saas/dashboard.py index 349e74b..e5df4c4 100644 --- a/gitea_saas/dashboard.py +++ b/gitea_saas/dashboard.py @@ -412,7 +412,7 @@ def render_html(summary: Dict[str, Any]) -> str: {rows_rg}
RepoPRVerdictHeadReviewed at

5 · Ledger flash

{rows_lf}
ClientKindAmountRefTime
-

Listen-only page: bind the server to 127.0.0.1. Live numbers, no caching.

+

Operator page — requires the same API key as the JSON summary. Live numbers, no caching.

""" @@ -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"}) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b6c9e1a..5d070d5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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")