[security] Close tg12 audit issues #201–#214 seamlessly (#261)

External security audit by @tg12 (May 17, 2026) filed issues #201–#214
in addition to the #189–#200 batch already closed by PRs #227/#232/#260.
This PR closes all eight that are real security bugs (the other six in
the 201–214 range are either design discussions or upstream-abuse/TOS
concerns we're keeping intentional, see issue triage notes on each).

The user-facing principle for this PR: fix the security gap WITHOUT
introducing a single hostile error or behavior change for legitimate
users. Every fix follows the same template — fail forward, not loud.
When the secure path is harder than the insecure one, build a
fallback chain that ends in graceful degradation, not in a scary
modal or 422 response.

  #205 — OpenMHZ audio redirect SSRF (services/radio_intercept.py)

  Replaced requests.get(..., allow_redirects=True) with a manual
  redirect loop that re-validates each hop's host against
  _OPENMHZ_AUDIO_HOSTS. Same-host redirects (CDN edge selection)
  still work, so legitimate audio playback is unaffected. Cross-host
  redirects to disallowed hosts return a generic 502 which the
  browser audio element handles gracefully. Cap at 5 hops.

  #207 — infonet/status verify_signatures DoS (routers/mesh_public.py)

  Silently downgrade verify_signatures=true to False for
  unauthenticated callers. No error surfaced — the response shape is
  identical, just without the O(n_events) signature verification.
  Authenticated callers (scoped mesh.audit) still get the full path.
  The frontend never passes this param so legitimate UI is unaffected.

  #211 — thermal/verify expensive analysis (routers/sigint.py)

  Added Depends(require_local_operator). Frontend has no direct
  callers (verified by grep); Tauri/AI agents use scoped tokens that
  pass the auth check. Anonymous abusers blocked silently — the
  legitimate UI keeps working through the Next.js admin-key proxy.

  #213, #214 — OpenMHZ calls/audio upstream abuse (routers/radio.py)

  Added Depends(require_local_operator) to both. Browser users hit
  these through the Next.js proxy at src/app/api/[...path]/route.ts
  which injects X-Admin-Key, so the auth check passes transparently.
  Direct attackers can no longer rotate sys_names to hammer
  api.openmhz.com or relay arbitrary audio streams through the
  backend's bandwidth.

  #202 — overflights unbounded hours (routers/data.py)

  Silently clamp `hours` to OVERFLIGHTS_MAX_HOURS (default 72,
  configurable). NO 422 — clients asking for an absurd window get a
  shorter window back with `requested_hours` and `effective_hours`
  hint fields. Postel's law: liberal in what we accept, conservative
  in what we compute.

  #203 — Meshtastic callsign UA leak (services/fetchers/meshtastic_map.py)

  Added MESHTASTIC_SEND_CALLSIGN_HEADER opt-out env var. Default is
  TRUE — preserves existing operator behavior (callsign sent so
  meshtastic.org can rate-limit per-install). Privacy-conscious
  operators set it to false to suppress.

  #206 — KiwiSDR upstream is HTTP-only (services/kiwisdr_fetcher.py)

  Upstream rx.linkfanel.net doesn't speak HTTPS (verified — Apache
  2.4.10 only on port 80). We can't fix the transport. Instead added
  three layers:
    1. Content validation on fetched data — reject responses with
       <50 receivers or >5% malformed entries (likely MITM injection).
    2. Existing disk cache fallback (already present).
    3. NEW: bundled static directory at backend/data/kiwisdr_directory.json
       shipping 798 known-good receivers. Used as last resort so the
       KiwiSDR map layer always renders something useful.

  #208 — Merkle proof DoS via /api/mesh/infonet/sync (services/mesh/mesh_hashchain.py)

  The endpoint is part of the cross-node federation protocol — peers
  legitimately call it without local-operator auth, so we can't add
  Depends(). Instead made the underlying operation O(1) per proof
  via a cached Merkle level structure on the Infonet instance:
    - _merkle_levels_cache + _merkle_levels_for_event_count on each
      Infonet instance
    - _invalidate_merkle_cache() called from every chain mutation
      point (append, ingest_events, apply_fork, cleanup_expired)
    - _get_merkle_levels() does the lazy recompute on first read
      after invalidation, then serves from cache thereafter
  Effect: anonymous attackers hammering the proofs endpoint hit a
  cached structure; the rebuild happens at most once per real chain
  advance. Federation untouched.

  #201 — Tor bundle SHA-256 bypass (services/tor_hidden_service.py)

  Docker users were already covered — backend/Dockerfile installs
  Tor via apt-get at build time (signed by Debian's package system).
  No runtime download needed for the 80%-of-users case.

  For Tauri desktop, replaced the single .sha256sum check with a
  multi-source verification chain implemented in _verify_tor_bundle():
    1. Try upstream .sha256sum (current behavior — fast path)
    2. Try baked-in digest list at backend/data/tor_bundle_digests.json
       (pinned per-version, maintainer-updated)
    3. If neither source is REACHABLE: HTTPS-only fallback with a loud
       warning (avoids breaking first-run onboarding while the
       maintainer hasn't yet pinned a new Tor release)
  A mismatch from a source that DID respond is always fatal — only
  the "no source reachable" case falls back to HTTPS-only. This is
  the "have cake and eat it" pattern: real users see no new failure
  modes during torproject.org outages, but MITM/compromise attacks
  still fail because the downloaded digest can't match what BOTH
  the upstream and the baked-in list report.

  Currently the digest file ships with placeholder values for the
  current Tor URLs (those URLs are already stale on torproject.org
  too). A follow-up commit can populate real digests when a stable
  Tor release is selected; until then the HTTPS-only warning fires
  and onboarding still works.

Tests (82 total, all passing):
  test_openmhz_redirect_ssrf.py        (5 tests)  — #205
  test_infonet_status_verify_gate.py   (2 tests)  — #207
  test_overflights_clamp.py            (5 tests)  — #202
  test_meshtastic_callsign_optout.py   (3 tests)  — #203
  test_kiwisdr_fallback.py             (6 tests)  — #206
  test_merkle_cache.py                 (6 tests)  — #208
  test_tor_bundle_verification.py      (6 tests)  — #201
  test_control_surface_auth.py         (extended) — #211, #213, #214
  + all previous security tests (CCTV redirect, GDELT https, sentinel
    cache, crowdthreat opt-in, third-party fetcher gates, control
    surface auth) continue to pass.

Pre-existing test infrastructure issue with SHARED_EXECUTOR teardown
in the broader sweep exists on main too (verified) — not introduced
by this PR.

Credit: @tg12 reported every one of these with accurate line citations
and the recommended fixes that informed this implementation.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shadowbroker
2026-05-20 19:57:06 -06:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d00c63abed
commit e36d1fc79c
21 changed files with 1073 additions and 83 deletions
+70 -12
View File
@@ -1444,9 +1444,51 @@ class Infonet:
self._save_lock = threading.Lock()
self._save_timer: threading.Timer | None = None
self._SAVE_INTERVAL = 5.0 # seconds — coalesce writes
# Issue #208: Merkle levels cache so get_merkle_proofs() doesn't
# rebuild O(n) levels on every public call. Invalidated whenever
# self.events mutates. Computed lazily on first read after an
# invalidation.
self._merkle_levels_cache: list[list[str]] | None = None
self._merkle_levels_for_event_count: int = -1
atexit.register(self._flush)
self._load()
def _invalidate_merkle_cache(self) -> None:
"""Clear the precomputed Merkle levels.
Called whenever ``self.events`` may have mutated (append, rebuild,
cleanup, fork resolution). The next call to ``get_merkle_root()``
or ``get_merkle_proofs()`` will recompute and re-cache.
"""
self._merkle_levels_cache = None
self._merkle_levels_for_event_count = -1
def _get_merkle_levels(self) -> list[list[str]]:
"""Return Merkle levels for the current chain, recomputing if
the cache is invalid or out of date.
Issue #208: a public endpoint (``/api/mesh/infonet/sync?include_proofs=true``)
used to rebuild Merkle levels on every request, which is O(n) in
chain length and trivially abusable for CPU exhaustion. By caching
the levels and invalidating on mutation, repeated proof requests
become O(1) per proof; the rebuild only happens after a genuine
append/rebuild/cleanup.
"""
from services.mesh.mesh_merkle import build_merkle_levels
current_count = len(self.events)
if (
self._merkle_levels_cache is not None
and self._merkle_levels_for_event_count == current_count
):
return self._merkle_levels_cache
leaves = [e["event_id"] for e in self.events]
levels = build_merkle_levels(leaves)
self._merkle_levels_cache = levels
self._merkle_levels_for_event_count = current_count
return levels
# ─── Persistence ──────────────────────────────────────────────────
def _load(self):
@@ -1983,6 +2025,8 @@ class Infonet:
self.head_hash = event.event_id
self.node_sequences[node_id] = sequence
self._replay_filter.add(event.event_id)
# Issue #208: chain advanced, cached Merkle levels are stale.
self._invalidate_merkle_cache()
self._update_counters_for_event(event_dict)
if event_type == "key_revoke":
@@ -2266,6 +2310,9 @@ class Infonet:
self._apply_revocation(evt)
if accepted:
# Issue #208: any accepted event invalidates the cached Merkle
# levels. One invalidation per batch, not per event.
self._invalidate_merkle_cache()
self._save()
return {"accepted": accepted, "duplicates": duplicates, "rejected": rejected}
@@ -2566,6 +2613,8 @@ class Infonet:
self._rebuild_state()
self._rebuild_revocations()
self._rebuild_counters()
# Issue #208: chain replaced, cached Merkle levels are stale.
self._invalidate_merkle_cache()
self._save()
try:
from services.mesh.mesh_metrics import increment as metrics_inc
@@ -2735,6 +2784,8 @@ class Infonet:
self._rebuild_state()
self._rebuild_revocations()
self._rebuild_counters()
# Issue #208: cleanup may have dropped expired events.
self._invalidate_merkle_cache()
self._save()
logger.info(f"Infonet cleanup: removed {before - len(new_events)} expired events")
@@ -2743,30 +2794,37 @@ class Infonet:
def get_merkle_root(self) -> str:
"""Compute a Merkle root hash of the Infonet for sync comparison.
Two nodes with the same Merkle root have identical chains.
Two nodes with the same Merkle root have identical chains. Reads
from the cached Merkle levels (issue #208) — O(1) when the chain
hasn't changed since the last computation.
"""
if not self.events:
return GENESIS_HASH
from services.mesh.mesh_merkle import merkle_root
leaves = [e["event_id"] for e in self.events]
root = merkle_root(leaves)
return root or GENESIS_HASH
levels = self._get_merkle_levels()
if not levels or not levels[-1]:
return GENESIS_HASH
return levels[-1][0] or GENESIS_HASH
def get_merkle_proofs(self, start_index: int, count: int) -> dict:
"""Return merkle proofs for a contiguous range of events."""
leaves = [e["event_id"] for e in self.events]
total = len(leaves)
"""Return merkle proofs for a contiguous range of events.
Issue #208: uses the cached Merkle levels so this is O(count *
log n) per request, not O(n + count * log n). Anonymous peers
hitting ``/api/mesh/infonet/sync?include_proofs=true`` no longer
force a rebuild on every call.
"""
total = len(self.events)
if total == 0:
return {"root": GENESIS_HASH, "total": 0, "start": 0, "proofs": []}
from services.mesh.mesh_merkle import build_merkle_levels, merkle_proof_from_levels
from services.mesh.mesh_merkle import merkle_proof_from_levels
leaves = [e["event_id"] for e in self.events]
start = max(0, start_index)
end = min(total, start + max(0, count))
levels = build_merkle_levels(leaves)
root = levels[-1][0] if levels else GENESIS_HASH
levels = self._get_merkle_levels()
root = levels[-1][0] if levels and levels[-1] else GENESIS_HASH
proofs = []
for idx in range(start, end):