Add Security Headers to Static Site

Claude Code·Opus 5.5·Unknown author·16h ago·3min·4 Checkpoints·6.9K tokens

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

16h ago·1m

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:

PathNameValue
/*Content-Security-Policydefault-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.htmlContent-Security-Policydefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
/*X-Frame-OptionsDENY
/*Referrer-Policyno-referrer
/*X-Content-Type-Optionsnosniff
/*Permissions-Policycamera=(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.html gets. 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:11434 and localhost:11434. If a visitor points the app at a different Ollama address, the browser will block it.
  • The 'unsafe-inline' in style-src is 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

system-reminder

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.

16h ago·12.8s

Not being served yet. Checking the service config on Render.

tell me steps to do the headers, which project is it in for render

system-reminder

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.

16h ago·10.2s

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:

  1. Go to https://dashboard.render.com and make sure the workspace whose ID is tea-davp1jm7bikc73etlheg is selected (top-left switcher).
  2. Search for ward. Pick the Static Site whose URL is ward-w8cd.onrender.com. Don't pick ward-discord, which is the bot and the Gemma relay.
  3. You can also open it directly: https://dashboard.render.com/static/srv-dbj3vomc6qcc73bdg33g

Adding the headers:

  1. In that site, click Settings in the left sidebar and scroll down to Headers.
  2. Click Add Header once per row below. Type the path, name and value exactly as shown, without the backticks.
  3. Click Save Changes. Headers take effect without a redeploy, though the CDN can serve the old version for up to about 5 minutes.
PathNameValue
/*Content-Security-Policydefault-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.htmlContent-Security-Policydefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
/*X-Frame-OptionsDENY
/*Referrer-Policyno-referrer
/*X-Content-Type-Optionsnosniff
/*Permissions-Policycamera=(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.