HTTP security headers
All of these are set once, in next.config.mjs under headers(), for every route. Front Door passes them through and caches them with the body, so beta and go get the same set and there is nothing per host to configure.
| Header | Value | Why |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | Browsers that have seen it refuse plain http for a year. No preload: that is a one-way door for the whole apex. |
X-Content-Type-Options | nosniff | No MIME sniffing. |
X-Frame-Options | SAMEORIGIN | Legacy twin of frame-ancestors below. |
Referrer-Policy | strict-origin-when-cross-origin | Cross-origin requests see the origin only, never a camera id in a query string. |
Permissions-Policy | geolocation=(self), microphone=(self), camera=(), payment=(), usb=() | The page itself keeps location (map locate, near-me list) and the microphone (voice search, what3words voice input). Nothing embedded gets any of them. |
Content-Security-Policy | see below | Production builds only. |
Reporting-Endpoints | csp-endpoint="/api/csp-report" | Where the browser posts CSP violations. |
X-Powered-By is switched off (poweredByHeader: false).
Content-Security-Policy
Production builds only (NODE_ENV === 'production' when next build runs). next dev injects scripts and styles the policy would refuse, and local development gains nothing from it. Development builds send frame-ancestors 'self' alone.
Every origin below is one the app really loads from. The inventory was taken from the code, the environment files, the ArcGIS and Peak Finder libraries and the service worker.
| Directive | Value | What needs it |
|---|---|---|
default-src | 'self' | Fallback for anything not listed. |
base-uri | 'self' | No <base> hijack. |
object-src | 'none' | No plugins. |
frame-ancestors | 'self' | Nobody else may iframe the app. Add a partner origin here if an embed is ever agreed. |
form-action | 'self' https://alertcalifornia.ciamlogin.com | Sign-in is a form POST to /api/auth/signin/azure-ad that redirects to Entra; Chromium checks form-action against the redirect target too. |
script-src | 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://js.arcgis.com https://www.peakfinder.com https://wasm.peakfinder.com https://storage.googleapis.com | 'unsafe-inline': Next's inline bootstrap scripts (a nonce needs a global middleware; proxy.ts only matches /api/*). 'wasm-unsafe-eval': ArcGIS and Peak Finder WebAssembly. blob:: the Peak Finder multithread engine is fetched from wasm.peakfinder.com and executed from a blob URL; ArcGIS creates its workers the same way. js.arcgis.com: ArcGIS 5 assetsPath default, workers and assets. www.peakfinder.com: the SDK loader. storage.googleapis.com: Workbox, which public/sw.js pulls with importScripts. |
worker-src | 'self' blob: | The service worker and the ArcGIS / Peak Finder blob workers. |
style-src | 'self' 'unsafe-inline' https://js.arcgis.com | MUI / Emotion and ArcGIS inject <style> elements; ArcGIS web components load CSS from the CDN. |
font-src | 'self' data: https://js.arcgis.com https://*.arcgis.com | next/font is self-hosted; ArcGIS vector tiles and components fetch fonts from static.arcgis.com and the CDN. |
img-src | 'self' data: blob: https: | Camera frames come from the static image domain, blob storage, Front Door and vendor hosts; tiles from many ArcGIS hosts; share images; the Entra profile picture. |
media-src | 'self' data: blob: https: | AI detection video clips from blob storage. |
connect-src | 'self' data: blob: https: wss: | BFF, Frontend.API, Azure SignalR (wss://*.service.signalr.net after negotiate), ArcGIS services and the Velocity stream (wss://us4-iot.arcgis.com), Application Insights ingestion and config, what3words, Peak Finder data, frames fetched for canvases. |
frame-src | 'self' | The archive viewer is an iframe of /api/archive/.... CONDOR and Survey123 open in new tabs, which CSP does not govern. |
manifest-src | 'self' | The PWA manifest. |
report-uri / report-to | /api/csp-report | Violations are posted here and logged as warnings. |
Reading violations
pages/api/csp-report.ts logs every report with the blocked URL, the directive, the source file and line, and the user agent. The frontend logger writes one JSON document per line into the trace message, so the fields are parsed out of Message.
In the Log Analytics workspace (azw2-law-wfca-alertca-prd-001, which is where the portal's Logs blade on the workspace and the az monitor log-analytics query CLI both land):
AppTraces
| where TimeGenerated > ago(7d)
| where AppRoleName has "frontend"
| where Message has "CSP violation"
| extend m = parse_json(Message)
| summarize count(), sample = any(tostring(m.documentUri))
by directive = tostring(m.directive), blockedUri = tostring(m.blockedUri)
| order by count_ desc
From a terminal, with the prd subscription selected:
az monitor log-analytics query -w f0a32dc3-cad6-4dbc-8949-9efd6154f04a --analytics-query 'AppTraces | where TimeGenerated > ago(7d) | where AppRoleName has "frontend" | where Message has "CSP violation" | extend m = parse_json(Message) | summarize count() by directive = tostring(m.directive), blockedUri = tostring(m.blockedUri) | order by count_ desc' -o table
On the Application Insights component blade instead, the table is traces and the JSON sits in message; swap those two names and the rest is the same.
Check this after every deploy that adds a library or a data source. A violation from a host the app needs means the policy is missing that host, not that the host is hostile.
Adding an origin
- Find which directive the browser named (
effective-directivein the report). - Add the host to that directive in
next.config.mjs, with a comment saying what loads from it. - Add a row here.
Do not widen script-src, worker-src, style-src, frame-src, object-src or base-uri to a wildcard. Those are the directives that make the policy worth having.