From 084e563412ecf5f9b18bc9b3c881bc73b4fafd78 Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Thu, 21 May 2026 09:45:08 -0600 Subject: [PATCH] Fix #240/#241: require admin auth on oracle resolve endpoints (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both POST /api/mesh/oracle/resolve and POST /api/mesh/oracle/resolve-stakes were previously gated only by a rate limit (5/min) and tagged with `mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)`. The exemption decorator is metadata only — it tells the mesh signed-write middleware not to require a signature envelope, it does NOT enforce caller authorization. Any network caller could: - /resolve: settle any prediction market to any outcome (corrupts every downstream profile/win-loss count derived from that ledger). - /resolve-stakes: trigger stake settlement for all expired contests at a time of their choosing (race against operator intent). Fix: add `dependencies=[Depends(require_admin)]` to both routes. The existing `mesh_write_exempt` tag stays in place because it accurately describes the route's relationship to the signed-write envelope system; adding `require_admin` is what closes the actual auth hole. Tests in backend/tests/test_oracle_resolve_auth_gate.py: - anonymous caller -> 403, ledger mutator NOT called - wrong admin key -> 403, ledger mutator NOT called - valid admin key -> 200, ledger mutator called - admin key unconfigured + no debug/insecure-admin -> 403 Credit: tg12 (external security audit) --- backend/routers/mesh_oracle.py | 25 ++- .../tests/test_oracle_resolve_auth_gate.py | 160 ++++++++++++++++++ 2 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_oracle_resolve_auth_gate.py diff --git a/backend/routers/mesh_oracle.py b/backend/routers/mesh_oracle.py index 7e8cca6..35e024f 100644 --- a/backend/routers/mesh_oracle.py +++ b/backend/routers/mesh_oracle.py @@ -223,11 +223,21 @@ async def oracle_markets_more(request: Request, category: str = "NEWS", offset: "has_more": offset + limit < len(cat_markets), "total": len(cat_markets)} -@router.post("/api/mesh/oracle/resolve") +@router.post( + "/api/mesh/oracle/resolve", + dependencies=[Depends(require_admin)], +) @limiter.limit("5/minute") @mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL) async def oracle_resolve(request: Request): - """Resolve a prediction market.""" + """Resolve a prediction market. + + Issue #240 (tg12): requires admin authentication. The + ``mesh_write_exempt`` decorator below is **metadata only** — it tags + the route as not requiring a mesh signed-write envelope, it does + NOT itself enforce caller authorization. The ``Depends(require_admin)`` + on the route decorator is what actually gates access. + """ from services.mesh.mesh_oracle import oracle_ledger body = await request.json() market_title = body.get("market_title", "") @@ -327,11 +337,18 @@ async def oracle_predictions(request: Request, node_id: str = ""): active_predictions, authenticated=_scoped_view_authenticated(request, "mesh.audit")) -@router.post("/api/mesh/oracle/resolve-stakes") +@router.post( + "/api/mesh/oracle/resolve-stakes", + dependencies=[Depends(require_admin)], +) @limiter.limit("5/minute") @mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL) async def oracle_resolve_stakes(request: Request): - """Resolve all expired stake contests.""" + """Resolve all expired stake contests. + + Issue #241 (tg12): requires admin authentication. See the note on + ``oracle_resolve`` above — ``mesh_write_exempt`` is metadata only. + """ from services.mesh.mesh_oracle import oracle_ledger resolutions = oracle_ledger.resolve_expired_stakes() return {"ok": True, "resolutions": resolutions, "count": len(resolutions)} diff --git a/backend/tests/test_oracle_resolve_auth_gate.py b/backend/tests/test_oracle_resolve_auth_gate.py new file mode 100644 index 0000000..d89fb72 --- /dev/null +++ b/backend/tests/test_oracle_resolve_auth_gate.py @@ -0,0 +1,160 @@ +"""Issues #240 & #241 (tg12): oracle market/stake resolution endpoints +must require admin authentication. + +Before the fix, ``POST /api/mesh/oracle/resolve`` and +``POST /api/mesh/oracle/resolve-stakes`` were decorated with +``@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)``. That decorator +only tags the route as not requiring a mesh signed-write envelope; it +does NOT enforce authorization. The rate limiter (5/minute) was the +only real gate, which is wrong for control-plane state mutations. + +The fix adds ``dependencies=[Depends(require_admin)]`` to both routes. +These tests prove: + +- Anonymous callers receive 403. +- A request bearing the configured admin key passes the auth gate. +- The underlying ledger mutator is not invoked on a 403. +""" +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.testclient import TestClient + + +_ADMIN_KEY = "test-admin-key-for-oracle-resolve-fixture-32+" + + +@pytest.fixture +def client(): + """TestClient with the private-lane transport middleware short-circuited. + + The ``enforce_high_privacy_mesh`` middleware in ``main.py`` returns + HTTP 202 ("preparing private lane") for ``/api/mesh/*`` requests + when the Wormhole supervisor is not yet at the required transport + tier. In tests that's always — Wormhole is not running. Patching + ``_minimum_transport_tier`` to return None disables the tier check + for the duration of the test, letting the request reach the route + (and therefore reach the ``Depends(require_admin)`` we are testing). + """ + import main + with patch("main._minimum_transport_tier", return_value=None): + yield TestClient(main.app, raise_server_exceptions=False) + + +@pytest.fixture +def mock_ledger(): + """Replace oracle_ledger methods so tests don't mutate persistent state. + + The handler does ``from services.mesh.mesh_oracle import oracle_ledger`` + at call time, so we patch the module attribute. + """ + fake = MagicMock() + fake.resolve_market.return_value = (0, 0) + fake.resolve_market_stakes.return_value = {"winners": 0, "losers": 0} + fake.resolve_expired_stakes.return_value = [] + with patch("services.mesh.mesh_oracle.oracle_ledger", fake): + yield fake + + +# --------------------------------------------------------------------------- +# /api/mesh/oracle/resolve — issue #240 +# --------------------------------------------------------------------------- + + +class TestOracleResolveAuthGate: + def test_anonymous_caller_is_rejected(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post( + "/api/mesh/oracle/resolve", + json={"market_title": "test-market", "outcome": "Yes"}, + ) + assert r.status_code == 403 + # Critically: the ledger mutator must NOT have been called on a 403. + assert mock_ledger.resolve_market.call_count == 0 + assert mock_ledger.resolve_market_stakes.call_count == 0 + + def test_wrong_admin_key_rejected(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post( + "/api/mesh/oracle/resolve", + headers={"X-Admin-Key": "this-key-is-wrong"}, + json={"market_title": "test-market", "outcome": "Yes"}, + ) + assert r.status_code == 403 + assert mock_ledger.resolve_market.call_count == 0 + + def test_valid_admin_key_passes_auth_gate(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post( + "/api/mesh/oracle/resolve", + headers={"X-Admin-Key": _ADMIN_KEY}, + json={"market_title": "test-market", "outcome": "Yes"}, + ) + # The auth gate let us through. The handler ran and called the + # (mocked) ledger. + assert r.status_code == 200 + assert mock_ledger.resolve_market.call_count == 1 + assert mock_ledger.resolve_market.call_args[0] == ("test-market", "Yes") + + def test_admin_key_unset_blocks_in_production_posture(self, client, mock_ledger): + """When ADMIN_KEY env is not configured at all and we're not in + debug, the endpoint must still refuse — never silently accept.""" + with ( + patch("auth._current_admin_key", return_value=""), + patch("auth._allow_insecure_admin", return_value=False), + patch("auth._debug_mode_enabled", return_value=False), + patch("auth._scoped_admin_tokens", return_value={}), + ): + r = client.post( + "/api/mesh/oracle/resolve", + json={"market_title": "test-market", "outcome": "Yes"}, + ) + assert r.status_code == 403 + assert mock_ledger.resolve_market.call_count == 0 + + +# --------------------------------------------------------------------------- +# /api/mesh/oracle/resolve-stakes — issue #241 +# --------------------------------------------------------------------------- + + +class TestOracleResolveStakesAuthGate: + def test_anonymous_caller_is_rejected(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post("/api/mesh/oracle/resolve-stakes") + assert r.status_code == 403 + assert mock_ledger.resolve_expired_stakes.call_count == 0 + + def test_wrong_admin_key_rejected(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post( + "/api/mesh/oracle/resolve-stakes", + headers={"X-Admin-Key": "nope"}, + ) + assert r.status_code == 403 + assert mock_ledger.resolve_expired_stakes.call_count == 0 + + def test_valid_admin_key_passes_auth_gate(self, client, mock_ledger): + with patch("auth._current_admin_key", return_value=_ADMIN_KEY): + r = client.post( + "/api/mesh/oracle/resolve-stakes", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + assert mock_ledger.resolve_expired_stakes.call_count == 1 + body = r.json() + assert body["ok"] is True + assert body["count"] == 0 + + def test_admin_key_unset_blocks_in_production_posture(self, client, mock_ledger): + with ( + patch("auth._current_admin_key", return_value=""), + patch("auth._allow_insecure_admin", return_value=False), + patch("auth._debug_mode_enabled", return_value=False), + patch("auth._scoped_admin_tokens", return_value={}), + ): + r = client.post("/api/mesh/oracle/resolve-stakes") + assert r.status_code == 403 + assert mock_ledger.resolve_expired_stakes.call_count == 0