Commit Graph

3 Commits

Author SHA1 Message Date
BigBodyCobain c54ea7fd9f Fix #299/#300/#301: gate Sentinel proxy routes with require_local_operator
Reported by @tg12 in three audit issues opened the same day:

  #299 — POST /api/sentinel/token is an unauthenticated Copernicus
         OAuth relay for caller-supplied client_id/secret.
  #300 — POST /api/sentinel/tile is an unauthenticated quota/bandwidth
         relay for Sentinel Hub Process API tile fetches.
  #301 — GET /api/sentinel2/search is an unauthenticated Planetary
         Computer STAC + Esri imagery search relay.

All three lived in backend/routers/tools.py decorated only with
@limiter.limit(...) — no Depends(require_local_operator). That made
the backend a free anonymous relay for any caller's Sentinel /
Planetary Computer queries, in the same shape we already closed for
#240/#241 (oracle resolve) and #211/#213/#214 (thermal verify, OpenMHZ
calls + audio relay).

Fix: add dependencies=[Depends(require_local_operator)] to each route.
Loopback / Docker-bridge / admin-key callers (the operator dashboard)
are unaffected — they still resolve through the same allowlist used by
every other operator-only helper in this file. Anonymous remote callers
now receive 403 BEFORE any outbound HTTP call to Copernicus or
Planetary Computer happens.

Tests
-----
test_sentinel_routes_auth_gate.py — 8 new tests:
  * anonymous-remote → 403 on all three routes
  * NO upstream HTTP call when the gate fires (asserted via
    MagicMock(side_effect=AssertionError) on requests.post and
    services.sentinel_search.search_sentinel2_scene). This is the
    property that makes the gate real — without it, a 403 returned
    after the upstream call still burns quota.
  * 127.0.0.1 loopback caller reaches the handler (no false-positive
    where the gate accidentally blocks the local operator too).
  * Uses raw ASGITransport(client=(peer_ip, ...)) rather than
    FastAPI's TestClient because TestClient reports client.host as
    "testclient" which is not on the loopback allowlist.

test_control_surface_auth.py — extended the existing parameterised
regression with the three new routes. That regression is the global
"no remote control surface ships without auth" guard for the whole
codebase; adding these to it means a future refactor that drops the
dependency from any of them will fail CI alongside the existing
~30 gated routes.

The egress-on-403 property and the parameterised regression together
give two independent proofs that the gate fires before the upstream
network call, even if FastAPI's internal dependant tree shape changes
across versions (an earlier draft of this PR included a static walker
of the route table; it was removed because behavioural evidence is
strictly stronger and version-independent).
2026-05-22 09:58:25 -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
BigBodyCobain 28b3bd5ebf release: prepare v0.9.7 2026-05-01 22:56:50 -06:00