mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
c54ea7fd9f
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).
358 lines
14 KiB
Python
358 lines
14 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)
|
|
|
|
|
|
# ── Sentinel proxy routes (Issue #299/#300/#301, reported by tg12) ──────────
|
|
# These three endpoints relay external Sentinel / Planetary Computer
|
|
# requests through the backend to avoid browser CORS blocks. They are
|
|
# operator-only helpers — they MUST NOT be callable by anonymous remote
|
|
# users, because:
|
|
#
|
|
# * /api/sentinel/token — caller supplies their own Sentinel client_id +
|
|
# client_secret. Without operator gating, the backend becomes a free
|
|
# anonymous OAuth-mint relay for any Copernicus account.
|
|
# * /api/sentinel/tile — same shape as the token route but for tile
|
|
# imagery. Without gating, the backend acts as an anonymous quota and
|
|
# bandwidth relay for Sentinel Hub Process API calls.
|
|
# * /api/sentinel2/search — hits the Planetary Computer STAC search API
|
|
# and falls back to Esri imagery. No caller credentials are involved,
|
|
# but the route is still an anonymous external-search relay. We gate
|
|
# it the same way for consistency with the rest of the operator-only
|
|
# helper surface.
|
|
#
|
|
# Gating is via require_local_operator (loopback / bridge / admin key),
|
|
# matching the same allowlist already used by /api/region-dossier and
|
|
# the other operator helpers further up this file. Single-operator nodes
|
|
# see no behavior change — their dashboard already lives on loopback or
|
|
# the trusted Docker bridge, so it still resolves.
|
|
@router.get("/api/sentinel2/search", dependencies=[Depends(require_local_operator)])
|
|
@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)
|
|
|
|
|
|
@router.post("/api/sentinel/token", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("60/minute")
|
|
async def api_sentinel_token(request: Request):
|
|
"""Proxy Copernicus CDSE OAuth2 token request (avoids browser CORS block)."""
|
|
import requests as req
|
|
body = await request.body()
|
|
from urllib.parse import parse_qs
|
|
params = parse_qs(body.decode("utf-8"))
|
|
client_id = params.get("client_id", [""])[0]
|
|
client_secret = params.get("client_secret", [""])[0]
|
|
if not client_id or not client_secret:
|
|
raise HTTPException(400, "client_id and client_secret required")
|
|
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", dependencies=[Depends(require_local_operator)])
|
|
@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"})
|
|
|
|
client_id = body.get("client_id", "")
|
|
client_secret = body.get("client_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:
|
|
raise HTTPException(400, "client_id, client_secret, and 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
|