fix(dashboard): require X-API-Key on /dashboard (fail-closed, #25) #26
No reviewers
Labels
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gitea-saas-core#26
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-25"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #25
The finding
GET /dashboardserved clients, ledger, queue, throughput and review-gatedata with no auth at all, while the sibling JSON route
GET /api/dashboard/summaryserved the same data behind_guard(
X-API-Key, 401/503 fail-closed). The loopback requirement the HTML pagerelied on existed only as prose — a page note and a docstring line — and was
never enforced anywhere:
mount_dashboardregisters routes on a caller-ownedFlask app and neither inspects the bind address nor reads
request.host, soapp.run(host="0.0.0.0")(or any reverse proxy) would publish the billingdashboard to the network unauthenticated.
Fix (preferred route from the issue)
Put
/dashboardbehind the same_guardas/api/dashboard/summary:X-API-Key→401and no billing data in the body503(fail-closed)200HTML page, rendering unchangedThe now-false loopback-only prose claims were removed from the page note and
the
mount_dashboarddocstring; loopback binding remains deploymentdefense-in-depth, not the authorization boundary.
Acceptance criteria
GET /dashboardwith noX-API-Keydoes not return billing data —401and body contains no client/ledger/queue data
(
test_dashboard_page_guard_blocks_anonymous_access).Non-loopback Host rejection — N/A: took the preferred
_guardroute, notallow_non_loopback.Mutation check — removing
@_guardfrom/dashboardmakes bothtest_dashboard_page_guard_blocks_anonymous_accessandtest_dashboard_wrong_key_same_length_rejectedfail (verified live:assert 200 == 401). The guard is load-bearing./api/dashboard/summarybehaviour unchanged — existing JSON tests stillpass untouched (401 wrong/missing key, 503 no configured key, 200 valid).
Suite green before/after:
158 passed, 1 skipped160 passed, 1 skipped(+2 security tests)Also updated:
test_dashboard_page_open_and_htmlnow sends a valid key (200 + HTML unchanged)test_summary_fail_closed_without_configured_keynow asserts the HTML pagealso fails closed (503), not open
test_dashboard_mounts_onto_existing_appsends a valid keyOut of scope (per #25): API-key consolidation (#24), stripe webhook signature
check,
/healthz.Verified green, deliberately NOT merged -- escalated to the owner.
This PR is on the sweep's escalation list ("the PR touches auth, credentials,
..."), and this repo as a whole is held pending an owner decision on the auth
design. So this is a review, not a merge.
What I measured
That matches the PR's own claim (158 -> 160, +2 security tests) exactly.
The fix is the right shape
GET /dashboardserved clients, ledger, queue and throughput data with no authwhile its sibling
GET /api/dashboard/summaryserved the SAME data behind_guard. Putting both behind one guard is correct, and removing theloopback-only prose is the right call:
mount_dashboardregisters routes on acaller-owned Flask app and never inspects the bind address or
request.host,so that claim was never enforced by anything. A comment asserting a security
property nothing implements is worse than no comment.
The reason this is held, and it is not about quality
!27(#24) conflicts with this PR, and the conflict resolution can silentlyundo it. See the detail on
!27-- summary:!27branched from a main where/dashboardwas public and still carriesso resolving the conflict in
!27's favour reinstates the exact hole this PRcloses. Merging this first and then mis-resolving
!27is a realistic path tobelieving #25 is fixed while it is not.
Ordering recommendation for whenever the owner greenlights: this one first,
then rebase
!27onto it, then re-run the suite and asserttest_dashboard_page_guard_blocks_anonymous_accessstill fails when the guardis removed.
Escalated to the owner — not merged by the sweep
This PR is on the sweep's escalation list and I am deliberately leaving it for a
human. The standing rule: a PR that touches auth, credentials,
execute_action,the action allow-list, or the adversarial corpus is escalated rather than merged.
gitea-saas-coreis additionally a standing owner escalation in its own right —it holds a key authorizing debits, a Stripe secret, and a repo-creating Gitea
token, so the blast radius of an auth mistake here is money and repo control, not
a failed test.
This is not a defect finding. I have not reviewed the diff on its merits and
nothing here says the change is wrong. It is a "who is allowed to merge this"
call, not a "is this correct" call.
Deliberately not labelling this
bounced: that label means "rework wanted"and would put it in the worker rework queue, which is the opposite of what this
needs.
I also could not have run a gate this sweep even if the change were mergeable —
see
gitea-worker-ops#40:wsl.exeexec fails on the review box withWsl/Service/0x8007274c(reproduced twice, 31s each) whilewsl -l -vstillreports the distro
Running.What the owner needs to do: review and merge, or hand it back with a finding.
Gate evidence -- the "I could not run a gate" caveat on my earlier escalation is now stale
My 2026-08-26 escalation said I could not gate this because
wsl.exeexec wasfailing on the review box (
gitea-worker-ops#40). That has cleared -- probedwsl -e echo alive->alive, exit 0. So I have now run the real gate.The escalation itself is unchanged and this PR is still not merged by the
sweep. This comment adds evidence only; it is not a defect finding and not an
approval.
Gate:
pytest -qunderC:\Users\Videostation\AppData\Local\Programs\Python\Python312\python.exe(pytest 8.3.2), in a throwaway detached worktree, against the merge result
(
git merge origin/maininto the PR branch), never the branch alone.The suite is safe to run here:
ARCHITECTURE.md:72states the tests arehermetic (mock Stripe + mock Gitea + tmp SQLite), and the run touches no live
service, no Stripe key and no real Gitea token.
Count arithmetic checks exactly:
158 + 2 = 160, adds 2def test_, removes 0.Read this before merging: !27 already contains this PR
issue-25is a direct ancestor ofissue-24(!27), measured, not inferred:!27 is stacked on top of this branch and carries both of its tests. Composing
them in a throwaway tree in either order gives 0 conflicts and 173 passed --
the same as !27 alone, not
158 + 2 + 15 = 175.So merging !27 alone delivers this change too. !27 then goes further and
replaces the local
_guardhere with the sharedgitea_saas/auth.pyconstant-time path, so this PR's inline guard is code !27 deletes.
This PR is green and correct on its own merits; it is simply redundant if !27
lands. Whether to close it as subsumed or merge it first is a call on an
escalated auth PR, so it is the owner's, not the sweep's. Still not merged by
the sweep; the escalation is unchanged.
ChatGPT (gpt-5.6-sol) review
Reviewed by
gpt-5.6-solon the ChatGPT subscription, because Opus usage is exhausted. The model did not decide the mechanical checks — those ran first and passed:VERDICT: CONCERNS
REASON: ESCALATE: this changes authentication behavior, and the missing diff prevents verification that
/dashboardfails closed or that tests exercise the new behavior.NOTES:
origin/master...HEADrevision error, so no implementation or test changes are reviewable.Reviewed by gpt-5.6-sol while Opus usage was exhausted. The mechanical checks above are authoritative; the model's MERGE is a recommendation.
Closed as subsumed by owner-authorized PR #27
PR #27 merged at head
3819b49498030f49b3ecc842f9ef5e1f29d5985eafter its exact current-base synthetic merge passed 218 tests with 1 skipped. This PR's exact head08febd56ca1ea4e23efb0f99887a76efa52ddf13is an ancestor of both PR #27 and the resultingmainb4e577b31b6b4c7cbdf6a9ac5508bfba64431b02.Therefore the guarded
/dashboardimplementation and its load-bearing anonymous/same-length-wrong-key tests are already onmain. This PR is closed unmerged as redundant; no work was discarded and the guard was not reimplemented twice.Pull request closed