Commit Graph

331 Commits

Author SHA1 Message Date
Shadowbroker 9ef6213284 Fix #250: bind Docker bridge local-operator trust to frontend hostname (#278)
Tightens the bridge-trust check so a connection on the Docker bridge
is only granted local-operator status when its source IP matches a
configured frontend container hostname (default: `frontend` + the
shipped `container_name` `shadowbroker-frontend`). Previously, when
`SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1` was set, ANY IP
in the 172.16.0.0/12 range was granted local-operator privileges —
on a shared Docker host that included any unrelated container on the
same bridge.

Operators with renamed services can list new hostnames via the new
`SHADOWBROKER_TRUSTED_FRONTEND_HOSTS` env var (comma-separated). DNS
resolution is cached for 30s; if Docker DNS can't resolve any of the
configured names we fail closed and refuse the bridge entirely.

Single-user installs see no behavior change — the default-named
frontend container still resolves and is still trusted.

Credit: tg12 (external security audit)
2026-05-21 02:06:11 -06:00
Shadowbroker fb11e0881f Fix #251: refuse symlink/hardlink members during Tor bundle extraction (#277)
External audit (@tg12) flagged that the Tor Expert Bundle extractor
checked tarinfo.name against path traversal but never inspected
tarinfo.linkname for symlink or hardlink members. Python 3.11's
tarfile.extractall() honors symlinks, so a malicious archive could
ship a member like::

    name     = "innocent.txt"          (passes the path-traversal check)
    type     = SYMTYPE
    linkname = "C:\Windows\System32\config\system"

After extraction, subsequent reads of innocent.txt dereference to that
arbitrary filesystem location; subsequent writes corrupt it. On
Windows (where Tor Expert Bundle extraction actually runs), this is
a host-compromise path of essentially the same severity as the
supply-chain RCE in #231 — gated only by the integrity check we just
hardened in PR #261/#265.

Python 3.12+ added tarfile.extract / extractall filter='data' as a
built-in mitigation; we're on Python 3.11 in production, so we
implement the same idea manually.

Fix in backend/services/tor_hidden_service.py:

  Extract the existing path-traversal-only check into a new
  _extract_tor_bundle_safely() helper that:

  1. Refuses any member with member.issym() or member.islnk() True.
     Tor bundles never legitimately contain symlinks or hardlinks
     so this is non-disruptive. Logs the linkname so an operator
     can see what the malicious archive was trying to alias.
  2. Refuses any member that isn't isfile() or isdir() — no FIFOs,
     no character or block devices, no contiguous-file-type entries.
     None of those belong in a Tor Expert Bundle and accepting them
     is a class of bug we don't need to debug later.
  3. Preserves the original path-traversal guard (member.name must
     resolve under install_dir).
  4. Catches tarfile.TarError so a corrupt archive returns False
     gracefully instead of bubbling out an exception.

