Files
Shadowbroker/backend/tests/test_control_surface_auth.py
T
BigBodyCobain 71a9d9e144 [security] Close post-#227 control-surface and fetcher gaps
PR #227 hardened most Wormhole/Infonet control surfaces behind
require_local_operator and made the CrowdThreat fetcher opt-in. An
audit of the codebase against that PR's stated goals turned up four
classes of gap that the original change missed:

1. Two operator-only endpoints were left unprotected:
   - POST /api/wormhole/join: calls bootstrap_wormhole_identity() and
     flips the node into Tor mode, exactly the surface #227 hardened
     on /api/wormhole/identity/bootstrap.
   - POST /api/sigint/transmit: relays APRS-IS packets over radio
     using operator-supplied credentials. Anything that reached the
     API could transmit on the operator's authority.

   Both now require_local_operator. test_control_surface_auth.py
   extended with regression coverage for both.

2. Five third-party fetchers were still default-on, phoning home to
   politically/commercially sensitive upstreams on every poll cycle:
   - fimi.py            -> euvsdisinfo.eu        -> FIMI_ENABLED
   - prediction_markets -> Polymarket + Kalshi   -> PREDICTION_MARKETS_ENABLED
   - financial.py       -> Finnhub / yfinance    -> FINANCIAL_ENABLED or FINNHUB_API_KEY
   - nuforc_enrichment  -> huggingface.co        -> NUFORC_ENABLED
   - news.py            -> configured RSS feeds  -> NEWS_ENABLED (default on, kill switch)

   Same CrowdThreat-style pattern: explicit env-var opt-in, empty
   the data slot and mark_fresh when disabled. New regression test
   file test_third_party_fetchers_opt_in.py asserts each fetcher's
   network entry point is not called when its gate is off.

3. The outbound User-Agent leaked both the operator's personal email
   and a fork-specific GitHub URL on every fetcher request. Consolidated
   to a single DEFAULT_USER_AGENT in network_utils.py, project-generic
   by default (no contact info), overridable via SHADOWBROKER_USER_AGENT
   for operators who want to identify themselves (e.g. for Nominatim or
   weather.gov usage-policy compliance). Six call sites updated; the
   Nominatim-specific override is preserved.

4. The same generic UA now also flows through the peer prekey lookup
   in mesh_wormhole_prekey.py, so DM first-contact requests no longer
   identify the caller as a Shadowbroker fork to the peer being
   queried.

.env.example updated to document all new opt-in env vars.

Tests: backend/tests/test_control_surface_auth.py (extended),
       backend/tests/test_crowdthreat_opt_in.py (unchanged, still passes),
       backend/tests/test_third_party_fetchers_opt_in.py (new, 7 tests).
All 31 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:53:33 -06:00

88 lines
3.4 KiB
Python

"""Regression coverage for operator-only control surfaces."""
import pytest
@pytest.mark.parametrize(
("method", "path", "payload"),
[
("get", "/api/wormhole/identity", None),
("post", "/api/wormhole/identity/bootstrap", {}),
("post", "/api/wormhole/gate/enter", {"gate_id": "general-talk"}),
("post", "/api/wormhole/gate/leave", {"gate_id": "general-talk"}),
("post", "/api/wormhole/sign", {"event_type": "gate_event", "payload": {"ok": True}}),
("post", "/api/wormhole/gate/key/rotate", {"gate_id": "general-talk", "reason": "test"}),
(
"post",
"/api/wormhole/gate/key/grant",
{
"gate_id": "general-talk",
"recipient_node_id": "node-test",
"recipient_dh_pub": "dh-test",
},
),
("post", "/api/wormhole/gate/persona/create", {"gate_id": "general-talk", "label": "test"}),
(
"post",
"/api/wormhole/gate/persona/activate",
{"gate_id": "general-talk", "persona_id": "persona-test"},
),
("post", "/api/wormhole/gate/persona/clear", {"gate_id": "general-talk"}),
(
"post",
"/api/wormhole/gate/persona/retire",
{"gate_id": "general-talk", "persona_id": "persona-test"},
),
(
"post",
"/api/wormhole/gate/message/sign-encrypted",
{
"gate_id": "general-talk",
"epoch": 1,
"ciphertext": "ciphertext",
"nonce": "nonce",
"format": "mls1",
"envelope_hash": "hash",
},
),
("post", "/api/wormhole/gate/message/compose", {"gate_id": "general-talk", "plaintext": "hello"}),
("post", "/api/wormhole/sign-raw", {"message": "raw"}),
("post", "/api/wormhole/gate/state/export", {"gate_id": "general-talk"}),
("post", "/api/wormhole/gate/proof", {"gate_id": "general-talk"}),
("post", "/api/wormhole/connect", {}),
("post", "/api/layers", {"layers": {"viirs_nightlights": True}}),
("post", "/api/ais/feed", {"msgs": []}),
# Added in post-#227 gap audit:
# /api/wormhole/join also calls bootstrap_wormhole_identity() — same
# identity-takeover surface as /identity/bootstrap. PR #227 hardened
# the latter but missed the former.
("post", "/api/wormhole/join", {}),
# /api/sigint/transmit relays APRS-IS packets over radio using
# operator-supplied credentials. Any caller who reaches this endpoint
# could transmit on the operator's authority. Must be local-only.
(
"post",
"/api/sigint/transmit",
{
"callsign": "N0CALL",
"passcode": "12345",
"target": "NOCALL",
"message": "test",
},
),
],
)
def test_remote_control_surface_rejects_without_local_operator_or_admin(
remote_client, method, path, payload
):
request = getattr(remote_client, method)
response = request(path, json=payload) if payload is not None else request(path)
assert response.status_code == 403
def test_remote_agent_actions_poll_rejects_without_local_operator_or_admin(remote_client):
response = remote_client.get("/api/ai/agent-actions")
assert response.status_code == 403