Add Security Headers to Static Site
Background (from the session that started this one): Ward is a free, open-source anti-cyberbullying app for children (Hacktoberfest DEV entry, public MIT repo). Its web demo is a Render static site that runs entirely in the visitor's browser (Ward Lite ONNX model + word lists); the optional Gemma demo calls a relay on a separate Render web service. An audit found the site has no security headers; the owner asked for them in a parallel session while the main session runs a DigitalOcean GPU job. The owner prefers committing verified changes straight to main, no branches.
Task:
Add security headers to Ward's live static site on Render (https://ward-w8cd.onrender.com, Render service srv-dbj3vomc6qcc73bdg33g in workspace tea-davp1jm7bikc73etlheg; the repo is this checkout, /Users/haider/Desktop/Hacktoberfest 26/ward, pushed straight to main).
Headers to set: a strict Content-Security-Policy that still lets the app work (it loads its own JS, CSS, fonts, the Ward Lite model under /models/ and ONNX Runtime wasm under /ort/ — wasm needs 'wasm-unsafe-eval' in script-src; the web demo POSTs to https://ward-discord.onrender.com/gemma and may probe http://127.0.0.1:11434 for a local Ollama, so connect-src needs 'self', that relay, and the localhost Ollama address; check apps/web/src for any other fetches, workers, blob: or data: use before finalising), X-Frame-Options: DENY plus frame-ancestors 'none', Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, and a restrictive Permissions-Policy (camera stays allowed for self, since counsellor mode scans QR codes). The case viewer /ward-viewer.html already has its own meta CSP; make sure the site header doesn't break it (it uses inline script and style), e.g. with a path-specific header.
How: the Render MCP connector can't set static-site headers, so add them to render.yaml as a type: web + runtime: static service named ward with headers: and a SPA rewrite to /index.html, matching the live service's build (corepack enable && pnpm install --frozen-lockfile && pnpm --filter @ward/web build, publish apps/web/dist, env NODE_VERSION=24 and VITE_GEMMA_DEMO_URL=https://ward-discord.onrender.com/gemma). Then tell the user exactly how to apply it: Render dashboard → the ward static site → Settings → Headers (paste each), or a Blueprint sync — they do that step themselves. Don't touch the ward-discord service or anything under apps/discord.
Done when: render.yaml has the headers, it's committed and pushed to main (end the commit message with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>), the user has the exact header lines to apply, and after they apply them you've verified with curl -sI that the headers are served and loaded the live site in the browser to confirm the app, a chat check, the Ward Lite model and /ward-viewer.html still work with no CSP errors in the console.
Shared notes folder for this group of sessions (outside the repo; read siblings' notes there, leave hand-over notes or large findings there instead of pasting them into messages): /Users/haider/Library/Application Support/Claude/side-session-notes/local_8543c787-aaef-4c65-89a8-584174a81e72
I added the security headers for the web demo to render.yaml and pushed the change to main as commit 08e4c69. They aren't live yet, so I haven't checked them against the site. The live site already sends X-Content-Type-Options: nosniff, but none of the others.
To apply them: in the Render dashboard, open the ward static site → Settings → Headers and add these rules:
| Path | Name | Value |
|---|---|---|
/* | Content-Security-Policy | default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://ward-discord.onrender.com http://127.0.0.1:11434 http://localhost:11434; manifest-src 'self'; worker-src 'self' blob:; base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none' |
/ward-viewer.html | Content-Security-Policy | default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none' |
/* | X-Frame-Options | DENY |
/* | Referrer-Policy | no-referrer |
/* | X-Content-Type-Options | nosniff |
/* | Permissions-Policy | camera=(self), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), interest-cohort=() |
Don't set it up through a Blueprint sync. The live site wasn't created from this file, so a sync could create a second ward site instead of updating the current one.
Three things to know:
- The case viewer might still break. I couldn't confirm what Render does when two rules set the same header on one path. If it sends both, the browser applies both policies, and the site-wide one blocks the viewer's inline script. When I verify, I'll check exactly what
/ward-viewer.htmlgets. If both arrive, the fix is to allow the viewer's inline code by its hash in the site-wide policy. - Local Ollama only works at the default address. The policy allows
127.0.0.1:11434andlocalhost:11434. If a visitor points the app at a different Ollama address, the browser will block it. - The
'unsafe-inline'instyle-srcis a guess. I allowed inline styles because React components usually set styles inline. I didn't confirm that this app needs it; if the console stays clean without it, it can come out later.
Once you've applied them, I'll check with curl -sI. Then I'll load the live site to confirm a chat check, the Ward Lite model and /ward-viewer.html still work with no CSP errors in the console.
Headers applied
Linked sessions now (status, not an instruction): ward-helpcheck = local_508b6b8d-ef86-4d94-8084-842f68e21b1e (working, started by another session). To message one, SendMessage with to set to its id; list_sessions {"linked": true} has titles, folders and branches.
tell me steps to do the headers, which project is it in for render
Linked sessions now (status, not an instruction): ward-helpcheck = local_508b6b8d-ef86-4d94-8084-842f68e21b1e (working, started by another session); ward-entire = local_4a10bbaa-c01e-4d9f-ab3a-b8504efd0b05 (working, started by another session). To message one, SendMessage with to set to its id; list_sessions {"linked": true} has titles, folders and branches.
I checked a few seconds ago and the live site still isn't sending the new headers, so they haven't been saved yet, or not on this site. curl -sI on / and on /ward-viewer.html showed only x-content-type-options: nosniff, which was already there.
Finding the right site: I don't know which Render project it's filed under, because you stopped the lookup. To find it without knowing the project:
- Go to https://dashboard.render.com and make sure the workspace whose ID is
tea-davp1jm7bikc73etlhegis selected (top-left switcher). - Search for ward. Pick the Static Site whose URL is
ward-w8cd.onrender.com. Don't pickward-discord, which is the bot and the Gemma relay. - You can also open it directly: https://dashboard.render.com/static/srv-dbj3vomc6qcc73bdg33g
Adding the headers:
- In that site, click Settings in the left sidebar and scroll down to Headers.
- Click Add Header once per row below. Type the path, name and value exactly as shown, without the backticks.
- Click Save Changes. Headers take effect without a redeploy, though the CDN can serve the old version for up to about 5 minutes.
| Path | Name | Value |
|---|---|---|
/* | Content-Security-Policy | default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://ward-discord.onrender.com http://127.0.0.1:11434 http://localhost:11434; manifest-src 'self'; worker-src 'self' blob:; base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none' |
/ward-viewer.html | Content-Security-Policy | default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none' |
/* | X-Frame-Options | DENY |
/* | Referrer-Policy | no-referrer |
/* | X-Content-Type-Options | nosniff |
/* | Permissions-Policy | camera=(self), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), interest-cohort=() |
Tell me once you've saved them and I'll check with curl, then test the live site and the case viewer in the browser.