Files
Shadowbroker/backend/services/api_settings.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

364 lines
14 KiB
Python

"""
API Settings management — serves the API key registry and allows updates.
Keys are stored in the backend .env file and loaded via python-dotenv.
"""
import os
import re
import tempfile
from pathlib import Path
# Path to the backend .env file
ENV_PATH = Path(__file__).parent.parent / ".env"
# Path to the example template that ships with the repo
ENV_EXAMPLE_PATH = Path(__file__).parent.parent.parent / ".env.example"
DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data")))
if not DATA_DIR.is_absolute():
DATA_DIR = Path(__file__).parent.parent / DATA_DIR
OPERATOR_KEYS_ENV_PATH = Path(
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
)
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
# ---------------------------------------------------------------------------
# API Registry — every external service the dashboard depends on
# ---------------------------------------------------------------------------
API_REGISTRY = [
{
"id": "opensky_client_id",
"env_key": "OPENSKY_CLIENT_ID",
"name": "OpenSky Network — Client ID",
"description": "OAuth2 client ID for the OpenSky Network API. Provides global flight state vectors with 400 requests/day.",
"category": "Aviation",
"url": "https://opensky-network.org/",
"required": True,
},
{
"id": "opensky_client_secret",
"env_key": "OPENSKY_CLIENT_SECRET",
"name": "OpenSky Network — Client Secret",
"description": "OAuth2 client secret paired with the Client ID above. Used for authenticated token refresh.",
"category": "Aviation",
"url": "https://opensky-network.org/",
"required": True,
},
{
"id": "ais_api_key",
"env_key": "AIS_API_KEY",
"name": "AIS Stream",
"description": "WebSocket API key for real-time Automatic Identification System (AIS) vessel tracking data worldwide.",
"category": "Maritime",
"url": "https://aisstream.io/",
"required": True,
},
{
"id": "adsb_lol",
"env_key": None,
"name": "ADS-B Exchange (adsb.lol)",
"description": "Community-maintained ADS-B flight tracking API. No key required — public endpoint.",
"category": "Aviation",
"url": "https://api.adsb.lol/",
"required": False,
},
{
"id": "usgs_earthquakes",
"env_key": None,
"name": "USGS Earthquake Hazards",
"description": "Real-time earthquake data feed from the United States Geological Survey. No key required.",
"category": "Geophysical",
"url": "https://earthquake.usgs.gov/",
"required": False,
},
{
"id": "celestrak",
"env_key": None,
"name": "CelesTrak (NORAD TLEs)",
"description": "Satellite orbital element data from CelesTrak. Provides TLE sets for 2,000+ active satellites. No key required.",
"category": "Space",
"url": "https://celestrak.org/",
"required": False,
},
{
"id": "gdelt",
"env_key": None,
"name": "GDELT Project",
"description": "Global Database of Events, Language, and Tone. Monitors news media for geopolitical events worldwide. No key required.",
"category": "Intelligence",
"url": "https://www.gdeltproject.org/",
"required": False,
},
{
"id": "nominatim",
"env_key": None,
"name": "Nominatim (OpenStreetMap)",
"description": "Reverse geocoding service. Converts lat/lng coordinates to human-readable location names. No key required.",
"category": "Geolocation",
"url": "https://nominatim.openstreetmap.org/",
"required": False,
},
{
"id": "rainviewer",
"env_key": None,
"name": "RainViewer",
"description": "Weather radar tile overlay. Provides global precipitation data as map tiles. No key required.",
"category": "Weather",
"url": "https://www.rainviewer.com/",
"required": False,
},
{
"id": "rss_feeds",
"env_key": None,
"name": "RSS News Feeds",
"description": "Aggregates from NPR, BBC, Al Jazeera, NYT, Reuters, and AP for global news coverage. No key required.",
"category": "Intelligence",
"url": None,
"required": False,
},
{
"id": "yfinance",
"env_key": None,
"name": "Yahoo Finance (yfinance)",
"description": "Defense sector stock tickers and commodity prices. Uses the yfinance Python library. No key required.",
"category": "Markets",
"url": "https://finance.yahoo.com/",
"required": False,
},
{
"id": "openmhz",
"env_key": None,
"name": "OpenMHz",
"description": "Public radio scanner feeds for SIGINT interception. Streams police/fire/EMS radio traffic. No key required.",
"category": "SIGINT",
"url": "https://openmhz.com/",
"required": False,
},
{
"id": "shodan_api_key",
"env_key": "SHODAN_API_KEY",
"name": "Shodan — Operator API Key",
"description": "Paid Shodan API key for local operator-driven searches and temporary map overlays. Results are attributed to Shodan and are not merged into ShadowBroker core feeds.",
"category": "Reconnaissance",
"url": "https://account.shodan.io/billing",
"required": False,
},
{
"id": "finnhub_api_key",
"env_key": "FINNHUB_API_KEY",
"name": "Finnhub — API Key",
"description": "Free market data API. Defense stock quotes, congressional trading disclosures, and insider transactions. 60 calls/min free tier.",
"category": "Financial",
"url": "https://finnhub.io/register",
"required": False,
},
# Issue #298 (tg12): Sentinel Hub / Copernicus Data Space Ecosystem
# credentials were previously held in browser localStorage / sessionStorage
# by the Settings panel. Moved server-side to the same .env-backed
# store every other third-party API key lives in. The Sentinel proxy
# routes (POST /api/sentinel/token, /tile) now fall back to these
# env values when the request body omits credentials — see
# backend/routers/tools.py for the resolution order.
{
"id": "sentinel_client_id",
"env_key": "SENTINEL_CLIENT_ID",
"name": "Sentinel Hub / Copernicus — Client ID",
"description": "OAuth2 client ID for Copernicus Data Space Ecosystem (CDSE). Required for the Sentinel-2 imagery overlay and the right-click Sentinel-2 Intel Card. Sign in at dataspace.copernicus.eu and create OAuth credentials.",
"category": "Imagery",
"url": "https://dataspace.copernicus.eu/",
"required": False,
},
{
"id": "sentinel_client_secret",
"env_key": "SENTINEL_CLIENT_SECRET",
"name": "Sentinel Hub / Copernicus — Client Secret",
"description": "OAuth2 client secret paired with the Client ID above. Used by the backend to mint short-lived access tokens against the CDSE identity provider. Stored in the backend .env; never sent to the browser.",
"category": "Imagery",
"url": "https://dataspace.copernicus.eu/",
"required": False,
},
]
ALLOWED_ENV_KEYS = {
str(api["env_key"])
for api in API_REGISTRY
if api.get("env_key")
}
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not _ENV_KEY_RE.match(key):
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
values[key] = value
return values
def _quote_env_value(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _write_env_values(path: Path, updates: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
seen: set[str] = set()
next_lines: list[str] = []
for raw_line in lines:
stripped = raw_line.strip()
if "=" not in stripped or stripped.startswith("#"):
next_lines.append(raw_line)
continue
key = stripped.split("=", 1)[0].strip()
if key in updates:
next_lines.append(f"{key}={_quote_env_value(updates[key])}")
seen.add(key)
else:
next_lines.append(raw_line)
for key, value in updates.items():
if key not in seen:
next_lines.append(f"{key}={_quote_env_value(value)}")
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f"{path.name}.tmp.", text=True)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(next_lines).rstrip() + "\n")
if os.name != "nt":
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
if os.name != "nt":
os.chmod(path, 0o600)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
def load_persisted_api_keys_into_environ() -> None:
"""Load persisted operator API keys if no process env value exists."""
for key, value in _parse_env_file(OPERATOR_KEYS_ENV_PATH).items():
if key in ALLOWED_ENV_KEYS and value and not os.environ.get(key):
os.environ[key] = value
def get_env_path_info() -> dict:
"""Return absolute paths for the backend .env and .env.example template.
Surfaced to the frontend so the API Keys settings panel can tell users
exactly where to put their keys when in-app editing fails (admin-not-set,
file permissions, read-only filesystem, etc.).
"""
env_path = ENV_PATH.resolve()
example_path = ENV_EXAMPLE_PATH.resolve()
return {
"env_path": str(env_path),
"env_path_exists": env_path.exists(),
"env_path_writable": os.access(env_path.parent, os.W_OK)
and (not env_path.exists() or os.access(env_path, os.W_OK)),
"env_example_path": str(example_path),
"env_example_path_exists": example_path.exists(),
"operator_keys_env_path": str(OPERATOR_KEYS_ENV_PATH.resolve()),
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
}
def get_api_keys():
"""Return the API registry with a binary set/unset flag per key.
Key values themselves are NEVER returned to the client — not even an
obfuscated prefix. Users edit the .env file directly; the panel uses
`is_set` to render a CONFIGURED / NOT CONFIGURED badge and the path
info from `get_env_path_info()` to tell them where to put each key.
"""
load_persisted_api_keys_into_environ()
result = []
for api in API_REGISTRY:
entry = {
"id": api["id"],
"name": api["name"],
"description": api["description"],
"category": api["category"],
"url": api["url"],
"required": api["required"],
"has_key": api["env_key"] is not None,
"env_key": api["env_key"],
"is_set": False,
}
if api["env_key"]:
raw = os.environ.get(api["env_key"], "")
entry["is_set"] = bool(raw)
result.append(entry)
return result
def save_api_keys(updates: dict[str, str]) -> dict:
"""Persist allowed API keys from a local operator request.
Values are accepted write-only: the response includes only configured flags.
"""
clean: dict[str, str] = {}
for key, value in updates.items():
env_key = str(key or "").strip().upper()
if env_key not in ALLOWED_ENV_KEYS:
continue
clean_value = str(value or "").strip()
if clean_value:
clean[env_key] = clean_value
if not clean:
return {"ok": False, "detail": "No supported API keys were provided."}
_write_env_values(OPERATOR_KEYS_ENV_PATH, clean)
try:
_write_env_values(ENV_PATH, clean)
except OSError:
# The persistent operator key file is the source of truth for Docker.
pass
for key, value in clean.items():
os.environ[key] = value
if "AIS_API_KEY" in clean:
try:
from services import ais_stream
ais_stream.API_KEY = clean["AIS_API_KEY"]
except Exception:
pass
if "OPENSKY_CLIENT_ID" in clean or "OPENSKY_CLIENT_SECRET" in clean:
try:
from services.fetchers import flights
flights.opensky_client.client_id = os.environ.get("OPENSKY_CLIENT_ID", "")
flights.opensky_client.client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "")
flights.opensky_client.token = None
flights.opensky_client.expires_at = 0
except Exception:
pass
try:
from services.config import get_settings
get_settings.cache_clear()
except Exception:
pass
return {
"ok": True,
"updated": sorted(clean.keys()),
"keys": get_api_keys(),
"env": get_env_path_info(),
}