From e12546772115c4360642f6ea7a8fa563da42919e Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Thu, 21 May 2026 10:32:23 -0600 Subject: [PATCH] Fix #243/#252/#253: stop leaking settings posture to anonymous callers (#283) Three settings endpoints were disclosing operational posture or operator-curated configuration to any network caller. This change either tightens the redacted-public view (#243) or adds a local-operator auth gate (#252, #253) per the audit recommendations. Zero hostility to legitimate users: in all three cases, the Tauri shell (loopback), the Docker bridge frontend container (#250 + #278), and any caller with an admin key continue to see the full data. Only anonymous LAN/internet callers see the reduced surface. == #243 (Wormhole transport posture, anonymous-mode, profile, node mode) Tightened the public-redaction allowlists in BOTH the main.py and routers/wormhole.py copies: - _WORMHOLE_PUBLIC_SETTINGS_FIELDS: {enabled, transport, anonymous_mode} -> {enabled} - _WORMHOLE_PUBLIC_PROFILE_FIELDS: {profile, wormhole_enabled} -> {wormhole_enabled} `GET /api/settings/node` (both the routers/admin.py and main.py copies) now returns an empty stub for unauthenticated callers and the full node_mode + node_enabled fields only for authenticated callers via _scoped_view_authenticated(request, "node"). == #252 (news feed inventory disclosure) `GET /api/settings/news-feeds` now requires Depends(require_local_operator) in both the canonical routers/admin.py handler and the duplicate main.py handler. Anonymous callers can no longer enumerate operator-curated feed names and URLs. == #253 (Time Machine archival-capture posture disclosure) `GET /api/settings/timemachine` now requires Depends(require_local_operator). Anonymous callers can no longer fingerprint whether a deployment is retaining replayable historical surveillance data. Tests: backend/tests/test_round5_settings_info_disclosure.py (10 tests) - Wormhole settings: anonymous sees only `enabled`; authenticated sees full state. - Privacy profile: anonymous sees only `wormhole_enabled`; authenticated sees `profile` + `transport` + `anonymous_mode`. - Node settings: anonymous sees `{}`; authenticated sees node_mode + node_enabled + persisted state. - news-feeds: anonymous gets 403 (and get_feeds() is NOT called); authenticated gets full inventory. - timemachine: anonymous gets 403; authenticated sees enabled + storage_warning. Local: 73/73 security suite (round 5 + earlier rounds) green. Credit: tg12 (external security audit, P1 + 2x Medium). --- backend/main.py | 34 ++- backend/routers/admin.py | 32 ++- backend/routers/wormhole.py | 9 +- .../test_round5_settings_info_disclosure.py | 263 ++++++++++++++++++ 4 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_round5_settings_info_disclosure.py diff --git a/backend/main.py b/backend/main.py index 81e6039..aa6710f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1080,8 +1080,18 @@ def _public_mesh_log_size(entries: list[dict[str, Any]]) -> int: return sum(1 for item in entries if _public_mesh_log_entry(item) is not None) -_WORMHOLE_PUBLIC_SETTINGS_FIELDS = {"enabled", "transport", "anonymous_mode"} -_WORMHOLE_PUBLIC_PROFILE_FIELDS = {"profile", "wormhole_enabled"} +# Issue #243 (tg12): the public redaction now exposes only the bare +# "is Wormhole on?" boolean. Transport choice (tor/i2p/mixnet/direct), +# anonymous-mode state, and the named privacy profile are all +# operational posture and were leaking actionable recon to any +# unauthenticated caller. They are now gated behind authenticated reads +# (admin key or scoped-view token). Loopback Tauri shells and Docker +# bridge frontend containers continue to see full status because the +# Next.js catch-all proxy injects the configured ADMIN_KEY for +# same-origin/non-browser callers (see PR #263), so legitimate operator +# UX is unaffected. +_WORMHOLE_PUBLIC_SETTINGS_FIELDS = {"enabled"} +_WORMHOLE_PUBLIC_PROFILE_FIELDS = {"wormhole_enabled"} _PRIVATE_LANE_CONTROL_FIELDS = {"private_lane_tier", "private_lane_policy"} _PUBLIC_RNS_STATUS_FIELDS = {"enabled", "ready", "configured_peers", "active_peers"} _NODE_PUBLIC_EVENT_HOOK_REGISTERED = False @@ -8810,9 +8820,14 @@ async def api_uw_flow(request: Request): from services.news_feed_config import get_feeds, save_feeds, reset_feeds -@app.get("/api/settings/news-feeds") +@app.get( + "/api/settings/news-feeds", + dependencies=[Depends(require_local_operator)], +) @limiter.limit("30/minute") async def api_get_news_feeds(request: Request): + """Issue #252 (tg12): gated on local-operator. See the canonical + handler in backend/routers/admin.py for the full rationale.""" return get_feeds() @@ -9015,9 +9030,22 @@ class NodeSettingsUpdate(BaseModel): @app.get("/api/settings/node") @limiter.limit("30/minute") async def api_get_node_settings(request: Request): + """Issue #243 (tg12): node mode and participant state are + operational posture. Anonymous callers receive an empty stub — + enough for the UI to know the endpoint exists but nothing + fingerprintable. Authenticated callers see the full state. + + Authenticated == local-operator (loopback / Docker bridge) OR an + admin / scoped-view token. The Tauri shell and Docker frontend + container both qualify via their existing transport (PR #263 + + PR #278), so legitimate operator UX is unchanged. + """ from services.node_settings import read_node_settings data = await asyncio.to_thread(read_node_settings) + authenticated = _scoped_view_authenticated(request, "node") + if not authenticated: + return {} return { **data, "node_mode": _current_node_mode(), diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 33053b5..5678623 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -82,9 +82,18 @@ async def api_get_keys_meta(request: Request): return get_env_path_info() -@router.get("/api/settings/news-feeds") +@router.get( + "/api/settings/news-feeds", + dependencies=[Depends(require_local_operator)], +) @limiter.limit("30/minute") async def api_get_news_feeds(request: Request): + """Issue #252 (tg12): the curated feed inventory is configuration + state, not a public data feed. Gated on local-operator so the + Tauri shell, the Docker bridge frontend, and any caller with an + admin key all see the full list; anonymous LAN/internet callers + can no longer enumerate operator source URLs. + """ from services.news_feed_config import get_feeds return get_feeds() @@ -118,9 +127,18 @@ async def api_reset_news_feeds(request: Request): @router.get("/api/settings/node") @limiter.limit("30/minute") async def api_get_node_settings(request: Request): + """Issue #243 (tg12): node_mode and node_enabled are operational + posture. Anonymous callers receive an empty stub; authenticated + callers (local-operator or admin/scoped token) see the full + state. See the canonical handler in backend/main.py for the full + rationale. + """ import asyncio + from auth import _scoped_view_authenticated from services.node_settings import read_node_settings data = await asyncio.to_thread(read_node_settings) + if not _scoped_view_authenticated(request, "node"): + return {} return { **data, "node_mode": _current_node_mode(), @@ -210,9 +228,19 @@ async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqt return _meshtastic_runtime_snapshot() -@router.get("/api/settings/timemachine") +@router.get( + "/api/settings/timemachine", + dependencies=[Depends(require_local_operator)], +) @limiter.limit("30/minute") async def api_get_timemachine_settings(request: Request): + """Issue #253 (tg12): archival-capture posture is operationally + sensitive — it tells a remote caller whether this deployment is + retaining replayable historical surveillance data. Gated on + local-operator so the Tauri shell and Docker bridge frontend + still see the toggle state, but anonymous LAN/internet callers + can no longer fingerprint Time Machine state. + """ import asyncio from services.node_settings import read_node_settings data = await asyncio.to_thread(read_node_settings) diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index faf19ed..cbd34bd 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -160,8 +160,13 @@ router = APIRouter() # --- Constants --- -_WORMHOLE_PUBLIC_SETTINGS_FIELDS = {"enabled", "transport", "anonymous_mode"} -_WORMHOLE_PUBLIC_PROFILE_FIELDS = {"profile", "wormhole_enabled"} +# Issue #243 (tg12): the public redaction now exposes only the bare +# "is this on?" boolean. Transport choice, anonymous-mode state, and +# the named privacy profile were all leaking actionable recon to +# unauthenticated callers and are now gated behind authenticated reads. +# See the matching block in backend/main.py for the full rationale. +_WORMHOLE_PUBLIC_SETTINGS_FIELDS = {"enabled"} +_WORMHOLE_PUBLIC_PROFILE_FIELDS = {"wormhole_enabled"} _PRIVATE_LANE_CONTROL_FIELDS = {"private_lane_tier", "private_lane_policy"} _PUBLIC_RNS_STATUS_FIELDS = {"enabled", "ready", "configured_peers", "active_peers"} _NODE_PUBLIC_EVENT_HOOK_REGISTERED = False diff --git a/backend/tests/test_round5_settings_info_disclosure.py b/backend/tests/test_round5_settings_info_disclosure.py new file mode 100644 index 0000000..55a9cf7 --- /dev/null +++ b/backend/tests/test_round5_settings_info_disclosure.py @@ -0,0 +1,263 @@ +"""Issues #243, #252, #253 (tg12): settings endpoints must not leak +operational posture to unauthenticated callers. + +- **#243**: ``GET /api/settings/wormhole``, ``/api/settings/privacy-profile``, + and ``/api/settings/node`` were leaking transport choice, anonymous-mode + state, the named privacy profile, and node-participant state to any + unauthenticated caller. The fix tightens the redaction allowlists to + expose ONLY a bare "is this feature on?" boolean and gates node mode + behind authenticated reads. + +- **#252**: ``GET /api/settings/news-feeds`` returned the operator's full + curated feed inventory (names + URLs) to anyone. Now gated on + local-operator. + +- **#253**: ``GET /api/settings/timemachine`` returned whether archival + capture is enabled to anyone. Now gated on local-operator. + +Auth model: ``require_local_operator`` allows loopback (Tauri shell), +the Docker bridge frontend container (via the hostname-bound trust from +PR #278), and any caller that presents the configured admin key. +Anonymous LAN or internet callers do NOT pass and either receive 403 +(news-feeds, timemachine) or a redacted minimum (wormhole / node). +""" +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.testclient import TestClient + + +_ADMIN_KEY = "test-admin-key-for-round5-fixture-32+chars" + + +@pytest.fixture +def client(): + """TestClient with the private-lane transport middleware disabled. + + Same shape as the oracle resolve fixture — the mesh privacy + middleware returns 202 for ``/api/settings/*`` under TestClient + because Wormhole is not actually running. Patching out the tier + requirement lets requests reach the route's auth gate. + """ + import main + with patch("main._minimum_transport_tier", return_value=None): + yield TestClient(main.app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# #243: Wormhole posture redaction +# --------------------------------------------------------------------------- + + +class TestWormholeSettingsRedaction: + """``GET /api/settings/wormhole`` must NOT leak transport choice or + anonymous-mode state to unauthenticated callers.""" + + def _read_settings_payload(self): + return { + "enabled": True, + "transport": "tor_arti", + "anonymous_mode": True, + "privacy_profile": "high", + "socks_proxy": "socks5h://127.0.0.1:9050", + } + + def test_anonymous_caller_sees_only_enabled_bool(self, client): + with ( + patch("main.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("routers.wormhole.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("services.wormhole_settings.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get("/api/settings/wormhole") + assert r.status_code == 200 + body = r.json() + # Only the bare "is Wormhole on?" boolean is exposed publicly. + assert "enabled" in body + assert body["enabled"] is True + # Posture fields the audit flagged must be absent. + assert "transport" not in body + assert "anonymous_mode" not in body + assert "privacy_profile" not in body + assert "socks_proxy" not in body + + def test_authenticated_caller_sees_full_state(self, client): + with ( + patch("main.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("routers.wormhole.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("services.wormhole_settings.read_wormhole_settings", return_value=self._read_settings_payload()), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get( + "/api/settings/wormhole", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + body = r.json() + # All fields visible when authenticated. + assert body["enabled"] is True + assert body["transport"] == "tor_arti" + assert body["anonymous_mode"] is True + assert body["privacy_profile"] == "high" + + +class TestPrivacyProfileRedaction: + """``GET /api/settings/privacy-profile`` must NOT leak the named + profile to unauthenticated callers (the profile name itself + discloses operator intent).""" + + def _payload(self): + return { + "enabled": True, + "transport": "tor_arti", + "anonymous_mode": True, + "privacy_profile": "high", + } + + def test_anonymous_caller_sees_only_wormhole_enabled_bool(self, client): + with ( + patch("main.read_wormhole_settings", return_value=self._payload()), + patch("routers.wormhole.read_wormhole_settings", return_value=self._payload()), + patch("services.wormhole_settings.read_wormhole_settings", return_value=self._payload()), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get("/api/settings/privacy-profile") + assert r.status_code == 200 + body = r.json() + assert "wormhole_enabled" in body + assert body["wormhole_enabled"] is True + # The named profile, transport, and anonymous mode must NOT + # leak to anonymous callers. + assert "profile" not in body or body.get("profile") is None + assert "transport" not in body + assert "anonymous_mode" not in body + + def test_authenticated_caller_sees_named_profile_and_transport(self, client): + with ( + patch("main.read_wormhole_settings", return_value=self._payload()), + patch("routers.wormhole.read_wormhole_settings", return_value=self._payload()), + patch("services.wormhole_settings.read_wormhole_settings", return_value=self._payload()), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get( + "/api/settings/privacy-profile", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + body = r.json() + assert body["profile"] == "high" + assert body["wormhole_enabled"] is True + assert body["transport"] == "tor_arti" + assert body["anonymous_mode"] is True + + +class TestNodeSettingsRedaction: + """``GET /api/settings/node`` must NOT disclose node_mode or + node_enabled to anonymous callers.""" + + def _node_data(self): + return {"some_node_field": "value"} + + def test_anonymous_caller_sees_empty_stub(self, client): + with ( + patch("services.node_settings.read_node_settings", return_value=self._node_data()), + patch("routers.admin._current_node_mode", return_value="participant"), + patch("routers.admin._participant_node_enabled", return_value=True), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get("/api/settings/node") + assert r.status_code == 200 + body = r.json() + # No posture fields. + assert "node_mode" not in body + assert "node_enabled" not in body + assert "some_node_field" not in body + + def test_authenticated_caller_sees_full_node_state(self, client): + with ( + patch("services.node_settings.read_node_settings", return_value=self._node_data()), + patch("routers.admin._current_node_mode", return_value="participant"), + patch("routers.admin._participant_node_enabled", return_value=True), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get( + "/api/settings/node", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + body = r.json() + assert body["node_mode"] == "participant" + assert body["node_enabled"] is True + assert body["some_node_field"] == "value" + + +# --------------------------------------------------------------------------- +# #252: news-feeds auth gate +# --------------------------------------------------------------------------- + + +class TestNewsFeedsAuthGate: + def _fake_feeds(self): + return [ + {"name": "Custom Internal", "url": "https://internal.example/rss", "weight": 5}, + {"name": "Default News", "url": "https://news.example/rss", "weight": 3}, + ] + + def test_anonymous_caller_rejected(self, client): + with ( + patch("services.news_feed_config.get_feeds", return_value=self._fake_feeds()) as get_feeds, + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get("/api/settings/news-feeds") + assert r.status_code == 403 + # Critically: the underlying config read must NOT have been performed + # (else the response body could leak the count via response timing). + assert get_feeds.call_count == 0 + + def test_authenticated_caller_sees_full_feed_inventory(self, client): + with ( + patch("services.news_feed_config.get_feeds", return_value=self._fake_feeds()), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get( + "/api/settings/news-feeds", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + body = r.json() + assert len(body) == 2 + assert body[0]["name"] == "Custom Internal" + assert body[0]["url"] == "https://internal.example/rss" + + +# --------------------------------------------------------------------------- +# #253: timemachine auth gate +# --------------------------------------------------------------------------- + + +class TestTimemachineAuthGate: + def test_anonymous_caller_rejected(self, client): + node_data = {"timemachine_enabled": True} + with ( + patch("services.node_settings.read_node_settings", return_value=node_data), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get("/api/settings/timemachine") + assert r.status_code == 403 + + def test_authenticated_caller_sees_enabled_state(self, client): + node_data = {"timemachine_enabled": True} + with ( + patch("services.node_settings.read_node_settings", return_value=node_data), + patch("auth._current_admin_key", return_value=_ADMIN_KEY), + ): + r = client.get( + "/api/settings/timemachine", + headers={"X-Admin-Key": _ADMIN_KEY}, + ) + assert r.status_code == 200 + body = r.json() + assert body["enabled"] is True + assert "storage_warning" in body