Files
Shadowbroker/frontend/src/lib/sentinelHub.ts
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

374 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Sentinel Hub (Copernicus CDSE) — client-side token + Process API tile fetcher.
*
* Issue #298 (tg12): Credentials are now stored server-side in the backend
* ``.env`` (managed through the existing ``/api/settings/api-keys`` flow,
* same as every other third-party API key). The browser no longer holds
* ``client_id`` / ``client_secret`` in localStorage or sessionStorage and
* no longer forwards them in proxy requests.
*
* Old browser-storage keys (``sb_sentinel_client_id`` / ``sb_sentinel_client_secret``
* / ``sb_sentinel_instance_id``) are migrated out by ``SettingsPanel`` on
* first mount after the upgrade — see ``migrateLegacySentinelBrowserKeys()``
* exported below.
*/
import { API_BASE } from '@/lib/api';
// Token exchange proxied through our backend (Copernicus blocks browser CORS).
const TOKEN_PROXY_URL = `${API_BASE}/api/sentinel/token`;
// In-memory token cache (never persisted)
let cachedToken: string | null = null;
let tokenExpiry = 0;
// Dedup: only one in-flight token request at a time
let _tokenPromise: Promise<string | null> | null = null;
// In-memory cache of "does the backend have Sentinel credentials configured?"
// so the rest of the UI can short-circuit tile load attempts without a server
// round-trip per tile. Refreshed by callers via `refreshSentinelStatus()`.
let _backendCredentialsConfigured: boolean | null = null;
let _backendStatusPromise: Promise<boolean> | null = null;
// ─── Credential status (server-side) ───────────────────────────────────────
/**
* Ask the backend whether Sentinel credentials are configured in ``.env``.
* Caches the result in memory; call ``refreshSentinelStatus()`` after the
* operator saves new API keys in the settings panel.
*
* Returns ``false`` on network errors so the UI fails safely (no broken
* tile requests). Never returns the secret itself — that stays server-side.
*/
export async function checkBackendSentinelStatus(): Promise<boolean> {
if (_backendCredentialsConfigured !== null) return _backendCredentialsConfigured;
if (_backendStatusPromise) return _backendStatusPromise;
_backendStatusPromise = (async () => {
try {
const resp = await fetch(`${API_BASE}/api/settings/api-keys`, {
headers: { Accept: 'application/json' },
});
if (!resp.ok) return false;
const list = await resp.json();
// /api/settings/api-keys returns an array of { id, env_key, is_set, ... }
const ids = new Set(['sentinel_client_id', 'sentinel_client_secret']);
const configured = Array.isArray(list)
&& list.filter((row: { id?: string; is_set?: boolean }) =>
row && row.id && ids.has(row.id) && row.is_set === true,
).length === 2;
_backendCredentialsConfigured = configured;
return configured;
} catch {
_backendCredentialsConfigured = false;
return false;
} finally {
_backendStatusPromise = null;
}
})();
return _backendStatusPromise;
}
/** Invalidate the cached status — call this after the API Keys panel saves. */
export function refreshSentinelStatus(): void {
_backendCredentialsConfigured = null;
// Drop any cached token too — credentials may have changed.
cachedToken = null;
tokenExpiry = 0;
}
/**
* Synchronous getter — returns the last known status without a network call.
* Returns ``null`` until ``checkBackendSentinelStatus()`` has run at least once.
*/
export function getCachedSentinelStatus(): boolean | null {
return _backendCredentialsConfigured;
}
/**
* Back-compat shim. Pre-#298 callers asked ``hasSentinelCredentials()`` to
* decide whether to render the Sentinel layer / open the API key prompt.
* The credential now lives server-side, so this is just the cached
* server-status check. Returns ``false`` until the first
* ``checkBackendSentinelStatus()`` resolves (callers should kick that off
* once at app startup — see ``page.tsx`` mount effect).
*/
export function hasSentinelCredentials(): boolean {
return _backendCredentialsConfigured === true;
}
/**
* One-time migration helper: clear the legacy browser-storage keys that
* pre-#298 versions used to persist Sentinel credentials. Idempotent and
* safe to call on every page load — does nothing if no keys are present.
*
* Called by ``SettingsPanel`` on mount. We do NOT auto-POST the legacy
* browser values to the backend, because doing so would silently migrate
* a secret across a trust boundary without operator consent. Operators
* who relied on browser-stored credentials will re-enter them once in
* the API Keys panel, and the legacy keys get wiped here.
*/
export function migrateLegacySentinelBrowserKeys(): { cleared: string[] } {
if (typeof window === 'undefined') return { cleared: [] };
const legacy = [
'sb_sentinel_client_id',
'sb_sentinel_client_secret',
'sb_sentinel_instance_id',
];
const cleared: string[] = [];
for (const key of legacy) {
try {
if (window.localStorage?.getItem(key) !== null) {
window.localStorage.removeItem(key);
cleared.push(key);
}
} catch { /* ignore quota / privacy mode errors */ }
try {
if (window.sessionStorage?.getItem(key) !== null) {
window.sessionStorage.removeItem(key);
if (!cleared.includes(key)) cleared.push(key);
}
} catch { /* ignore */ }
}
return { cleared };
}
// ─── OAuth2 token ──────────────────────────────────────────────────────────
/**
* Fetch an OAuth2 access token using the client_credentials grant.
* Caches in memory; auto-refreshes 30 s before expiry.
*
* The request body NO LONGER carries client_id/secret — the backend
* resolves credentials from its ``.env`` via the API Keys flow. The
* server-side proxy still accepts body credentials for legacy callers,
* but the dashboard does not supply them.
*/
export function getSentinelToken(): Promise<string | null> {
// Return cached token if still valid (with 30 s margin)
if (cachedToken && Date.now() < tokenExpiry - 30_000) return Promise.resolve(cachedToken);
// Dedup: reuse in-flight request so 20 tiles don't each trigger a token fetch
if (_tokenPromise) return _tokenPromise;
_tokenPromise = (async () => {
try {
const resp = await fetch(TOKEN_PROXY_URL, {
method: 'POST',
// Backend resolves credentials from env. Empty body = "use server-side".
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({}),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Sentinel Hub token request failed (${resp.status}): ${text}`);
}
const data = await resp.json();
cachedToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in ?? 300) * 1000;
return cachedToken;
} finally {
_tokenPromise = null;
}
})();
return _tokenPromise;
}
/** Synchronous getter — returns the current cached token or null. */
export function getCachedSentinelToken(): string | null {
if (cachedToken && Date.now() < tokenExpiry - 5_000) return cachedToken;
return null;
}
// ─── Tile fetcher (proxied through backend) ───────────────────────────────
const TILE_PROXY_URL = `${API_BASE}/api/sentinel/tile`;
/**
* Fetch a single 256×256 tile via backend proxy to Sentinel Hub Process API.
* Returns a PNG ArrayBuffer or null on failure.
*
* Body no longer carries client_id/secret — the backend uses .env values.
*/
export async function fetchSentinelTile(
z: number,
x: number,
y: number,
preset: string,
date: string,
): Promise<ArrayBuffer | null> {
const resp = await fetch(TILE_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset, date, z, x, y }),
});
if (!resp.ok) return null;
return resp.arrayBuffer();
}
// ─── MapLibre protocol registration ───────────────────────────────────────
let _protocolRegistered = false;
/**
* Register the `sentinel://` custom protocol with MapLibre.
* Tile URLs look like: sentinel://z/x/y?preset=TRUE-COLOR&date=2024-06-01
*
* Call once at app startup or before adding the Sentinel source.
*/
export function registerSentinelProtocol(maplibregl: {
addProtocol: (
name: string,
handler: (
params: { url: string },
abortController: AbortController,
) => Promise<{ data: ArrayBuffer }>,
) => void;
}): void {
if (_protocolRegistered) return;
_protocolRegistered = true;
maplibregl.addProtocol('sentinel', async (params: { url: string }) => {
// Parse: sentinel://14/8529/5765?preset=TRUE-COLOR&date=2024-06-01
const url = new URL(params.url.replace('sentinel://', 'http://dummy/'));
const parts = url.pathname.split('/').filter(Boolean);
const z = parseInt(parts[0], 10);
const x = parseInt(parts[1], 10);
const y = parseInt(parts[2], 10);
const preset = url.searchParams.get('preset') || 'TRUE-COLOR';
const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
tileLoadStart();
try {
const data = await fetchSentinelTile(z, x, y, preset, date);
if (!data) {
return { data: TRANSPARENT_1X1_PNG };
}
recordTileFetch();
return { data };
} finally {
tileLoadEnd();
}
});
}
// 1×1 transparent PNG (68 bytes)
const TRANSPARENT_1X1_PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00,
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]).buffer;
/**
* Build a sentinel:// tile URL template for MapLibre.
* MapLibre will substitute {z}, {x}, {y} at render time.
*/
export function buildSentinelTileUrl(preset: string, date: string): string {
return `sentinel://{z}/{x}/{y}?preset=${encodeURIComponent(preset)}&date=${encodeURIComponent(date)}`;
}
// ─── Layer presets ─────────────────────────────────────────────────────────
export const SENTINEL_PRESETS = [
{ id: 'TRUE-COLOR', name: 'True Color (S2)', description: 'Natural color RGB' },
{ id: 'FALSE-COLOR', name: 'False Color IR', description: 'Vegetation analysis' },
{ id: 'NDVI', name: 'NDVI', description: 'Vegetation index' },
{ id: 'MOISTURE-INDEX', name: 'Moisture Index', description: 'Soil/vegetation moisture' },
] as const;
export type SentinelPresetId = (typeof SENTINEL_PRESETS)[number]['id'];
// ─── Usage tracking ───────────────────────────────────────────────────────
const LS_USAGE_KEY = 'sb_sentinel_usage';
interface SentinelUsage {
month: string; // "2026-03"
tiles: number;
pu: number; // tiles * 0.25
}
function currentMonth(): string {
return new Date().toISOString().slice(0, 7);
}
export function getSentinelUsage(): SentinelUsage {
if (typeof window === 'undefined') return { month: currentMonth(), tiles: 0, pu: 0 };
try {
const raw = localStorage.getItem(LS_USAGE_KEY);
if (raw) {
const data = JSON.parse(raw) as SentinelUsage;
// Reset if month changed
if (data.month === currentMonth()) return data;
}
} catch { /* ignore */ }
return { month: currentMonth(), tiles: 0, pu: 0 };
}
export function recordTileFetch(count = 1): void {
const usage = getSentinelUsage();
usage.tiles += count;
usage.pu = Math.round(usage.tiles * 0.25 * 100) / 100;
usage.month = currentMonth();
localStorage.setItem(LS_USAGE_KEY, JSON.stringify(usage));
}
// ─── First-time flag ──────────────────────────────────────────────────────
const LS_SENTINEL_SEEN = 'sb_sentinel_info_seen';
export function hasSentinelInfoBeenSeen(): boolean {
if (typeof window === 'undefined') return true;
return localStorage.getItem(LS_SENTINEL_SEEN) === 'true';
}
export function markSentinelInfoSeen(): void {
localStorage.setItem(LS_SENTINEL_SEEN, 'true');
}
// ─── Tile loading tracker ────────────────────────────────────────────────
type LoadingListener = (inflight: number, loaded: number) => void;
let _inflight = 0;
let _loaded = 0;
let _listeners: LoadingListener[] = [];
/** Subscribe to tile loading state changes. Returns unsubscribe function. */
export function onTileLoadingChange(cb: LoadingListener): () => void {
_listeners.push(cb);
return () => { _listeners = _listeners.filter(l => l !== cb); };
}
function _notifyListeners() {
for (const cb of _listeners) cb(_inflight, _loaded);
}
export function tileLoadStart(): void {
_inflight++;
_notifyListeners();
}
export function tileLoadEnd(): void {
_inflight = Math.max(0, _inflight - 1);
_loaded++;
_notifyListeners();
}
export function resetTileLoading(): void {
_inflight = 0;
_loaded = 0;
_notifyListeners();
}
export function getTileLoadingState(): { inflight: number; loaded: number } {
return { inflight: _inflight, loaded: _loaded };
}