Tests: backend/tests/test_tor_bundle_symlink_filter.py (8 tests)
  - Clean archive with only regular files extracts successfully
  - Symlink member is rejected (the core regression)
  - Hardlink member is rejected
  - Symlink with relative target inside install_dir is still rejected
    (we don't allow symlinks at all, not just absolute-target ones)
  - FIFO/device-style member is rejected
  - Path-traversal guard still works under the new shape
  - Malformed/non-tar file is rejected gracefully (no crash)
  - Failure on one member rejects the whole bundle (no half-extract)

Validation:
  pytest backend/tests/test_tor_bundle_symlink_filter.py
         backend/tests/test_tor_bundle_verification.py
  -> 14 passed

UX impact: zero for legitimate Tor releases. Operators installing
a real Tor Expert Bundle continue to see "Tor installed at:" exactly
as before. Only malicious archives are refused, with a clear log
message identifying the rejected linkname.

Credit: @tg12 — the original report was specific enough that the
fix design was immediate.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:41:13 -06:00
Shadowbroker 7f96151e56 Fix #231: multi-source SHA-256 verification for the self-updater (#265)
External audit (@tg12, May 18) found that backend/services/updater.py
silently skipped all SHA-256 integrity verification whenever the
MESH_UPDATE_SHA256 env var was unset — which is the default. Nothing
in any install doc tells operators to set it, so practically every
deployment was running the auto-updater with zero integrity check.
That made GitHub release pipeline compromise a single-step path to
arbitrary code execution on every node that auto-updates.

Investigation surfaced a deeper bug too: the updater downloads
zipball_url (GitHub's auto-generated source archive) but the
maintainer's release process publishes SHA256SUMS.txt for a separate
named asset (ShadowBroker_v*.zip). So even if MESH_UPDATE_SHA256
WERE set, operators had no published digest to compare against — the
file they were downloading wasn't the file the maintainer had signed.

This PR fixes both issues with the same multi-source verification
chain we shipped for the Tor bundle in PR #261:

  backend/services/updater.py
    _download_release() now prefers a maintainer-signed release asset
    matching ShadowBroker_v*.zip over zipball_url. Captures the
    SHA256SUMS.txt asset URL when present.

    _validate_zip_hash() rewritten as a four-source chain:
      1. MESH_UPDATE_SHA256 env var (operator override, preserved)
      2. SHA256SUMS.txt asset published with the release (primary —
         the maintainer's release process already publishes this)
      3. Baked-in backend/data/release_digests.json (second line of
         defense for releases that lack the SHA256SUMS asset, or when
         the asset can't be fetched at update time)
      4. HTTPS-only fallback with a loud warning (preserves the auto-
         update flow during transient outages)

    Mismatch from any source that DID respond is fatal — the update
    is refused and the existing install keeps running. Only the
    "no source reachable at all" case falls back to HTTPS-only.

    _fetch_sha256sums() new — fetches and parses a standard
    SHA256SUMS.txt asset. Handles both "<digest>  <name>" and binary-
    marker "<digest> *<name>" formats. Tolerant to comments, blank
    lines, and malformed entries.

  backend/data/release_digests.json (new)
    Baked-in digest list keyed by release tag. Seeded with the v0.9.79
    entries copied from the published SHA256SUMS.txt:
      ShadowBroker_v0.9.79.zip      = f6877c1d6661...
      ShadowBroker_0.9.79_x64-setup.exe = f7b676ada45c...
      ShadowBroker_0.9.79_x64_en-US.msi = e0713c3cdda1...
    Whitelisted in .gitignore alongside the other static reference
    data files (kiwisdr_directory.json, tor_bundle_digests.json,
    aisstream_spki_pins.json).

  backend/tests/test_update_integrity_chain.py (new, 16 tests)
    - Each source matches → success, identifies which source verified
    - Each source mismatches → RuntimeError "mismatch"
    - No source reachable → https-only fallback with loud warning
    - Env override beats all other sources (preserved precedence)
    - SHA256SUMS.txt parser handles standard, binary-marker, comments,
      and network-failure cases

Validation:
  pytest backend/tests/test_update_integrity_chain.py → 16 passed
  pytest (all 15 security test files together) → 105 passed

UX impact: zero. Normal auto-update flow is unchanged for legitimate
releases (path 2 catches everything because the release publishes
SHA256SUMS.txt). Transient network failures during update gracefully
fall through to path 3 then path 4 — no operator intervention needed.
The only user-visible behavior change is in the compromised-release
case, where the update is now refused instead of silently applied.

Credit: @tg12 for the original bug report and the specific call-out
that MESH_UPDATE_SHA256 was unreachable by default operators.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:31:20 -06:00
Shadowbroker d0299fc0a0 test(ci): raise vitest testTimeout to 15s to stop CI-load flakes (#266)
Vitest's default per-test timeout is 5s. That's plenty for tests that
exercise pure functions or even simple JSX, but the heavier React
component trees we render under jsdom — MessagesView, GateView,
Wormhole contact flows — consistently measure 6-10s on GitHub Actions'
shared Node workers under load.

Concrete flake history that drove this bump (none were real product
bugs — all were CI load racing the 5s ceiling on findByText /
waitFor against React reconciliation):

  PR #226 messagesViewFirstContact > removes approved contact
  PR #237 (same)
  PR #261 (same)
  PR #262 (same) ← worst: fired on post-merge Docker Publish run,
                   prevented the AIS SPKI security fix's image from
                   being published to GHCR until PR #263 cumulatively
                   re-published it. Real security-fix-shipping risk.
  PR #264 fixed messagesViewFirstContact specifically with waitFor
  PR #265 messagesViewFirstContact > legacy handle-only addresses
                  AND gateCompatDecryptUx > browser-local gate runtime
                  AND failed on the rerun too — confirming the flake
                  class is broader than the one test we deflaked.

The deflake in PR #264 was too surgical — it addressed one specific
test out of a class of similarly-flaky CI-load-sensitive sites. This
PR addresses the root cause at the config layer instead of playing
whack-a-mole.

Why 15s specifically: 3x the default. Headroom for routine CI slowness
without masking real "test never settles" bugs (those would still
time out, just three rounds later). Individual tests can still pin
their own tighter timeout via the third arg to `it()`.

Also bumps hookTimeout to 15s — beforeEach/afterEach setup for the
same heavier component tests has the same CI-load sensitivity.

User-facing impact: zero. This is dev pipeline infrastructure. End
users never see test timeouts. The cost is theoretical: a buggy test
that genuinely never resolves now takes 15s to declare failure
instead of 5s. In practice that's negligible because the suite runs
once per CI invocation and tests don't usually deadlock.

Validation:
  Local full vitest run → 707 passed, 72 files, 10.36s wall clock
  (same speed as before — we only changed how long we WAIT for slow
   tests, not how fast tests actually run)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:26:34 -06:00
Shadowbroker 87ba70acd6 test: deflake messagesViewFirstContact remove-contact test (#264)
This test asserts that clicking "Remove" on a contact:
  1. Surfaces a toast "Removed contact: <name>."
  2. Drops the contact from the visible list

The Remove handler in MessagesView dispatches a tight cluster of React
state updates in one event tick:
  removeContact(peerId)
  locallySavedContactIdsRef.current.delete(peerId)
  setContacts(...)
  setComposeError('')
  setComposeStatus(`Removed contact: ${displayNameForPeer(...)}.`)

Locally those updates settle in <100ms and the toast appears under any
findByText default. Under GitHub Actions runner load — especially the
shared Node.js workers on the "CI Gate / Frontend Tests & Build" step
— the reconcile-and-paint cycle has been measured at ~1.4s, which
exceeds the 1s default findByText timeout.

This is a load-sensitive timing flake, not a real bug — the toast
always renders eventually because the state update chain is purely
synchronous and the displayed text comes from the closure's pre-update
contacts (so the "Remove Me" name is always available when the toast
finally renders).

Historical flake hits in CI on this exact assertion:
  PR #226   (zh-CN i18n landing, exposed by i18n parse error)
  PR #237   (GitLab mirror parity)
  PR #261   (post-#227 audit gap closures)
  PR #262   (AIS SPKI pinning — failed post-merge Docker Publish,
             skipping image publication for commit 729ea78)

The last one is the worst — a post-merge flake that blocked the
Docker image for an actual security fix from being published. The
subsequent merge of #263 cumulatively re-published the image, but
that's by accident, not by design.

Fix: replace the 1-second findByText with waitFor + 5s timeout +
50ms polling. The 5s ceiling still surfaces a real "toast never
renders" regression with a clear error; it just doesn't get racy
under CI load anymore.

Validation:
  Local sequential 10x run of just this test → 10 passed, 0 failed
  Full vitest suite → 707 passed, 72 files

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:13:40 -06:00
Shadowbroker bcc2d036b3 [security] Close tg12 auth-bypass chain #249, #254, #255 (#263)
External audit by @tg12 found three coupled vulnerabilities in the
Next.js admin-auth surface that together let any webpage the operator
visits trigger arbitrary privileged backend calls:

  #249/#254 — Cross-origin webpages can have process.env.ADMIN_KEY
              injected into their forwarded backend requests just by
              issuing fetch('http://localhost:3000/api/wormhole/...')
              from a browser tab the operator has open. Full
              identity-takeover CSRF.

  #255      — When ADMIN_KEY is unset on the server (the default in
              .env.example), the admin session route fell through to
              GET /api/settings/privacy-profile to "verify" the user-
              supplied key. That endpoint is public; it always returns
              200 for any X-Admin-Key value. So arbitrary attacker
              keys minted full admin session cookies on default
              installs.

Both fixes preserve every legitimate UX path. Origin-header gating is
transparent to browser tabs on the dashboard's own host, transparent
to Tauri/native shells (no Origin), and transparent to server-to-
server callers (no Origin). Only cross-origin browser fetches with a
foreign Origin lose the injection.

  frontend/src/app/api/[...path]/route.ts
    Adds isSameOriginOrNonBrowser() — checks the Origin header against
    the request's own Host. Allow if no Origin (native/server-to-
    server), allow if Origin host == Host host (same-origin), reject
    otherwise. The admin-key injection now requires EITHER a valid
    session cookie (auth) OR same-origin-or-non-browser (CSRF guard).

  frontend/src/app/api/admin/session/route.ts
    verifyAdminKey() simplified to local-only string comparison. When
    ADMIN_KEY is configured, the supplied key must match exactly.
    When ADMIN_KEY is unset, minting is refused entirely with a clear
    message pointing the operator at the backend's auto-trust-loopback
    behavior (SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1, the
    Docker default — local users keep working without a session).

    The previous round-trip to /api/settings/privacy-profile was both
    the source of the bug AND useless on its own merits (the endpoint
    is public). Removing it makes the validation honest about what
    it's checking.

Tests:
  frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts (new, 12)
    Cross-origin fetch to sensitive route → no admin-key injection
    Cross-origin POST to sensitive route → no admin-key injection
    Same-origin fetch → admin-key injection works
    No-Origin (server-to-server / native) → admin-key injection works
    Valid session cookie on cross-origin → cookie auth wins
    Malformed Origin → conservative reject
    Non-sensitive routes unaffected
    Mint with ADMIN_KEY unset → refused (no fetch happens)
    Empty key → 400
    Mint with matching ADMIN_KEY → success
    Mint with mismatched key → 403
    Mint never round-trips to the backend (local-only validation)

  frontend/src/__tests__/desktop/adminSessionBoundary.test.ts (updated)
    Three tests updated to reflect the new local-only validation
    contract. The previous tests asserted fetchMock.toHaveBeenCalled
    which validated the now-removed (and broken) backend round-trip.

Full frontend suite: 707 passed, 72 files. No regressions.

Credit: @tg12 for the report. The cross-origin CSRF angle was
non-obvious — they specifically called out that the proxy's
admin-key injection was an open door for any page running in the
operator's browser, which is exactly the right framing.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:59:40 -06:00
Shadowbroker 729ea78cb2 Fix #258: AIS proxy SPKI pinning fallback for expired upstream cert (#262)
External report from @jmleclercq: AISStream's Let's Encrypt cert
expired on 2026-05-20 (verified — their renewal pipeline failed), so
the AIS WebSocket connection dies with CERT_HAS_EXPIRED and the
maritime layer empties out. The reporter worked around it locally by
passing { rejectUnauthorized: false } to the WebSocket constructor and
asked whether we should add an env var for that.

That fix is the wrong fix. Disabling TLS validation entirely lets any
network attacker MITM the WebSocket and inject fake ship positions —
same class as the GDELT plaintext-HTTP MITM we just closed in #199.
Adding an env var for it would be an attractive nuisance: operators
set it once during a bad cert week and then forget, leaving themselves
open to MITM forever.

Right fix: SPKI pinning, same pattern as the Tor bundle digest pinning
in #201. The insight is that Let's Encrypt renewals keep the SAME
public key by default, so the SPKI hash survives normal cert rotation.
We can relax the date check while keeping the identity check.

Mechanics:

  backend/data/aisstream_spki_pins.json (new)
    Pinned SHA-256 hashes of the DER-encoded SPKI bytes for
    stream.aisstream.io. Captured 2026-05-20 from the live cert.
    Format is base64(sha256(pubkey_der)), matching the canonical
    openssl pipeline. Whitelisted in .gitignore alongside the other
    static reference data files (KiwiSDR directory, Tor bundle
    digests).

  backend/ais_proxy.js
    Path A (99.9% of the time): normal TLS validation. Untouched.
    Path B (on CERT_HAS_EXPIRED only): re-handshake with
    rejectUnauthorized=false JUST to read the leaf cert, compute its
    SPKI hash, compare against the pinned list. If match → upstream
    is still the genuine AISStream → re-open the WebSocket with
    rejectUnauthorized=false and log DEGRADED MODE. If no match →
    refuse the connection, log loudly: this would be a real MITM.

    Pin file is looked up in three locations so the same code works
    in the Docker backend, the Tauri desktop runtime, and any
    operator-relocated layout (SHADOWBROKER_AIS_PINS env var).
    Embedded fallback list inside the JS so portable installs that
    haven't shipped the JSON still work.

  backend/services/ais_stream.py
    Captures the proxy's status markers from stdout
    ({"__ais_proxy_status": {"degraded_tls": true}}) into a module-
    level snapshot. Exposes ais_proxy_status() for the health
    endpoint. Doesn't touch the data plane — degraded mode keeps
    receiving vessel data, just with weaker MITM protection.

  backend/routers/health.py + backend/services/schemas.py
    /api/health now includes an ais_proxy block with degraded_tls.
    Top-level status escalates ok -> degraded when AIS is in
    degraded TLS mode (but won't downgrade a worse SLO status).
    Operators get a visible signal that they're in degraded mode
    without needing to grep logs.

Tests: backend/tests/test_ais_spki_pinning.py (7 tests)
  - Pin file structure validation (JSON, host entry, base64 SHA-256)
  - ais_proxy_status() snapshot semantics (starts empty, defensive copy)
  - /api/health surfaces ais_proxy.degraded_tls when set
  - /api/health returns empty ais_proxy when proxy hasn't reported

Node.js syntax check passes (node --check) on both backend/ais_proxy.js
and the Tauri runtime mirror.

When AISStream renews their cert (likely within hours-to-days), the
normal-TLS path succeeds on next reconnect and degraded_tls clears
automatically. No operator action needed. If they instead rotate their
server key, the SPKI check will fail and we'll need to add the new
hash to backend/data/aisstream_spki_pins.json before removing the old
one.

Credit: @jmleclercq for the clear report and the careful workaround
verification (Node version, ws version, manual probe).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 20:31:56 -06:00
Romain Baraud 459178f283 feat(i18n): add French translation (#257)
Co-authored-by: Romain BARAUD <romain.baraud@gmail.com>
2026-05-20 20:08:35 -06:00
@aaronjmars 8e27658157 fix(security): use defusedxml for untrusted XML parsing (#259)
Detected by Aeon + Semgrep (5x use-defused-xml ERROR).
Severity: medium
CWE-776 (billion laughs) / CWE-611 (XML external entity)

Five XML parse sites pass response bodies into the Python stdlib
xml.etree.ElementTree without protection against entity expansion
attacks. Python's ElementTree still permits internal entity references
by default (per the docs vulnerabilities table), so a malicious or
compromised upstream can ship a "billion laughs"-style payload that
expands to gigabytes in memory.

The user-controllable site is sb_monitor._parse_rss: the OpenClaw skill
exposes add_custom_feed(name, url, ...) to the agent, then
poll_custom_feeds fetches feed.url and passes the body to
xml.etree.ElementTree.fromstring with no host allowlist or
entity-bomb defence. The other four sites (psk_reporter_fetcher,
aircraft_database, cctv_pipeline x2) parse XML from hard-coded
upstreams (pskreporter.info, s3.opensky-network.org,
datos.madrid.es); defence-in-depth for upstream-compromise/MITM.

Switch all five call sites to defusedxml.ElementTree. Same
fromstring/find/findall/iter/findtext API, but rejects entity
references by default (raises defusedxml.EntitiesForbidden).
Confirmed locally that a 4-deep billion-laughs payload that
expands to 3000 chars under stdlib ET is rejected by defusedxml.

Added defusedxml>=0.7.1 to backend/pyproject.toml dependencies.

Co-authored-by: aeonframework <aeon-bot@aaronjmars.com>
2026-05-20 20:01:25 -06:00
Shadowbroker e36d1fc79c [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>
2026-05-20 19:57:06 -06:00
Shadowbroker d00c63abed [security] Close tg12 audit gaps #192, #198, #199, #200 (#260)
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>
2026-05-20 14:45:11 -06:00
Shadowbroker e3297e9bc0 i18n: add language toggle, neutrality policy, and codeowner gate (#238)
PR #226 landed the i18n infrastructure and Chinese (zh-CN) translations.
This follow-up adds the safeguards that make accepting community
translations sustainable without exposing the project to subtle
state-aligned framing in future translation PRs.

Changes:

  frontend/src/i18n/index.tsx (renamed from .ts)
    - Add LOCALES registry: a single source of truth for available
      languages and their NATIVE display names ("English", "中文 (简体)").
      Adding a new language is now a one-entry change here plus a
      JSON file.
    - Add isLocale() guard so an unknown value in localStorage falls
      through to navigator.language detection instead of corrupting
      state.
    - File renamed to .tsx because it contains JSX. Next.js tolerated
      JSX in .ts but Vite/Oxc (used by vitest) does not.

  frontend/src/components/SettingsPanel.tsx
    Add a UI language picker to the Settings header — a small <select>
    populated from LOCALES. Users no longer need the dev console to
    switch languages. Locale change remains 100% client-side
    (localStorage), no network call, no telemetry.

  CONTRIBUTING.md (new)
    Documents the translation-neutrality requirement that applies
    symmetrically to all source countries:
      - Translations must be technically faithful to the English source.
      - Substitutions aligned with state propaganda from ANY country
        (PRC, Russia, US, EU, etc.) will be rejected.
      - The test is: "would a translator working strictly from the
        English source produce this rendering?"
    Also explains how translation PRs are reviewed and how to add
    a new language.

  .github/CODEOWNERS (new)
    Auto-requests maintainer review on:
      - /frontend/src/i18n/  (translation safety)
      - /backend/auth.py, /backend/routers/wormhole.py,
        /backend/services/mesh/, /backend/services/fetchers/
        (the same paths recent security audits flagged as sensitive)
      - /.github/workflows/, /.gitlab-ci.yml, /docker-compose*.yml,
        /helm/  (build/deploy)
      - /CONTRIBUTING.md, /.github/CODEOWNERS  (policy itself)

  frontend/src/__tests__/i18n/i18nProvider.test.tsx (new, 8 tests)
    Locks in the i18n contract:
      - LOCALES has both en and zh-CN with non-empty native labels
      - Default English when navigator is English
      - Auto-detect zh-CN when navigator language starts with "zh"
      - localStorage preference overrides auto-detect
      - setLocale persists to localStorage
      - Unknown stored locale falls back to auto-detect
      - Renders a real zh-CN translation (catches large-scale
        translation removal in future PRs)
      - Missing key falls back to the key itself

  Note: i18n/index.tsx, the language toggle UI, the translation
  policy, and the test suite together form a defense-in-depth setup.
  The structural safety guarantee (no network calls, static JSON
  bundled at build) is intact; this PR makes the social contract
  around translations explicit and enforceable via branch
  protection on CODEOWNERS-marked paths.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:48:24 -06:00
wsdone 9ae0b189ba feat: add Chinese (zh-CN) localization with i18n infrastructure (#226)
Introduce a lightweight i18n system with auto-detection of browser
language and localStorage persistence. Add complete Chinese translations
for all major UI sections: navigation, controls, update dialogs, node
activation, terminal launcher, data layers, settings, filters, and more.

Technical terms (Wormhole, Infonet, Mesh, Shodan, SAR, etc.) are
intentionally kept in English. Falls back to English when Chinese
translation is not found.

Co-authored-by: wangsudong <wangsudong@kylinos.cn>
2026-05-19 01:33:07 -06:00
Shadowbroker dd7706f17f Add GitLab mirror parity: CI + image registry + install overrides (#237)
Brings the GitLab side to full parity with GitHub so users who prefer
gitlab.com get the same source, the same images, and the same install
paths. Today, GitLab users can clone the source but the Helm chart and
docker-compose paths only worked against GHCR.

What's new:

  .gitlab-ci.yml
    Multi-arch (amd64 + arm64) Docker builds on every push to main,
    pushed to the project's GitLab Container Registry as:
      registry.gitlab.com/bigbodycobain/shadowbroker/backend:latest
      registry.gitlab.com/bigbodycobain/shadowbroker/frontend:latest
    Plus a :$CI_COMMIT_SHORT_SHA tag for traceability. Uses
    $CI_JOB_TOKEN — no credentials need to be configured.

    Also adds a 'mirror-to-github' job that pushes main back to GitHub
    via fast-forward-only `git push`. Skipped silently if the
    GITHUB_MIRROR_TOKEN CI/CD variable isn't set. Setup instructions
    are in the file header.

  docker-compose.gitlab.yml
    Override file that swaps the backend/frontend image: lines to the
    GitLab registry. Used as:
      docker compose -f docker-compose.yml -f docker-compose.gitlab.yml up -d
    Verified with `docker compose config` — merges cleanly and emits
    registry.gitlab.com/... image references.

  helm/chart/values-gitlab.yaml
    Helm values override that points the chart at the GitLab registry.
    Used alongside the default values.yaml:
      helm install ... -f helm/chart/values.yaml -f helm/chart/values-gitlab.yaml

  README.md
    Documents both install paths (GitHub default, GitLab override) for
    both docker compose and Helm. Notes that both registries publish
    identical images (same source, same CI matrix).

No credentials needed for the GitLab→GitLab side. The optional reverse
mirror requires a GitHub PAT (public_repo scope) added as the GitLab
CI/CD variable GITHUB_MIRROR_TOKEN — instructions in the .gitlab-ci.yml
header.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:14:30 -06:00
Shadowbroker 30f0360ef8 Helm chart: switch image registry from GitLab to GHCR (#236)
The chart referenced registry.gitlab.com/bigbodycobain/shadowbroker/{backend,frontend}:latest
as the primary image source, but two things made that path effectively
broken for new K8s installs:

  1. No .gitlab-ci.yml has ever existed in this repo, so the GitLab
     registry was never populated by automated builds. Any images there
     would be stale or manually pushed.
  2. The GitLab registry returns HTTP 401 on anonymous pulls, so even
     if images existed, Helm-managed deployments without registry
     credentials would fail.

GHCR, by contrast, is auto-built and pushed on every merge to main by
.github/workflows/docker-publish.yml, and ghcr.io allows anonymous pulls
for public images. It's also the registry that docker-compose.yml has
been using as primary all along, so this brings the Helm install path
to parity with the Docker Compose install path.

After this change:
- ghcr.io/bigbodycobain/shadowbroker-backend:latest   <- now in chart
- ghcr.io/bigbodycobain/shadowbroker-frontend:latest  <- now in chart

GitLab is preserved in the comments as a documented fallback for
operators who run private mirrors with their own CI.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:01:05 -06:00
Shadowbroker 421682c447 Pause AlertToast auto-dismiss while hovered (#235)
Each alert toast had a 5-second auto-dismiss timer that fired even
while the user was reading the card. This adds pause-on-hover: the
dismiss timer stops while the mouse is over a toast and restarts (full
lifetime) on mouse leave. The progress bar animation pauses with it,
so the visual matches the actual remaining time.

All other behavior is preserved: same cyber/mono styling, same spring
slide-in, same risk-color border + glow, same warning icon, same
LVL X/10 readout, same title/source layout, same click-to-fly + dismiss
on body click, same × dismiss button.

Implementation notes:
- Extract a ToastCard sub-component so each card can own its own
  paused state (useState can't be array-indexed in the parent).
- Move the auto-dismiss timer out of useAlertToasts.ts and into
  ToastCard. The hook previously scheduled the dismiss itself, which
  meant the UI couldn't pause it — only the component knows whether
  the user is interacting.
- Add tests covering: title/source/severity render, auto-dismiss
  fires at 5s, hover pauses indefinitely, mouse-leave restarts the
  full lifetime, × dismisses without flying, body-click flies +
  dismisses.

This implements the genuine UX improvement that PR #234 was reaching
for, without #234's broken syntax, missing-field bug, duplicate
timer logic, or design regression.

Refs: #234

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:49:36 -06:00
Shadowbroker 40734e310b Merge pull request #232 from BigBodyCobain/security/post-pr227-gap-fixes-v2
[security] Close post-#227 control-surface and fetcher gaps
2026-05-18 14:03:47 -06:00
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
Shadowbroker de27d119f9 Merge pull request #227 from BigBodyCobain/codex/infonet-control-surface-hardening
Harden infonet control surfaces
2026-05-18 12:56:51 -06:00
BigBodyCobain b8384d6d91 Fix secure mail contact hydration race 2026-05-18 12:38:20 -06:00
BigBodyCobain 11ea345518 Harden infonet control surfaces 2026-05-18 11:22:38 -06:00
BigBodyCobain 25a98a9869 Harden Infonet DM address flow and seed sync
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.
v0.9.79
2026-05-12 21:23:38 -06:00
BigBodyCobain 2ce0e43ee5 Fix secure messaging test expectations 2026-05-12 12:46:56 -06:00
BigBodyCobain b86a258535 Release v0.9.79 runtime and messaging update
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.
2026-05-12 11:49:46 -06:00
BigBodyCobain 85636ce95c Stabilize secure mail warmup test v0.9.75 2026-05-06 22:54:11 -06:00
BigBodyCobain 5ee4f8ecd7 Stabilize Infonet private sync and selected telemetry 2026-05-06 22:10:04 -06:00
BigBodyCobain b8ac0fb9e7 Harden v0.9.75 wormhole node sync and telemetry panels
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.
2026-05-06 14:04:16 -06:00
BigBodyCobain 8926e08009 Fix cached satellite propagation 2026-05-06 02:25:10 -06:00
BigBodyCobain 585a08bbac Fix MeshChat decomposition release gate 2026-05-06 01:46:26 -06:00
BigBodyCobain 6ffd54931c Release v0.9.75 runtime and onboarding update
Ship the 0.9.75 source update with improved startup/runtime hardening, operator API key onboarding, Meshtastic MQTT controls, Infonet/MeshChat separation, desktop package versioning, and aircraft telemetry refinements.

Also updates focused backend/frontend tests for node settings, Meshtastic MQTT settings, and desktop runtime behavior.
2026-05-06 01:15:54 -06:00
BigBodyCobain a017ba86d6 Fix desktop release packaging without signing keys 2026-05-04 21:54:29 -06:00
BigBodyCobain 9427935c7f Align CSP tests with hydration-safe policy 2026-05-04 13:04:31 -06:00
BigBodyCobain 63043b32b5 Stabilize Docker startup and runtime proxy
Reduce cold-start stalls by raising the default backend memory limit, bounding heavy feed concurrency, preserving non-empty startup caches, and refreshing working news feeds. Fix the Next API proxy for Docker control-plane writes by stripping unsupported hop/body headers and forwarding small request bodies safely. Keep the dashboard dynamic so production users do not get stuck on a cached startup shell.
2026-05-04 12:37:23 -06:00
BigBodyCobain 1e34fa53b2 Make Docker backend port configurable 2026-05-03 21:13:31 -06:00
BigBodyCobain d69602be9e Align CSP test with production hydration policy 2026-05-03 14:06:39 -06:00
BigBodyCobain ce9ba39cd2 Fix production CSP hydration 2026-05-03 13:59:07 -06:00
BigBodyCobain 3eafb622ed Clarify Podman compose setup 2026-05-03 08:44:56 -06:00
Shadowbroker eb5564ca0e Update README.md 2026-05-03 02:59:03 -06:00
BigBodyCobain 20d2ccc52c Fix desktop static export build v0.9.7 2026-05-02 23:18:57 -06:00
BigBodyCobain 0fc09c9011 Fix Docker Infonet and Wormhole startup 2026-05-02 21:53:35 -06:00
BigBodyCobain 707ca29220 Add in-app local API key setup
Let fresh Docker and local installs enter OpenSky, AIS, and other provider keys directly in onboarding or Settings without manually creating .env files. Persist keys server-side in the backend data store, keep them write-only from the browser, reload runtime settings, and retain local-operator access controls.
2026-05-02 21:16:32 -06:00
BigBodyCobain eb0288ee4e Fix Docker local controls and setup guidance
Allow the bundled Docker frontend proxy to reach local-operator endpoints through the private compose bridge without trusting LAN clients. This restores Time Machine, MeshChat key creation, AI pins/layers, and related local controls in Docker installs. Refresh first-run guidance so Docker users know to configure OpenSky and AIS keys through .env.
2026-05-02 20:18:46 -06:00
BigBodyCobain 8d3c7a51b7 Fix Docker frontend hydration under CSP
Render the app shell dynamically so Next can attach per-request CSP nonces to its production scripts, preventing Docker from serving a static shell that cannot hydrate. Also gives the first-contact warmup test enough time in CI.
2026-05-02 19:47:32 -06:00
BigBodyCobain fa18c032e2 Fix Docker first-run startup data seeding
Seed safe static backend data into fresh Docker volumes, tighten Docker build-context exclusions, avoid optional env warnings, and make the frontend healthcheck use the IPv4 loopback path that works inside the container.
2026-05-02 19:27:59 -06:00
BigBodyCobain e1060193d0 Improve v0.9.7 startup and runtime reliability
Prioritize cached first-paint data, defer heavyweight feed synthesis, make MeshChat activation explicit, improve CCTV media handling, and tighten desktop runtime packaging filters.
2026-05-02 17:31:54 -06:00
BigBodyCobain 08810f2537 fix: stabilize v0.9.7 startup and feeds 2026-05-02 13:35:49 -06:00
BigBodyCobain f5b9d14b48 Merge remote-tracking branch 'origin/main' 2026-05-02 09:40:23 -06:00
BigBodyCobain 9122d306cd fix: refresh privacy-core pin on source startup 2026-05-02 09:38:13 -06:00
Shadowbroker 03e5fc1363 Update README.md 2026-05-02 09:20:40 -06:00
BigBodyCobain 447afe0b2b build: refresh v0.9.7 updater key 2026-05-02 02:24:46 -06:00