# ZWN Location – Security Policy

**Last updated**: 2026-09-22

`vercel.json` is the runtime source of truth for headers. This file describes what the code actually does.

## Headers

Applied to all paths:

- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
- `X-Frame-Options: DENY`
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- `Content-Security-Policy` (see `vercel.json`)

The CSP allows this origin, Cloudflare Turnstile (`https://challenges.cloudflare.com`), Google Fonts, and — only so audience measurement can run after cookie consent — Google Tag Manager and Google Analytics. It also sets `frame-ancestors 'none'`, `object-src 'none'`, and `form-action 'self'`.

`unsafe-inline` stays because of the JSON-LD blocks and a short inline script that sets the canonical URL and the document title before React hydrates.

## Contact form

`POST /api/contact` runs on Vercel. The browser does not call EmailJS.

- Cloudflare Turnstile is checked server-side with `TURNSTILE_SECRET_KEY`.
- A successful token must belong to `zwnlocation.com`, `www.zwnlocation.com`, `localhost`, `127.0.0.1`, or a `*.vercel.app` preview host. Lock the widget hostnames in the Cloudflare dashboard as well.
- Mail is sent with the EmailJS REST API and `EMAILJS_PRIVATE_KEY` (server only). Service, template, and public key are read from `EMAILJS_*`, with `VITE_EMAILJS_*` still accepted so an existing Vercel project keeps working.
- Fields are length-capped and stripped of control characters before they are sent.
- Honeypot field `company`: a filled value returns success and does not send mail.
- Body size is capped (32 KB). Upstream calls time out after 10 seconds.
- Rate limit: 4 requests / 10 minutes per warm instance, keyed on `x-real-ip` (not the client-supplied forwarding header). This is not a global limit across instances.
- The browser receives a generic French error. Missing configuration is logged on the server only.

The minimum time-on-form check in the browser is a convenience. It is not a security boundary.

## Analytics

GA4 is injected only after the visitor accepts « Mesure d’audience ». Refusing, or turning it off later in the same visit, sets Google’s `ga-disable-<id>` flag. The measurement id must match `G-` plus letters and digits. There is no marketing pixel.

## Dependencies

CI on `main` runs the type check, the Node test files, the production build, and `npm audit --audit-level=critical --omit=dev`. Dependabot opens weekly npm updates.

## If something breaks

- Form spam: EmailJS dashboard and Vercel function logs. Message bodies are not written to those logs.
- CSP breakage: revert the `vercel.json` change, then retest the form, Turnstile, and a consented analytics load on a preview.
- Header regression: redeploy the previous Vercel deployment.

Update this file when the headers or the contact path change.
