Security audit (5 phases) → reports under _docs/05_security/. AZ-501 (F-SAST-1, HIGH): Externalize hardcoded Google Geocode key from mission-planner/src/config.ts to VITE_GOOGLE_GEOCODE_KEY via new GeocodeService.ts; fail-soft warn when unset; STC-SEC1D static deny-list gate; +5 unit tests in tests/mission_planner_geocode.test.ts. AZ-502 (F-DEP-1, HIGH): Force vite>=6.4.2 and postcss>=8.5.10 via package.json overrides in both roots; clean reinstall clears all bun audit advisories. Test-spec sync (Step 12) + Update Docs (Step 13) deltas: AC-43, AC-44, NFT-SEC-09b, FT-P-61, FT-N-17, ripple log, batch_12 report. Pending user actions: revoke Google + OWM keys (AC-6 / AZ-499 AC-7). 229 PASS / 13 SKIP / 0 FAIL on static + fast suites. Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
Security Approach — Azaion UI
Output of
/documentStep 6e. Retrospective security view of the SPA grounded in code (src/auth/AuthContext.tsx,src/api/client.ts,src/api/sse.ts), config (nginx.conf,Dockerfile,.woodpecker/build-arm.yml), and the verified architecture (_docs/02_document/architecture.md§ 7). Every claim cites its evidence.
Status: synthesised-from-verified-docs (Step 6e — /document)
Date: 2026-05-10
Threat model summary
The UI is operator-internal, not public. The trust model is:
- Trusted: the suite services (reached via nginx reverse-proxy on the
same origin); the suite's identity provider (
admin/); the operator's authenticated browser session. - Untrusted: the browser itself (XSS-resistant design — bearer in memory only); operator network if not on the suite VPN; OpenWeatherMap (currently exfiltrated to via a hardcoded key — finding); OSM tile servers (read-only third-party).
Primary threats considered: token theft via XSS; CSRF via cookie auto-attach; bearer leakage via SSE query string; secret leakage in bundle; privilege escalation via missing client-side route gates; clickjacking / framing.
1. Authentication
Login
POST /api/admin/auth/loginwith{ email, password }.admin/service responds with:- Bearer JWT in the response body — held in
AuthContextmemory only (never written tolocalStorage/sessionStorage, P3). Secure HttpOnly SameSite=Strictrefresh cookie — issued by server, scoped to the suite origin.
- Bearer JWT in the response body — held in
Source: src/auth/AuthContext.tsx; architecture.md § 7.
Session bootstrap (cold load)
- On mount,
AuthContextattemptsGET /api/admin/auth/refreshto obtain a new bearer. - Bug: this call is missing
credentials:'include'— the HttpOnly refresh cookie is NOT sent → cold-load refresh fails → user is redirected to/logineven with a valid cookie. Step 4 fix candidate.
Source: src/auth/AuthContext.tsx:24; 04_verification_log.md F2.
401-retry path
- The
01_api-transportclient.tswraps every authenticatedfetch. On 401 it issuesPOST /api/admin/auth/refreshwithcredentials:'include', replaces the bearer inAuthContext, and retries the original request. - This path is correct and is the working refresh mechanism today.
Source: src/api/client.ts:44; 04_verification_log.md F2.
Logout
POST /api/admin/auth/logout— clears the bearer in memory; server invalidates the refresh cookie.
Source: src/auth/AuthContext.tsx.
Pre-port (legacy WPF)
- The WPF-era encrypted-creds command-line handoff (binary-split key fragments + DPAPI) is intentionally not ported — the browser cannot participate in that handoff and the suite identity infrastructure now lives server-side. P8.
Source: _docs/legacy/wpf-era.md §11.
2. Authorization
- RBAC is server-enforced — every authenticated endpoint validates
User.role+permissions[]server-side. - The UI inspects
AuthUser.roleto render or hide nav links and pages, but does NOT treat the result as a security gate. - Browser is treated as untrusted; every action confirms with the server.
Findings
/adminroute lacks a client-side role-gate (PRIORITY — security finding, AC-22). Server-side 403 IS the authoritative gate, but a non-admin user navigating to/admintoday sees the broken admin UI flicker before the server rejects requests. Step 4 / Step 8 fix./settingsroute gate is more nuanced — there is no explicitSETTINGSpermission code in the suite spec; gating relies on server-side 403. Treat as a soft gate (don't expose the link in the Header for non-admins) rather than a hard redirect.
Source: architecture.md § 7 Authorization; App.tsx.
3. Token handling
| Token | Lifetime | Where it lives | Where it appears on the wire |
|---|---|---|---|
| Bearer JWT | Short (server-issued; refreshed on 401) | AuthContext React state — memory only |
Authorization: Bearer ${token} header on every authenticated fetch; ?token=${bearer} query string on SSE (ADR-008) |
| Refresh token | Long (server-issued) | Secure HttpOnly SameSite=Strict cookie — never accessible to JS |
Cookie header on POST /api/admin/auth/refresh (and the broken bootstrap GET — Step 4 fix) |
X-Refresh-Token header |
Per-request (long-running video detect) | passed in by 01_api-transport for endpoints that need it |
X-Refresh-Token: ${value} per _docs/10_auth.md. Currently NOT sent on POST /api/detect/${mediaId} for video — long videos can blow the access-token TTL → silent failure. Step 4 fix candidate (finding #29). |
Key invariants (P3)
- Bearer is never written to
localStorage/sessionStorage/ IndexedDB. - Refresh token is never read from JS —
HttpOnlyenforces this. - Code-search regression test: zero matches for
localStorage|sessionStoragetouching the bearer token insrc/.
4. SSE bearer-in-query-string
EventSource cannot send arbitrary headers, so src/api/sse.ts passes the
bearer in the URL: ?token=${bearer}.
Trade-offs
- Bearer is short-lived — minimises window of compromise.
- HTTPS encrypts the URL on the wire — but the URL still appears in:
- nginx access logs (mitigation: log redaction at the nginx layer — Step 6 surface; not configured today).
- Browser history (low risk for SSE URLs, but document).
- Refresh-rotation breaks open SSE connections — the URL was created with the old bearer; no reconnect logic exists today (Step 8 hardening — AC-24).
Source: src/api/sse.ts; ADR-008; architecture.md § 7.
5. Secrets management
Hardcoded OpenWeatherMap API key — P10 violation
- File:
mission-planner/src/utils/flightPlanUtils.ts:60 - Value: a 32-char hex key shipped in the production bundle.
- Risk: anyone with access to the bundle can extract and reuse the key (rate-limit theft; provider account abuse). The key is committed to git history.
- Fix sequence (Step 4 / Phase B):
- Rotate the key at OpenWeatherMap (out-of-band, user action).
- Move to env —
import.meta.env.VITE_OPENWEATHERMAP_API_KEYread at build time (interim). - Proxy via suite — long-term, route the wind compute through
flights/so no key ever reaches the browser (preferred; perarchitecture.md§ Architecture Vision Open Questions item 8).
Hardcoded Google Geocode API key — discovered cycle 2 audit (AZ-501)
- File:
mission-planner/src/config.ts:2(originally — extracted tomission-planner/src/services/GeocodeService.tsby AZ-501). - Production-bundle exposure: NONE.
mission-planner/is a port-source not built intodist/(AC-31/STC-S5). - Git-history exposure: HIGH — same threat class as the OWM key.
- Closed cycle 2 by AZ-501: env-resolved via
VITE_GOOGLE_GEOCODE_KEY, fail-soft + singleconsole.warnwhen unset, defended bySTC-SEC1D(literal scan acrosssrc/+mission-planner/). The/documentStep 6e retrospective missed this because mission-planner/ was treated as out-of- scope (port-source) — the security audit (_docs/05_security/) caught it via a broader source-tree grep, demonstrating the value of a separate audit pass. - Manual deliverable PENDING USER: revoke the key at the Google Cloud Console (AZ-501 AC-6).
Other secrets
- No other hardcoded keys in
src/per Grep audit at Step 4 + cycle-2 security-audit (_docs/05_security/static_analysis.md). - Suite service URLs are not secrets (they are docker-network hostnames).
- The bearer is the only sensitive value in browser memory, and it is short-lived.
Source: P10; architecture.md § Architecture Vision; finding (security);
_docs/05_security/security_report.md F-SAST-1.
6. CORS, cookie scope, CSRF
- Same-origin via nginx: the SPA is served by the same nginx that
reverse-proxies
/api/<service>/. The browser sees a single origin → cookies scope cleanly; CORS preflight is unnecessary for the suite endpoints. credentials:'include'is required on every authenticatedfetchfor the HttpOnly refresh cookie to flow. The 401-retry path (api/client.ts:44) is correct; the bootstrap refresh (AuthContext.tsx:24) is broken.- CSRF:
SameSite=Stricton the refresh cookie + bearer-in-header on authenticated requests. The bearer header cannot be auto-attached by a cross-origin form submit. No additional CSRF token is used today — the architecture pattern (header-based bearer + SameSite=Strict cookie) obviates it.
Source: src/api/client.ts; nginx.conf; architecture.md § 7.
7. Input validation
- Server is authoritative. The UI does not duplicate validation logic it cannot guarantee.
- Numeric inputs in
09_settingsuseparseInt(v) || 0— clearing a field silently writes0(finding B4, AC-26). Step 4 fix. - File upload:
react-dropzonefilters by MIME / extension client-side; the server is authoritative on virus scanning and size enforcement (client_max_body_size 500M). - Annotation save body must include
Source,WaypointId,videoTime(currently incomplete — finding #32). The wire format is validated by theannotations/service.
Source: 09_settings/SettingsPage.tsx; 06_annotations/MediaList.tsx;
nginx.conf; finding B4 / #32.
8. Output encoding / XSS surface
- React 19 escapes JSX text by default — string content is safe.
dangerouslySetInnerHTMLis not used insrc/(Grep audit).HelpModalships hardcoded English strings inline — XSS-safe but P6 violation (i18n).- Tainted-canvas risk on annotation download (
AnnotationsPage.handleDownloadfinding) — cross-origin image data may taint the canvas; the download silently fails. Pure UX bug, not a security defect, but flagged.
Source: 06_annotations/AnnotationsPage.tsx; HelpModal.tsx.
9. Headers / hardening at the nginx layer
Currently configured
- nginx serves
dist/and reverse-proxies/api/<service>/to suite services. client_max_body_size 500M.
Currently MISSING (Step 6 surface)
Content-Security-Policy— no CSP header. Recommended starting point:default-src 'self'; img-src 'self' https: data:; connect-src 'self' https://api.openweathermap.org/ https://*.tile.openstreetmap.org/; frame-ancestors 'none'; object-src 'none'.X-Frame-Options: DENY(or covered by CSPframe-ancestors) — clickjacking protection.Referrer-Policy: strict-origin-when-cross-origin.Strict-Transport-Security— depends on suite ingress; document the expected value.X-Content-Type-Options: nosniff.- Bearer-redaction in nginx access logs for SSE URLs.
These are nginx config additions (server-side), not SPA changes — but the SPA depends on them for hardening. Track at suite level.
Source: nginx.conf; architecture.md § 7 row "Cross-site / clickjack".
10. Audit logging
- Server-side concern — the
admin/,flights/,annotations/, etc. services are responsible for audit-event emission. - The SPA does not emit audit events directly. It does not maintain any client-side audit log.
- The browser console is the only client-side log surface today; no
centralized client telemetry (Step 6 surface —
_docs/02_document/deployment/observability.md).
Source: architecture.md § 7 Audit logging.
11. Image / supply-chain
Currently in pipeline
- Multi-stage Dockerfile:
oven/bun:1.3.11-alpine(build) →nginx:alpine(runtime). bun install --frozen-lockfileenforces lockfile fidelity.AZAION_REVISION=$CI_COMMIT_SHAand OCI labels stamped at push time.
Currently MISSING (Step 6 surface)
- No vulnerability scan (Trivy / Grype) on the produced image.
- No SBOM emission (Syft / cyclonedx).
- No image signing (cosign).
- No dependency audit step in CI (
bun auditequivalent — Bun does not yet have a first-party audit;npm audit --omit=devagainst the lockfile is a reasonable substitute).
Source: .woodpecker/build-arm.yml; architecture.md § 3 "Missing from the
pipeline today".
12. Findings → Fix Map
| Finding | AC | Fix step |
|---|---|---|
Bootstrap refresh missing credentials:'include' (F2) |
AC-01 | Step 4 (Code Testability Revision) |
| Bearer-in-query SSE — refresh-rotation breaks subscription | AC-24 | Step 8 (Refactor — optional) or Phase B |
| Hardcoded OpenWeatherMap key (P10) | AC-20 | Step 4 (env move); Phase B (suite proxy) |
/admin route lacks role-gate |
AC-22 | Step 4 |
09_settings numeric input writes 0 on empty |
AC-26 | Step 4 |
09_settings save handlers leak saving:true on PUT failure |
AC-27 | Step 4 |
AdminPage.handleDeleteClass lacks ConfirmDialog |
AC-30 | Step 4 |
MediaList uses alert() |
AC-14 | Step 4 |
ConfirmDialog lacks aria-modal/role=dialog |
AC-15 | Step 4 / Step 8 |
| Header dropdown lacks combobox/expanded/Esc/focus-trap | AC-16 | Step 4 / Step 8 |
Annotation save body missing Source, WaypointId, wrong time field |
AC-05 | Step 4 |
X-Refresh-Token not sent on long-video detect (#29) |
— | Step 4 |
Numeric enum drift (AnnotationStatus, MediaStatus, Affiliation, CombatReadiness) |
AC-04 | Step 4 (P9 alignment) |
No CSP / hardening headers in nginx.conf |
— | Step 6 — track at suite level (cycle-2 audit F-INF-2 → Phase B) |
| No vulnerability scan / SBOM / image signing in CI | — | Phase B (cycle-2 audit F-INF-3 / F-INF-4) |
| Vite ≤ 6.4.1 + PostCSS < 8.5.10 — published CVEs (HIGH/MOD) | AC-44 | Closed cycle 2 by AZ-502 (bun update vite + package.json overrides) |
Hardcoded Google Geocode API key in mission-planner/ port-source |
AC-43 | Closed cycle 2 by AZ-501; manual key revocation PENDING USER |
13. Compliance / standards
The UI does NOT claim conformance to any specific standard today:
- No WCAG-level declaration (multiple a11y findings recorded).
- No SOC2 / ISO27001 controls are implemented at the SPA layer (server-side concern of the suite).
- No FIPS / specific crypto-mode requirements at the SPA layer (TLS is terminated server-side; bearer JWT signing is server-side).
These are recorded as anti-criteria (AC-N4) — the UI is internal, operator-only, and trusts the suite for compliance enforcement. Phase B may revisit if a regulated deployment surface emerges.