mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 09:32:28 +02:00
d00c63abed
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>
335 lines
13 KiB
Python
335 lines
13 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)
|
|
|
|
|
|
@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)."""
|
|
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")
|
|
@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
|