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).
External security audit by @tg12 (May 17, 2026) filed 11 issues against
the backend. PR #227 (May 18, AI-generated) closed seven of them by
adding require_local_operator to control-plane endpoints. Four remained
live; this PR closes the rest.
#192 — CCTV proxy followed redirects without re-validating host
Issue: /api/cctv/media validated only the caller-supplied URL host
before passing it to requests.get(..., allow_redirects=True). A 302
to http://127.0.0.1 or any internal/disallowed host was silently
followed, turning the proxy into an open-redirect-to-SSRF chain.
Fix in routers/cctv.py: replace the single allow_redirects=True call
with a manual follow loop. Each hop's Location is parsed, the host is
rerun through _cctv_host_allowed(), and non-HTTP schemes (file://,
ftp://, etc.) are rejected. Cap chain length at 5 hops.
Test: backend/tests/test_cctv_redirect_ssrf.py covers
- redirect to disallowed host -> 502
- redirect to localhost -> 502
- redirect to another allowed host -> 200
- redirect chain length cap
- non-HTTP scheme rejected
#198 — Gate introspection GETs were unauthenticated
Issue: /api/wormhole/gate/{gate_id}/{identity,personas,key} were
callable with no auth dependency. Any caller that could reach the
backend could dump the operator's active persona, persona inventory,
and key status for any gate_id they knew. The wiki's privacy threat
model explicitly markets gate personas as rotating, unlinkable
pseudonyms — this leak defeated that property.
Fix in routers/wormhole.py: add
dependencies=[Depends(require_local_operator)] to all three routes.
Test: backend/tests/test_control_surface_auth.py extended with
three new parameterized cases (lines 75-77).
#199 — GDELT military incident ingestion used plaintext HTTP
Issue: backend/services/geopolitics.py fetched
http://data.gdeltproject.org/gdeltv2/lastupdate.txt and ~48 export
archive URLs over plaintext HTTP. Passive observers could identify
Shadowbroker nodes from the fetch pattern. Active MITM could inject
doctored military incident records into the global map.
Fix in services/geopolitics.py: rewrite the lastupdate.txt fetch and
the export download URL constructor to use https://. GDELT's
data.gdeltproject.org serves the same content over HTTPS.
Test: backend/tests/test_gdelt_https.py asserts no plaintext HTTP
URLs to data.gdeltproject.org remain in code (comments excluded) and
that the HTTPS URLs we expect are present.
#200 — Sentinel token cache lookup used client_id only
Issue: routers/tools.py kept a process-global cache of Copernicus
bearer tokens. The lookup compared
_sh_token_cache["client_id"] == client_id. A caller who knew a valid
client_id but supplied any wrong client_secret hit the cache and
reused the legitimate caller's bearer token — burning their quota
and accessing imagery on their account.
Fix in routers/tools.py: replace the client_id field with
credential_fp, an HMAC-SHA256 over (client_id, client_secret) under
a per-process random key (_SH_TOKEN_CACHE_HMAC_KEY = os.urandom(32),
regenerated at startup). A caller who doesn't know the secret cannot
compute a matching fingerprint, so they miss the cache and hit the
real Copernicus token endpoint — which will reject their wrong
secret with a 401.
Test: backend/tests/test_sentinel_token_cache.py covers
- same client_id + different secrets => different fingerprints
- same credentials => same fingerprint (cache still works)
- different client_ids + same secret => different fingerprints
- cache no longer stores raw client_id (catches regression)
- attacker with wrong secret cannot reuse victim's token
Validation
pytest backend/tests/test_control_surface_auth.py
backend/tests/test_cctv_redirect_ssrf.py
backend/tests/test_gdelt_https.py
backend/tests/test_sentinel_token_cache.py
-> 37 passed
Credit: @tg12 reported all four of these in their May 17 audit with
correct line-number citations and accurate remediation recommendations.
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.
Add Tor/onion runtime wiring and faster Infonet node status refresh.
Keep node bootstrap state clearer across Docker and local runtimes.
Use selected aircraft trail history for cumulative tracked-aircraft emissions.