Files
Shadowbroker/backend/routers/tools.py
T
BigBodyCobain b041b5e97c Fix #298: move Sentinel credentials from browser storage to backend .env
Reported by @tg12. Pre-fix, the Settings panel stored real third-party
Copernicus CDSE client_id + client_secret in browser localStorage /
sessionStorage via the privacy storage helper, and the proxy routes
required those values to come back in every tile/token request body.
Any same-origin script (XSS, malicious browser extension, dev-tools
HAR export) had read access to the credentials.

This change moves them server-side, behind the same .env-backed admin
flow every other third-party API key (OpenSky, AIS Stream, Finnhub,
Shodan, …) already uses.

Backend
-------
backend/services/api_settings.py
  * Added SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET entries to
    API_REGISTRY. The existing GET/PUT /api/settings/api-keys flow
    (already require_local_operator-gated, .env-backed) now manages
    them — no new route surface.

backend/routers/tools.py
  * /api/sentinel/token and /api/sentinel/tile resolve credentials via
    a new _resolve_sentinel_credentials() helper: body fields win for
    back-compat with any legacy callers, otherwise the helper reads
    SENTINEL_CLIENT_ID / SENTINEL_CLIENT_SECRET from os.environ.
  * When neither source has a value, the route returns 400 with a
    friendly pointer ("Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET
    in the API Keys panel") instead of the curt "required" message.
    The user's standing rule against hostile errors applies.
  * Function bodies only — decorator lines untouched, so this PR does
    not conflict with #303 (which adds Depends(require_local_operator)
    to the same routes).

Frontend
--------
frontend/src/lib/sentinelHub.ts — rewritten
  * Removed: getSentinelCredentials / setSentinelCredentials /
    clearSentinelCredentials / getSentinelCredentialStorageMode.
    These were the browser-storage read/write helpers; their existence
    was the bug.
  * Added: checkBackendSentinelStatus(), refreshSentinelStatus(),
    getCachedSentinelStatus(), and a kept-for-back-compat
    hasSentinelCredentials() shim. Status is sourced from
    /api/settings/api-keys (the same endpoint the API Keys panel
    already uses), so we don't add a new route just for this read.
  * Added: migrateLegacySentinelBrowserKeys() — one-shot, idempotent
    helper that clears sb_sentinel_client_id / _secret / _instance_id
    from BOTH localStorage and sessionStorage. We deliberately do NOT
    auto-POST those legacy browser values to the backend; doing so
    would silently migrate a secret across a trust boundary without
    operator consent. Operators re-enter once in the API Keys panel
    and the legacy keys get wiped here.
  * fetchSentinelTile and getSentinelToken no longer send client_id /
    client_secret in the request body. The backend uses .env.

frontend/src/components/SettingsPanel.tsx
  * Dropped sb_sentinel_client_id / _secret / _instance_id from
    PRIVACY_SENSITIVE_BROWSER_KEYS — they're no longer written.
  * SentinelTab rewritten: removed the inline Client ID / Client Secret
    inputs + Save / Clear / Test buttons. Replaced with a status panel
    that calls checkBackendSentinelStatus() on mount, a one-click
    "Open API Keys Panel" button, and a migration banner that appears
    only when migrateLegacySentinelBrowserKeys() actually cleared
    something.
  * Setup guide STEP 3 now points to the API Keys panel instead of
    the local form.

frontend/src/app/page.tsx
  * Added a one-time useEffect that fires checkBackendSentinelStatus()
    on mount so the cached value (which the synchronous
    hasSentinelCredentials() shim reads) is populated before
    MaplibreViewer's tile-URL memo runs.

Tests
-----
backend/tests/test_sentinel_credentials_server_side.py (new)
  * API_REGISTRY surface — sentinel_client_id / sentinel_client_secret
    are registered with the right env_keys, ALLOWED_ENV_KEYS lets
    /api/settings/api-keys PUT them.
  * Resolution order — body wins, env is fallback, neither → 400 with
    the friendly pointer message, and NO upstream HTTP call when
    neither source has credentials (asserted via
    MagicMock(side_effect=AssertionError)).
  * /api/sentinel/tile same shape.

frontend/src/__tests__/utils/sentinelHub.test.ts (new)
  * migrateLegacySentinelBrowserKeys clears localStorage AND
    sessionStorage, reports what it cleared, idempotent.
  * fetchSentinelTile + getSentinelToken POST WITHOUT client_id /
    client_secret in the body (plants leaked credentials in browser
    storage first to prove they are NOT picked up).
  * checkBackendSentinelStatus parses /api/settings/api-keys correctly:
    true only when both keys is_set, false on partial config or
    network errors.

