Both Workers answer on one host, siahra-radar.co: siahra-web owns the Custom Domain (every
path), siahra-api is bound to the narrower route /api/* and therefore runs first for that
prefix. So a browser can see responses from either Worker on the same origin, and the header story
has to be coherent across the two.
The rule this file records: the host-level headers are identical on both Workers, and only the Content-Security-Policy differs — because CSP binds the document that loads resources, and an API response is never a document.
Headers are additive to the same-origin guard and the rate limiter in apps/api/src/router.ts
and apps/api/src/rateLimit.ts. They do not replace either, and neither was touched.
| Surface | Set in | Covers |
|---|---|---|
siahra-web static assets (index.html, /assets/*, /fonts/*, /aoi/** tracked files) |
apps/web/public/_headers → copied into dist/, read as configuration by Cloudflare's asset layer |
every asset response |
siahra-web Worker responses (R2 tiles, tile 404/405) |
apps/web/worker/index.ts (withSecurityHeaders) |
only what the Worker constructs itself |
siahra-api responses (JSON, radar PNG, 4xx/5xx) |
apps/api/src/securityHeaders.ts, applied at the single exit of createRouter() |
every response except the WebSocket 101 |
siahra-web deliberately has no run_worker_first (see the comment in
apps/web/wrangler.jsonc): navigation requests are answered by the asset layer and never reach
worker/index.ts. That is exactly why _headers is load-bearing for the page policy and cannot be
replaced by Worker code.
The Worker hands the asset-layer fallthrough back untouched. Adding a second
Content-Security-Policy on top of the one _headers already applied would make the browser
intersect the two policies — a silently narrower policy than either — and would leave a second copy
of the policy string free to drift from the file Cloudflare actually reads.
| Header | Value | Status | Note |
|---|---|---|---|
Strict-Transport-Security |
max-age=31536000 |
on, both Workers | max-age only — no includeSubDomains, no preload. Decided by the repository owner (docs/roadmap.md §4): other hostnames under the zone are not ours to speak for, and preload is effectively irreversible |
X-Content-Type-Options |
nosniff |
on, both | |
Referrer-Policy |
strict-origin-when-cross-origin |
on, both | |
X-Frame-Options |
DENY |
on, both | Redundant with frame-ancestors on modern browsers; kept for old ones |
Permissions-Policy |
accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=() |
on, both | The app asks for none of these |
Content-Security-Policy (web, documents) |
see below | on, enforcing | |
Content-Security-Policy (web Worker responses, api) |
default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none' |
on, enforcing | A tile or a JSON body is never a document |
Cross-Origin-Resource-Policy |
— | deliberately not set | same-origin would block a browser from rendering og-image.jpg inside a third-party link preview, which is the image's entire purpose |
CSP report-uri/report-to |
— | not set | There is no endpoint to receive reports; adding one is a separate task |
default-src 'self'; base-uri 'none'; object-src 'none'; frame-src https://challenges.cloudflare.com;
frame-ancestors 'none'; form-action 'none'; script-src 'self' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https://server.arcgisonline.com https://tiles.maps.eox.at https://telemetry.dwr.go.th
https://camera1.iticfoundation.org;
font-src 'self'; connect-src 'self' wss://siahra-radar.co https://telemetry.dwr.go.th https://camerai1.iticfoundation.org
https://streaming1.highwaytraffic.go.th https://streaming2.highwaytraffic.go.th;
worker-src 'self'; manifest-src 'self'; media-src blob: https://camerai1.iticfoundation.org
https://streaming1.highwaytraffic.go.th https://streaming2.highwaytraffic.go.th
script-src 'self'— the builtindex.htmlhas exactly one<script src>and no inline script. No'unsafe-eval': three.js compiles GLSL on the GPU, it does noteval. The one third-party script is Cloudflare Turnstile, below.- Cloudflare Turnstile (community report votes and submissions) —
https://challenges.cloudflare.cominscript-src(/turnstile/v0/api.js?render=explicit) andframe-src(the challenge iframe;frame-srcwas'none'until this).apps/web/src/lib/turnstile.tsinjects the script only when a user casts their first vote or opens the report form — never at startup, never on opening a report — and only in a build that hasVITE_TURNSTILE_SITE_KEY(a public key; without it the sheet says voting is not enabled, the form says reports are not open, and neither loads anything). The widget rendersinteraction-onlyinside the report sheet or the report form and yields one single-use token — for a vote it is posted to our own/api/v1/community/session, for a new report it travels inside the multipart/api/v1/community/reports— and is removed; siteverify happens in the api Worker, never in the browser. Noconnect-srcentry: the script talks to Cloudflare from inside its own iframe. Report photos are served same-origin from/api/v1/community/image/{id}, soimg-srcdid not change (the form's photo preview is ablob:URL, already allowed, and the compression worker is a same-origin module underworker-src 'self'). A photo leaves the browser only afterlib/compressImage.tsre-encodes it through a canvas, which writes pixels only, so EXIF/GPS is stripped;lib/imageMetadata.tsthen drops any APP1 /EXIF/XMPsegment the encoder itself added (Safari's JPEG encoder writes an Exif block with only pixel size and colour space), re-sniffs the output and refuses to send anything still carrying APP1/EXIF/XMP, and the api'svalidate.tsrejects such a file again (422 image-metadata). Not yet run under the enforcing policy (the dev server does not apply_headers) — that belongs to the next production check. style-srcneeds'unsafe-inline', and this is the one relaxation in the policy. React writes inlinestyleattributes inTopBar,MapLegend,AppShell,SideDrawer,TimelineBar,ForecastStrip,BottomDock,AlertToast,MapViewportandMobileSheet— every value that depends on live data (bar widths, marker offsets, colour ramps) is astyle={{…}}prop, so removing it means moving hundreds of computed values into CSS custom properties, which is a UI refactor, not a header change. A nonce does not help: nonces cover<style>elements, notstyleattributes. Splitting it asstyle-src 'self'+style-src-attr 'unsafe-inline'was rejected because Safari does not implementstyle-src-attrand falls back tostyle-src, which would break the layout there while looking clean in Chrome.img-src—data:/blob:for the share-image download (App.tsx) and the stitched basemap canvas; the two hosts are the basemap providers loaded as<img>byscene/SatelliteImagery.ts(Esri World Imagery and EOX Sentinel-2 cloudless). The camera hosts are listed below.connect-src 'self' wss://siahra-radar.co …— same-origin/api/*plus the earthquake WebSocket. CSP3 says'self'already matcheswss:on the same origin; the explicit host is belt and braces. The camera hosts are listed below.media-src blob: …— was'none'until E15.2, the first<video>in the app. hls.js feeds the<video>through Media Source Extensions, which the element loads from ablob:URL; Safari/iOS play the same playlist natively, which is a media load of the playlist host itself. Nothing else is allowed as media.- Camera hosts (E15 / E15.2 / E15.3) — one bullet per CSP-directive host in
CAMERA_SOURCES[id].hosts(packages/shared-types/src/cctv.ts);hosts.link(E15.3 PR D,bma-cctv→https://cpudapp.bangkok.go.th) is a link-out allowlist, not a CSP directive, and is deliberately absent from_headers—cameraSources.test.tsasserts that.streamDirective(kind)says which directive a stream kind needs;apps/web/src/lib/cameraSources.test.tsreadspublic/_headersand asserts every registry host appears under each of those directives, andlib/cameraSources.tsrefuses any stream URL whose origin is not in the registry (plushttps:only, no userinfo, the source'surlPattern). The browser contacts a host only after the user opens that camera's sheet, one camera at a time; the API never does, so none of these sources has a row in/api/v1/health(kind"browser"). A source removed at build time —VITE_FEATURE_CCTV=0for the whole layer, orVITE_FEATURE_CCTV_DISABLE=<id,…>per source (VITE_FEATURE_ITIC=0stays one release as an alias foritic-cctv) — fetches no catalogue, draws no marker, prints no credit and leaves its entries here inert.https://telemetry.dwr.go.th—dwr-cctv, inconnect-src(snapshot: the browser asks for one snapshot per click,GET /api/public/reportCctv/snapshot/{id}thenPOST /api/file/image/cctvfor the JPEG — DWR reflects our origin in CORS, checked 2026-09-26; shown from ablob:URL) andimg-src(live view:GET /api/public/cctv/mjpegStream?stnCode=…answersmultipart/x-mixed-replace, rendered as a plain<img src>, re-set every ~15 s because DWR closes the stream after ~11–24 s, stopped after 5 min,src = ""on close; the pixel-hash frame check reads the image back withcrossOrigin="anonymous"only because the build-time probe recordedcors: true).https://camerai1.iticfoundation.org—itic-cctv, inconnect-srcandmedia-src: the iTIC Foundation's HLS server for the road cameras (Department of Highways and partners; the camera list from Longdo is baked intopublic/cctv/itic-cctv.jsonat ETL time, so the Longdo host itself is never contacted by the browser). hls.js loads the playlist and.tssegments by XHR (Access-Control-Allow-Origin: *, checked 2026-09-26), one camera per click, one player per page; Safari/iOS load the playlist natively as media.https://camera1.iticfoundation.org—itic-cctv, inimg-srconly: still images for the few iTIC road cameras that have no usable HLS stream,GET /jpeg2.php?camid=10.8.0.{n}:{port}(the source'surlPattern— the onlyjpeg2.phpgroup that returned a real frame when probed on 2026-09-26; the placeholder,CAMPK…,61.91.182.114andjpeg.cgigroups return "not found", "No signal" or nothing and are never stored,apps/etl/src/build-itic-cctv.README.md). One camera, only after a click; re-requested every ~5 s with a cache-busting parameter while open, a request is given up after 15 s, the loop stops after 5 min,src = ""on close. The image is only displayed — nocrossOrigin, never drawn to a canvas — so nothing oncamera1is fetched by XHR or loaded as media.https://streaming1.highwaytraffic.go.thandhttps://streaming2.highwaytraffic.go.th—doh-cctv(E15.3 PR C), both inconnect-srcandmedia-src: the Department of Highways' own Wowza HLS hosts (apps/etl/src/build-doh-cctv.README.md), played exactly like iTIC — hls.js XHR for playlist +.tssegments (Access-Control-Allow-Origin: *on everystreaming1answer probed 2026-09-27), native media on Safari/iOS, one camera per click, one player per page.streaming2timed out from every probe vantage so far, so its streams ship dimmed; it is listed so they can play from a network that reaches it. Both servers send an incomplete certificate chain (leaf only) — browsers recover the Sectigo intermediate via AIA, which is why the ETL probe needsNODE_EXTRA_CA_CERTSand the browser needs nothing. Removable withVITE_FEATURE_CCTV_DISABLE=doh-cctv(the host strings then stay in the registry chunk but no request is made — the same accepted gap as E15.3 PR B).
worker-src 'self'—src/workers/*.worker.tsare bundled to same-origin URLs, not blobs. hls.js is created withenableWorker: false(src/lib/streams.ts), so its transmuxer runs on the main thread and never asks for ablob:worker; this directive did not change for E15.2.font-src 'self'— this is only possible because E4.1 moved the fonts intopublic/fonts/— today IBM Plex Sans Thai (Sarabun until E18.5) and IBM Plex Mono. Re-adding a Google Fonts<link>would forcefont-src/style-srcback open.
The policy was verified enforcing, in Chromium, against the production bundle: apps/web/dist
served through a Playwright route handler that applies the parsed public/_headers verbatim, on top
of the running dev server, so /aoi/**, /api/** and the WebSocket still went to the real backend.
- three.js map renders (terrain, buildings, satellite imagery, flood/low-lying overlays) — checked on provinces 10 and 50
- both web workers run (
buildingTiles,featureTiles) — building and feature geometry is on screen - WebSocket
/api/v1/earthquakes/livereachesreadyState === 1(OPEN) with the policy on /methodologyrenders in full- province switch and a radar layer toggle produce zero console messages
- control:
fetch('https://example.com/')from the page is refused byconnect-src, proving the policy is enforced rather than ignored
The 2026-08-19 run predates the E15.2 changes (img-src + https://telemetry.dwr.go.th, connect-src +
https://camerai1.iticfoundation.org, media-src from 'none' to blob: https://camerai1.iticfoundation.org).
QA on 2026-09-26 ran the production dist under the enforcing CSP: iTIC played through hls.js/MSE
(blob:), the DWR MJPEG live view ran with its canvas read, the WebSocket opened, and the app raised
zero violations. The later img-src addition of https://camera1.iticfoundation.org (iTIC still
images) has not been verified under the enforcing policy: that host times out from the network
the change was made on, so no frame could be loaded either way. The two Department of Highways hosts
added on 2026-09-27 (connect-src + media-src) have not been run under the enforcing policy either;
the dev server does not apply _headers, so that check belongs to the next production verification.
Known gap: whether Cloudflare's asset layer honours _headers in production could not be exercised
here (Vite ignores it, and there is no wrangler dev for the web Worker in this environment). It was
checked syntactically and through wrangler deploy --dry-run; confirm with curl -I https://siahra-radar.co/
after the next deploy.
Deviation from roadmap E4.2 AC 3: the CSP ships enforcing, not report-only for one release. Report-only was the safety net for "we cannot tell whether it breaks the app"; the harness above answers that question directly, and a report-only header would have been a security header that secures nothing while claiming the task is done.