# Security Approach — Azaion UI > Output of `/document` Step 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/login` with `{ email, password }`. - `admin/` service responds with: - **Bearer JWT** in the response body — held in `AuthContext` memory only (never written to `localStorage` / `sessionStorage`, P3). - **`Secure HttpOnly SameSite=Strict` refresh cookie** — issued by server, scoped to the suite origin. Source: `src/auth/AuthContext.tsx`; `architecture.md` § 7. ### Session bootstrap (cold load) - On mount, `AuthContext` attempts `GET /api/admin/auth/refresh` to 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 `/login` even 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-transport` `client.ts` wraps every authenticated `fetch`. On 401 it issues `POST /api/admin/auth/refresh` **with** `credentials:'include'`, replaces the bearer in `AuthContext`, 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.role` to 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 - **`/admin` route 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 `/admin` today sees the broken admin UI flicker before the server rejects requests. **Step 4 / Step 8 fix.** - **`/settings` route gate is more nuanced** — there is no explicit `SETTINGS` permission 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 — `HttpOnly` enforces this. - Code-search regression test: zero matches for `localStorage|sessionStorage` touching the bearer token in `src/`. --- ## 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)**: 1. **Rotate** the key at OpenWeatherMap (out-of-band, user action). 2. **Move to env** — `import.meta.env.VITE_OPENWEATHERMAP_API_KEY` read at build time (interim). 3. **Proxy via suite** — long-term, route the wind compute through `flights/` so no key ever reaches the browser (preferred; per `architecture.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 to `mission-planner/src/services/GeocodeService.ts` by AZ-501). - **Production-bundle exposure**: NONE. `mission-planner/` is a port-source not built into `dist/` (`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 + single `console.warn` when unset, defended by `STC-SEC1D` (literal scan across `src/` + `mission-planner/`). The `/document` Step 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//`. The browser sees a single origin → cookies scope cleanly; CORS preflight is unnecessary for the suite endpoints. - **`credentials:'include'`** is required on every authenticated `fetch` for 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=Strict` on 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_settings`** use `parseInt(v) || 0` — clearing a field silently writes `0` (finding B4, AC-26). Step 4 fix. - **File upload**: `react-dropzone` filters 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 the `annotations/` 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. - **`dangerouslySetInnerHTML`** is **not used** in `src/` (Grep audit). - **`HelpModal`** ships hardcoded English strings inline — XSS-safe but P6 violation (i18n). - **Tainted-canvas** risk on annotation download (`AnnotationsPage.handleDownload` finding) — 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//` 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 CSP `frame-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-lockfile` enforces lockfile fidelity. - `AZAION_REVISION=$CI_COMMIT_SHA` and 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 audit` equivalent — Bun does not yet have a first-party audit; `npm audit --omit=dev` against 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.