mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
b041b5e97c
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.
386 lines
16 KiB
Python
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
|