All 7 backend tests + 8 frontend tests pass locally. The
test_no_new_duplicate_routes guard and the api-settings test suite
still pass.

Credit: @tg12 for the audit report.
2026-05-22 10:44:50 -06:00

386 lines
16 KiB
Python

import asyncio
import logging
import math
from typing import Any
from fastapi import APIRouter, Request, Query, Depends, HTTPException, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from limiter import limiter
from auth import require_admin, require_local_operator
logger = logging.getLogger(__name__)
router = APIRouter()
def _safe_int(val, default=0):
try:
return int(val)
except (TypeError, ValueError):
return default
def _safe_float(val, default=0.0):
try:
parsed = float(val)
if not math.isfinite(parsed):
return default
return parsed
except (TypeError, ValueError):
return default
class ShodanSearchRequest(BaseModel):
query: str
page: int = 1
facets: list[str] = []
class ShodanCountRequest(BaseModel):
query: str
facets: list[str] = []
class ShodanHostRequest(BaseModel):
ip: str
history: bool = False
@router.get("/api/region-dossier")
@limiter.limit("30/minute")
def api_region_dossier(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
):
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
from services.region_dossier import get_region_dossier
return get_region_dossier(lat, lng)
@router.get("/api/geocode/search")
@limiter.limit("30/minute")
async def api_geocode_search(
request: Request,
q: str = "",
limit: int = 5,
local_only: bool = False,
):
from services.geocode import search_geocode
if not q or len(q.strip()) < 2:
return {"results": [], "query": q, "count": 0}
results = await asyncio.to_thread(search_geocode, q, limit, local_only)
return {"results": results, "query": q, "count": len(results)}
@router.get("/api/geocode/reverse")
@limiter.limit("60/minute")
async def api_geocode_reverse(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
local_only: bool = False,
):
from services.geocode import reverse_geocode
return await asyncio.to_thread(reverse_geocode, lat, lng, local_only)
@router.get("/api/sentinel2/search")
@limiter.limit("30/minute")
def api_sentinel2_search(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
):
"""Search for latest Sentinel-2 imagery at a point. Sync for threadpool execution."""
from services.sentinel_search import search_sentinel2_scene
return search_sentinel2_scene(lat, lng)
# Issue #298 (tg12): Sentinel credentials moved server-side
# ---------------------------------------------------------------------------
# Previously the frontend kept Copernicus CDSE client_id + client_secret in
# browser localStorage / sessionStorage and forwarded them on every tile
# request through this proxy. That exposed real third-party credentials to
# any same-origin script (XSS, malicious browser extension, dev-tools HAR
# export).
#
# Resolution order (first match wins):
# 1. Request body — kept for back-compat. A small number of legacy
# operator setups may still post credentials; we don't break them.
# 2. Backend .env — SENTINEL_CLIENT_ID / SENTINEL_CLIENT_SECRET, managed
# through the existing /api/settings/api-keys flow (admin-gated).
#
# The frontend in ``sentinelHub.ts`` no longer reads browser storage and no
# longer forwards credentials — every dashboard request now lands in (2).
# ---------------------------------------------------------------------------
def _resolve_sentinel_credentials(body_id: str, body_secret: str) -> tuple[str, str]:
"""Return (client_id, client_secret) using body values when present,
otherwise falling back to backend .env. Empty strings if neither is set."""
import os as _os
cid = (body_id or "").strip() or (_os.environ.get("SENTINEL_CLIENT_ID", "") or "").strip()
csec = (body_secret or "").strip() or (_os.environ.get("SENTINEL_CLIENT_SECRET", "") or "").strip()
return cid, csec
@router.post("/api/sentinel/token")
@limiter.limit("60/minute")
async def api_sentinel_token(request: Request):
"""Proxy Copernicus CDSE OAuth2 token request (avoids browser CORS block).
Credentials are resolved by ``_resolve_sentinel_credentials`` — body
fields are honored for back-compat, otherwise the backend .env values
populated through ``/api/settings/api-keys`` are used.
"""
import requests as req
body = await request.body()
from urllib.parse import parse_qs
params = parse_qs(body.decode("utf-8"))
body_id = params.get("client_id", [""])[0]
body_secret = params.get("client_secret", [""])[0]
client_id, client_secret = _resolve_sentinel_credentials(body_id, body_secret)
if not client_id or not client_secret:
# Friendly, non-hostile error — points the operator at the place
# they configure other API keys instead of just saying "required".
raise HTTPException(
400,
"Sentinel client_id/client_secret are not configured. "
"Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET in the "
"API Keys panel (Settings → API Keys) or your backend .env.",
)
token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
try:
resp = await asyncio.to_thread(req.post, token_url,
data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
timeout=15)
return Response(content=resp.content, status_code=resp.status_code, media_type="application/json")
except Exception:
logger.exception("Token request failed")
raise HTTPException(502, "Token request failed")
# Cache key is an HMAC of (client_id, client_secret) — a caller cannot hit
# this cache without knowing the same secret that originally populated it.
# Without this binding, the lookup only checked client_id, so anyone who
# knew a valid client_id could reuse another caller's cached token (and
# burn their Copernicus quota / access tiles on their account).
_sh_token_cache: dict = {"token": None, "expiry": 0, "credential_fp": ""}
def _credential_fingerprint(client_id: str, client_secret: str) -> str:
"""Return a stable, secret-binding fingerprint for the Sentinel cache key.
Uses HMAC-SHA256 so the raw secret is never stored in process memory as
a cache key. The HMAC key is a per-process random value, which means the
fingerprint cannot be precomputed across restarts (additional defense
against an attacker who learned a valid client_id but not the secret).
"""
import hashlib
import hmac
return hmac.new(
_SH_TOKEN_CACHE_HMAC_KEY,
f"{client_id}\x00{client_secret}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
# Per-process random HMAC key. Regenerated on each backend startup so cached
# fingerprints don't survive restarts.
import os as _os
_SH_TOKEN_CACHE_HMAC_KEY = _os.urandom(32)
@router.post("/api/sentinel/tile")
@limiter.limit("300/minute")
async def api_sentinel_tile(request: Request):
"""Proxy Sentinel Hub Process API tile request (avoids CORS block)."""
import requests as req
import time as _time
try:
body = await request.json()
except Exception:
return JSONResponse(status_code=422, content={"ok": False, "detail": "invalid JSON body"})
# Issue #298: same resolution order as /api/sentinel/token — body
# values for back-compat, otherwise backend .env.
body_id = body.get("client_id", "")
body_secret = body.get("client_secret", "")
client_id, client_secret = _resolve_sentinel_credentials(body_id, body_secret)
preset = body.get("preset", "TRUE-COLOR")
date_str = body.get("date", "")
z = body.get("z", 0)
x = body.get("x", 0)
y = body.get("y", 0)
if not client_id or not client_secret or not date_str:
# Distinguish "no creds" from "no date" so the operator knows
# what to fix. Same friendly pointer as the /token route.
if not client_id or not client_secret:
raise HTTPException(
400,
"Sentinel client_id/client_secret are not configured. "
"Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET in the "
"API Keys panel (Settings → API Keys) or your backend .env.",
)
raise HTTPException(400, "date required")
now = _time.time()
credential_fp = _credential_fingerprint(client_id, client_secret)
if (_sh_token_cache["token"]
and _sh_token_cache["credential_fp"] == credential_fp
and now < _sh_token_cache["expiry"] - 30):
token = _sh_token_cache["token"]
else:
token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
try:
tresp = await asyncio.to_thread(req.post, token_url,
data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
timeout=15)
if tresp.status_code != 200:
raise HTTPException(401, f"Token auth failed: {tresp.text[:200]}")
tdata = tresp.json()
token = tdata["access_token"]
_sh_token_cache["token"] = token
_sh_token_cache["expiry"] = now + tdata.get("expires_in", 300)
_sh_token_cache["credential_fp"] = credential_fp
except HTTPException:
raise
except Exception:
logger.exception("Token request failed")
raise HTTPException(502, "Token request failed")
half = 20037508.342789244
tile_size = (2 * half) / math.pow(2, z)
min_x = -half + x * tile_size
max_x = min_x + tile_size
max_y = half - y * tile_size
min_y = max_y - tile_size
bbox = [min_x, min_y, max_x, max_y]
evalscripts = {
"TRUE-COLOR": '//VERSION=3\nfunction setup(){return{input:["B04","B03","B02"],output:{bands:3}};}\nfunction evaluatePixel(s){return[2.5*s.B04,2.5*s.B03,2.5*s.B02];}',
"FALSE-COLOR": '//VERSION=3\nfunction setup(){return{input:["B08","B04","B03"],output:{bands:3}};}\nfunction evaluatePixel(s){return[2.5*s.B08,2.5*s.B04,2.5*s.B03];}',
"NDVI": '//VERSION=3\nfunction setup(){return{input:["B04","B08"],output:{bands:3}};}\nfunction evaluatePixel(s){var n=(s.B08-s.B04)/(s.B08+s.B04);if(n<-0.2)return[0.05,0.05,0.05];if(n<0)return[0.75,0.75,0.75];if(n<0.1)return[0.86,0.86,0.86];if(n<0.2)return[0.92,0.84,0.68];if(n<0.3)return[0.77,0.88,0.55];if(n<0.4)return[0.56,0.80,0.32];if(n<0.5)return[0.35,0.72,0.18];if(n<0.6)return[0.20,0.60,0.08];if(n<0.7)return[0.10,0.48,0.04];return[0.0,0.36,0.0];}',
"MOISTURE-INDEX": '//VERSION=3\nfunction setup(){return{input:["B8A","B11"],output:{bands:3}};}\nfunction evaluatePixel(s){var m=(s.B8A-s.B11)/(s.B8A+s.B11);var r=Math.max(0,Math.min(1,1.5-3*m));var g=Math.max(0,Math.min(1,m<0?1.5+3*m:1.5-3*m));var b=Math.max(0,Math.min(1,1.5+3*(m-0.5)));return[r,g,b];}',
}
evalscript = evalscripts.get(preset, evalscripts["TRUE-COLOR"])
from datetime import datetime as _dt, timedelta as _td
try:
end_date = _dt.strptime(date_str, "%Y-%m-%d")
except ValueError:
end_date = _dt.utcnow()
if z <= 6:
lookback_days = 30
elif z <= 9:
lookback_days = 14
elif z <= 11:
lookback_days = 7
else:
lookback_days = 5
start_date = end_date - _td(days=lookback_days)
process_body = {
"input": {
"bounds": {"bbox": bbox, "properties": {"crs": "http://www.opengis.net/def/crs/EPSG/0/3857"}},
"data": [{"type": "sentinel-2-l2a", "dataFilter": {
"timeRange": {
"from": start_date.strftime("%Y-%m-%dT00:00:00Z"),
"to": end_date.strftime("%Y-%m-%dT23:59:59Z"),
},
"maxCloudCoverage": 30, "mosaickingOrder": "leastCC",
}}],
},
"output": {"width": 256, "height": 256,
"responses": [{"identifier": "default", "format": {"type": "image/png"}}]},
"evalscript": evalscript,
}
try:
resp = await asyncio.to_thread(req.post,
"https://sh.dataspace.copernicus.eu/api/v1/process",
json=process_body,
headers={"Authorization": f"Bearer {token}", "Accept": "image/png"},
timeout=30)
return Response(content=resp.content, status_code=resp.status_code,
media_type=resp.headers.get("content-type", "image/png"))
except Exception:
logger.exception("Process API failed")
raise HTTPException(502, "Process API failed")
@router.get("/api/tools/shodan/status", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_shodan_status(request: Request):
from services.shodan_connector import get_shodan_connector_status
return get_shodan_connector_status()
@router.post("/api/tools/shodan/search", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_shodan_search(request: Request, body: ShodanSearchRequest):
from services.shodan_connector import ShodanConnectorError, search_shodan
try:
return search_shodan(body.query, page=body.page, facets=body.facets)
except ShodanConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/api/tools/shodan/count", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_shodan_count(request: Request, body: ShodanCountRequest):
from services.shodan_connector import ShodanConnectorError, count_shodan
try:
return count_shodan(body.query, facets=body.facets)
except ShodanConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/api/tools/shodan/host", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_shodan_host(request: Request, body: ShodanHostRequest):
from services.shodan_connector import ShodanConnectorError, lookup_shodan_host
try:
return lookup_shodan_host(body.ip, history=body.history)
except ShodanConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.get("/api/tools/uw/status", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_uw_status(request: Request):
from services.unusual_whales_connector import get_uw_status
return get_uw_status()
@router.post("/api/tools/uw/congress", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_uw_congress(request: Request):
from services.unusual_whales_connector import FinnhubConnectorError, fetch_congress_trades
try:
return fetch_congress_trades()
except FinnhubConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/api/tools/uw/darkpool", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_uw_darkpool(request: Request):
from services.unusual_whales_connector import FinnhubConnectorError, fetch_insider_transactions
try:
return fetch_insider_transactions()
except FinnhubConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post("/api/tools/uw/flow", dependencies=[Depends(require_local_operator)])
@limiter.limit("12/minute")
async def api_uw_flow(request: Request):
from services.unusual_whales_connector import FinnhubConnectorError, fetch_defense_quotes
try:
return fetch_defense_quotes()
except FinnhubConnectorError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc