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>
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>
Allow local-operator DM invite import without requiring a full admin session.
Prioritize bundled/bootstrap seed peers and shorten stale seed cooldowns for faster Infonet recovery.
Replace raw DM invite dumps with copyable signed-address controls, contact request handling, and safer sealed-send behavior while the private delivery route connects.
Ship the v0.9.79 runtime refresh with transport lane isolation, Infonet secure-message address management, MeshChat MQTT controls, selected asset trail behavior, telemetry panel refinements, onboarding updates, and desktop/package metadata alignment.
Also ignore local graphify work products so analysis folders do not leak into future commits.
Relay nodes run in store-and-forward mode with no local gate configs,
so gate_manager.can_enter() always returned "Gate does not exist" —
silently rejecting every pushed gate message. This broke cross-node
gate message delivery entirely since no relay ever stored anything.
Relay mode now skips the gate-existence check after signature
verification passes, allowing encrypted gate blobs to flow through.
Phase 1 — Transport layer fix:
- Bake in default MESH_PEER_PUSH_SECRET so peer push, real-time
propagation, and pull-sync all work out of the box instead of
silently no-oping on an empty secret.
- Pass secret through docker-compose.yml for container deployments.
Phase 2 — Per-gate content keys:
- Generate a cryptographically random 32-byte secret per gate on
creation (and backfill existing gates on startup).
- Upgrade HKDF envelope encryption to use per-gate secret as IKM
so knowing a gate name alone no longer decrypts messages.
- 3-tier decryption fallback (phase2 key → legacy name-only →
legacy node-local) preserves backward compatibility.
- Expose gate_secret via list_gates API for authorized members.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Derive gate envelope AES key from gate ID via HKDF so all nodes
sharing a gate can decrypt each other's messages (was node-local)
- Preserve gate_envelope/reply_to in chain payload normalization
- Bump Wormhole modal text from 9-10px to 12-13px
- Add aircraft icon zoom interpolation (0.8→2.0 across zoom 5-12)
- Reduce Mesh Chat panel text sizes for tighter layout
Docker/Linux containers have no DPAPI or native keyring, causing all
wormhole persona/gate/identity endpoints to crash with
SecureStorageError. Detect /.dockerenv and auto-allow raw fallback
so mesh features work out of the box in Docker.
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them
through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation
system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery,
killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption
keys and chain state during updates.
New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers,
CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets,
desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing).
Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami,
@chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const,
@Elhard1, @ttulttul