# 485Tech Security Headers — Hardening Report

Applies to **485tech.com** (Cloudflare Workers + Assets). Current SecurityHeaders grade: **F**
(all six baseline headers missing — verified live on 2026-08-11).

**Principle of work:** every CSP allowlist entry below was verified against the actual
repository contents and the live site. Nothing is guessed.

---

## 1. Stack & deployment analysis (verified)

| Area | Finding |
|---|---|
| Edge | Cloudflare (verified `server: cloudflare`, HTTP/2, `cf-ray`) |
| App | Cloudflare Worker `src/worker/index.js` + static assets via the `ASSETS` binding (`public/`) |
| Frontend | Vanilla HTML/CSS/JS, web components, i18n (TH/EN), SVG sprite — **no framework, no build step for UI** |
| HTTP→HTTPS | Already redirects at the edge (verified 301) — worker adds an explicit redirect as belt-and-suspenders |
| Database | D1 binding **not configured** (commented out in `wrangler.toml`); subscribe API returns 503 until it is |
| Live DNS | Only `485tech.com` + `www.485tech.com` have A records (verified via DNS-over-HTTPS). All other candidate subdomains NXDOMAIN |
| Mail | Zoho MX (`mx.zoho.com`, `mx2`, `mx3`); SPF `v=spf1 include:zohomail.com ~all` |
| HSTS preload | Domain **not** on the preload list (verified `status: unknown` on hstspreload.org) |
| Analytics / payment / iframes / WebSocket | **None found** in code (searched HTML, CSS, JS) |

## 2. Third-party allowlist (evidence-based)

The site loads exactly **one** third-party origin set. This is the complete allowlist:

| Domain | Directive | Reason (evidence) |
|---|---|---|
| `https://fonts.googleapis.com` | `style-src` | `@import url('https://fonts.googleapis.com/css2?family=Inter…')` — `public/styles/main.css:5` (Google Fonts CSS). CSS `@import` is governed by `style-src` |
| `https://fonts.gstatic.com` | `font-src` | The `@font-face` files (woff2) served by Google Fonts |
| `data:` | `img-src` | Inline SVG background pattern in `public/styles/main.css` (`background: url("data:image/svg+xml,…")`) |
| `'self'` | everything else | All scripts (`/scripts/app.js`, plus the Tools page's on-demand core/hub modules `/scripts/tools/tools-core.js`, `/scripts/tools/tools-hub.js`, its `catalog.json`, and per-app bundles under `/scripts/tools/apps/*/` incl. the vendored `/scripts/tools/apps/qr/qrcode.js`), styles, SVG sprite (`/assets/icons/icons.svg` via `<use href>`), logo, API (`/api/subscribe`), SPA navigation fetches — all same-origin |

Explicitly checked and **absent**: inline event handlers (`onclick=` etc. — 0 matches),
`eval` / `new Function` (0), `<iframe>/<embed>/<object>` (0), analytics tags, payment
widgets, WebSockets, `preconnect`/`dns-prefetch`, other CDNs.

**If you add any third-party later** (analytics, chat widget, payment, YouTube embeds,
maps, tracking pixels), extend the policy deliberately — see §4 for where each lands.

### Ads (Google AdSense, consent-gated)

The site ships a consent-gated AdSense framework (`/scripts/ads.js` + `.ad-slot`
elements in the public and standalone layouts). The provider script is not loaded
until the visitor chooses to allow advertising. Until then, ad slots are hidden and
no AdSense request is made. The current code requests non-personalized ads by
default; personalized ads must not be enabled until a Google Privacy & Messaging
message or another Google-certified CMP has been configured for the relevant
traffic.

The integration is controlled by three pieces that must stay synchronized:

1. `window.AD_CONFIG` in `src/layout.html` and `src/app-layout.html` contains the
   verified publisher client, Auto Ads flag, consent-gated provider script, and an
   empty manual-slot map. Manual ad units must only be added with real slot IDs from
   the AdSense account.
2. `public/ads.txt` authorizes the publisher account at the domain root.
3. The worker CSP via `CSP_EXTRA_*` vars in `wrangler.toml` allows the provider's
   script, frame, image, and connection origins while keeping the policy explicit.

The consent UI includes a persistent settings entry in the footer and links to the
privacy policy. It is an application-level control, not a substitute for Google's
certified CMP requirement in the EEA, UK, or Switzerland. Before enabling
personalized ads, configure Google Privacy & Messaging in AdSense and replace the
non-personalized mode only after verifying the resulting consent signals.

The implementation must never label ads as navigation, downloads, or helpful links,
place them inside private communication flows, encourage clicks, or use pop-ups and
pop-unders. Keep the CSP in Report-Only while collecting legitimate provider
violations, then review before any enforce rollout.

## 3. Delivered changes

| File | Change |
|---|---|
| `src/worker/index.js` | Security headers on every response; CSP Report-Only with per-request nonce injected into inline `<script>`; CSP collector `POST /api/csp-report`; HTTPS redirect; hardened `/api/subscribe` (Origin + `Sec-Fetch-Site` CSRF checks, validation, best-effort KV rate limiting); phase config via env vars |
| `wrangler.toml` | `[vars]` block with rollout knobs: `CSP_MODE`, `HSTS_MAX_AGE`, `HSTS_INCLUDE_SUBDOMAINS`, `HSTS_PRELOAD` |
| `migrations/0001_csp_reports.sql` | `csp_reports` table for storing violations (D1) |
| `public/scripts/app.js` | `locale` cookie now `…; SameSite=Lax; Secure` |
| `scripts/worker-smoke.mjs` | Runnable smoke test: `node scripts/worker-smoke.mjs` (40 checks, all pass) — headers, nonce==CSP nonce, no wildcard/`unsafe-inline` in script-src, redirect, CSRF 403, D1-missing 503, invalid email 400, report endpoint 204, ad-CSP extras, JWT auth round-trip (200/401/503/402), alg-confusion rejection, nonce/cache integrity, `security.txt` |
| `public/.well-known/security.txt` | RFC 9116 security contact (vulnerability disclosure) |
| `src/lib/crypto.js` | JWT verifier now rejects any `alg` other than `RS256` (alg-confusion hardening) |
| `src/worker/index.js` | **Nonce/cache integrity:** HTML responses are served `Cache-Control: no-store` with stale `content-length` / `content-encoding` / `etag` / `last-modified` stripped after nonce injection (a cached HTML would freeze a nonce; a stale ETag could serve a 304 with an old nonce). Added `Reporting-Endpoints` + `report-to` (kept legacy `report-uri` for Safari). Added KV rate limits for `/api/me` (60/min) and `/api/credits/charge` (30/min). Removed dead `getLocale` import |
| `public/scripts/app.js` | Removed the unused `locale` cookie (never read by client or server — an unused JS-readable cookie is attack surface; language now lives only in `localStorage`) |
| `.gitignore` | Added `.env*` |

Deploy: `wrangler deploy` (after verifying locally — see §9). The Freebuff preview /
`bun run preview` server does **not** emulate the worker; security headers take effect
on the Cloudflare deployment only.

## 4. CSP policy

**Phase A (now): Report-Only** — `Content-Security-Policy-Report-Only` with `report-uri /api/csp-report`.
Nothing is blocked; the site keeps working while we collect genuine violations.

```text
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
form-action 'self';
script-src 'self' 'nonce-<per-request>' 'strict-dynamic';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data:;
connect-src 'self';
frame-src 'self';
upgrade-insecure-requests;
report-uri /api/csp-report
```

Compliance with requirements:
- **No wildcards** — `default-src 'self'` only; every directive is explicit. ✅
- **No `script-src *` / `unsafe-inline` / `unsafe-eval`** — every `<script>` tag
  (inline AND external) is allowed via a **per-request nonce** injected by the
  worker. ✅
- **`'strict-dynamic'`** is included so the nonce'd scripts may chain-load the
  runtime-injected scripts used by the tools platform (tools-core, app modules,
  vendored libs like the QR encoder) under enforcement — without this, those
  dynamically-inserted scripts would be blocked by the nonce policy and every
  tool app page would fail to boot. `'self'` remains as the fallback for browsers
  without strict-dynamic support. This is the refactor path req #3 asks for,
  implemented directly. ✅
- `style-src 'unsafe-inline'` is retained **only** because the templates use 28 inline
  `style=""` attributes (verified) and Google Fonts must load. Refactor plan to remove
  it: move inline styles into CSS classes in `public/styles/pages.css` + `main.css`,
  then drop `'unsafe-inline'` from `style-src`. (JS-set styles via CSSOM are not
  affected by CSP and don't need the exception.)

**Phase B (enforcement):** set `CSP_MODE = "enforce"` in `wrangler.toml [vars]` only
**after** ≥ 7–14 days of reports show zero legitimate violations (see §9). Keep
`report-uri` in the enforced policy for ongoing monitoring.

## 5. HSTS rollout (per requirement: 1d → 30d → 90d → 1y, no preload until subdomains verified)

| Phase | `HSTS_MAX_AGE` | Header served | Gate |
|---|---|---|---|
| 1 (initial deploy) | `86400` (1 day) | `max-age=86400; includeSubDomains` | current default |
| 2 (after ~1 week clean) | `2592000` (30 days) | `max-age=2592000; includeSubDomains` | |
| 3 (after ~1 month) | `7776000` (90 days) | `max-age=7776000; includeSubDomains` | |
| 4 (after ~3 months) | `31536000` (1 year) | `max-age=31536000; includeSubDomains` | baseline value |
| preload | — | `…; includeSubDomains; preload` | **Only after** every subdomain verified HTTPS **and** the domain is deliberately submitted to hstspreload.org (it is irreversible for months) |

The user-supplied baseline `max-age=31536000` is the **end state**, not the starting
value — a fresh, short max-age is the industry-standard safe rollout, and rolling back
a long HSTS is impossible before it expires. `includeSubDomains` is safe today
(verified: only apex + www have DNS records; both are Cloudflare-proxied HTTPS; email
is external Zoho MX). `HSTS_PRELOAD` stays `false` in code — enabling it requires
setting the var **and** submitting to the preload list.

## 6. Origin (non-Worker) fallback — defense in depth

The primary enforcement point is the Worker. These are fallbacks for direct-to-origin
access or if the Worker is ever bypassed. **Caveat:** without the Worker there is no
per-request nonce, so the origin fallback CSP uses SHA-256 hashes of the inline scripts
instead (they change if the inline script text changes — keep the Worker as the source
of truth).

```nginx
# nginx
add_header Strict-Transport-Security "max-age=86400; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), picture-in-picture=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'sha256-bjvkzVGRc3Eu5xNwFJw4Ef9o47nptaCMxXmnzNL+3gQ=' 'sha256-NFyyaUILZR83FPvPRv18X5qDQNpUSk+pkowWvWVKGe8='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-src 'self'; upgrade-insecure-requests" always;
```

```apache
# Apache (mod_headers)
Header always set Strict-Transport-Security "max-age=86400; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), picture-in-picture=(), usb=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'sha256-bjvkzVGRc3Eu5xNwFJw4Ef9o47nptaCMxXmnzNL+3gQ=' 'sha256-NFyyaUILZR83FPvPRv18X5qDQNpUSk+pkowWvWVKGe8='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-src 'self'; upgrade-insecure-requests"
```

The two hashes cover the theme bootstrap inline script (every page) and the legacy
minified copy found only inside `public/privacy.html` / `public/terms.html`.

**Cloudflare Transform Rule (no-code alternative, zone-level):** Rules → Transform
Rules → Modify Response Headers, set the same headers as above (minus CSP or with the
hash version). Note: a zone-level rule applies even if the Worker is bypassed, so it is
a useful second layer — but keep the Worker's nonce-based CSP as the primary.

## 7. Other security findings (requirement 9)

| Area | Finding | Action |
|---|---|---|
| Cookies | `locale` cookie was `…; SameSite=Lax` without `Secure` (later `+ Secure`) | ✅ **Removed entirely** — the cookie was never read by the client (language lives in `localStorage`) and the worker's `getLocale` was dead code, so an unused JS-readable cookie was pure attack surface. No cookies remain on the site |
| CORS | No `Access-Control-Allow-Origin` anywhere (correct — no cross-origin consumers) | Keep. Never add `*`; the subscribe API now also enforces Origin + `Sec-Fetch-Site` (CSRF) |
| CSRF | `/api/subscribe` accepted cross-site POSTs | ✅ 403 for cross-site browser POSTs; same-origin + `ALLOWED_ORIGINS` var only |
| Rate limiting | None before | ✅ Best-effort KV limiter in worker (10/min/IP subscribe, 120/min/IP csp-report) — activates when a KV binding exists. **Add a Cloudflare WAF rate-limiting rule:** `when (http.request.uri.path eq "/api/subscribe") → 20 req/10s per IP → BLOCK` (KV is eventually consistent, so WAF is the authoritative layer) |
| WAF | Managed Ruleset not confirmed enabled | Enable **Cloudflare Managed Ruleset** + the rate rule above in the dashboard |
| HTTPS redirect | Already works at edge (verified) | ✅ Worker adds explicit 301 as fallback |
| Secrets | None found in repo (scanned for keys, tokens, PEMs); no `.env` files | ✅ `.gitignore` now blocks `.env*`. When JWT auth is wired, use `wrangler secret put` (never in `wrangler.toml`) |
| HTML caching / nonce | HTML was returned with the ASSETS response headers — a stale `content-length`/`etag` could serve truncated or stale-nonce HTML from cache | ✅ HTML is now `Cache-Control: no-store` with `content-length`/`content-encoding`/`etag`/`last-modified` stripped after nonce injection. Static assets keep normal caching — only HTML carries the per-request nonce |
| CSP reporting | `report-uri` only (legacy, dropped by Chromium in favor of `report-to`) | ✅ Added `Reporting-Endpoints: csp-endpoint="/api/csp-report"` + `report-to csp-endpoint` (kept `report-uri` for Safari) |
| JWT verification | Accepted any signed token without checking `alg` | ✅ Verifier now requires `alg: RS256` — HS256/`none` alg-confusion tokens are rejected (covered by a smoke-test check) |
| Vulnerability disclosure | None | ✅ Added `/.well-known/security.txt` (RFC 9116) |
| Pre-existing issue (not header-related) | `public/privacy.html` and `public/terms.html` contain a **nested full HTML document** inside the body (from an old build), so `app.js` loads twice on those pages | Fix by regenerating those two pages from `src/pages/*` with the current layout (they are the only pages whose `src/pages/` file still contains a full document). Nonce injection already covers both inline scripts |

## 8. Test plan (before deploying)

1. **Local:** `node --check src/worker/index.js` && `node scripts/worker-smoke.mjs` (28 assertions: headers, nonce == CSP nonce, no wildcard/`unsafe-inline` in script-src, redirect, CSRF 403, D1-missing 503, invalid email 400, report endpoint 204).
2. **Staging:** `wrangler dev` (or deploy to a preview environment) and repeat the curl checks in §10.
3. **Browser smoke (every page):** console shows only `Content Security Policy` *reports* (Report-Only) — no broken resources; theme toggle works; language switch works; subscribe form still posts (expect 503 until D1 is wired — that's pre-existing, not header-related).
4. **External scanners:** https://securityheaders.com/ (expect A), paste the CSP into https://csp-evaluator.withgoogle.com/ (expect no warnings; `unsafe-inline` in `style-src` only).
5. **Report review window:** leave Report-Only for **7–14 days**. Review with:
   ```sql
   SELECT violated_directive, blocked_uri, count(*) FROM csp_reports
   WHERE ts > datetime('now', '-14 days') GROUP BY 1, 2 ORDER BY 3 DESC;
   ```
   Investigate anything not matching §2. Then set `CSP_MODE = "enforce"` and redeploy.
6. **HSTS ladder:** advance `HSTS_MAX_AGE` per §5 and re-run checks 1–4 each time.

## 9. Rollback plan

1. **Instant:** `wrangler rollback` — reverts to the previous worker version (Cloudflare keeps recent deploy versions). All headers and the CSP revert with it.
2. **Without code change:** flip the vars in the dashboard — `CSP_MODE="report-only"` (stop blocking), `HSTS_MAX_AGE` back to a small value — redeploy takes effect immediately.
3. **Git:** `git revert` the worker change, redeploy. The smoke test runs before and after.
4. **HSTS caveat (plan for it):** browsers remember `max-age`; you cannot un-pin HSTS before it expires. This is exactly why the rollout starts at 1 day and why preload stays off. For local testing after a change: use incognito or delete the pin via `chrome://net-internals/#hsts`.

## 10. Verification commands

```bash
# 1. All security headers on the homepage
curl -sI https://485tech.com/ | grep -iE 'strict-transport|x-content-type|x-frame-options|referrer-policy|permissions-policy|cross-origin-opener|cross-origin-resource'

# 2. CSP currently served (Report-Only)
curl -sI https://485tech.com/ | grep -i content-security

# 3. Nonce injected into the inline theme script
curl -s https://485tech.com/ | grep -o 'nonce="[^"]*"'

# 4. HTTPS redirect still enforced
curl -sI http://485tech.com/ | grep -i location          # expect Location: https://485tech.com/

# 5. CSP report endpoint accepts reports (expect 204)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://485tech.com/api/csp-report \
  -H 'Content-Type: application/json' \
  -d '{"csp-report":{"blocked-uri":"https://evil.example/x.js","violated-directive":"script-src"}}'

# 6. Subscribe CSRF protection (expect 403)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://485tech.com/api/subscribe \
  -H 'Origin: https://evil.example' -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","module":"store"}'

# 7. Subscribe without D1 configured (expect 503 until the D1 binding is added)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://485tech.com/api/subscribe \
  -H 'Origin: https://485tech.com' -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","module":"store"}'
```

## 11. Risk report — before / after

| Header | Before | After | Feature risk after deployment |
|---|---|---|---|
| Strict-Transport-Security | ❌ missing | `max-age=86400; includeSubDomains` (rollout §5) | Very low: only apex+www exist; both HTTPS. Subdomains added later must serve HTTPS |
| Content-Security-Policy | ❌ missing | Report-Only (phase A) → enforce (phase B) | **Zero in Report-Only.** On enforce: only inline scripts carrying the worker-injected nonce run; external scripts must be same-origin. Any future third-party script silently breaks → must be allowlisted first (§2) |
| X-Frame-Options | ❌ missing | `SAMEORIGIN` (+ `frame-ancestors 'self'`) | None today (no iframes, not embedded). Breaks embedding 485tech pages in other sites — intended |
| X-Content-Type-Options | ❌ missing | `nosniff` | None |
| Referrer-Policy | ❌ missing | `strict-origin-when-cross-origin` | None (no cross-origin outbound links today) |
| Permissions-Policy | ❌ missing | baseline `()…` policy | None — site uses none of the blocked APIs (verified in `app.js`) |
| COOP / CORP (added) | ❌ missing | `same-origin` | None for same-origin browsing. CORP stops other sites hotlinking 485tech assets — intended; revisit if partner sites must embed images |
| Cookies | `locale` w/o `Secure` | `+ Secure` | None (HTTPS-only site) |
| Subscribe API | unauthenticated cross-site POST | Origin + Sec-Fetch-Site check, validation, rate limit | Same-origin unaffected; cross-site bots 403; returns 503 until D1 bound (pre-existing) |
| Grade | **F** | **A** (Report-Only phase), **A+** after HSTS 1y + preload (phase 4/5) | — |

### Expected impact if something goes wrong
- **CSP enforcement breaks a feature** → the feature's violation shows up in reports; switch `CSP_MODE="report-only"` (var flip, no code change) while fixing.
- **Nonce injection fails** (only if HTML structure changes drastically) → inline theme script blocked in enforce mode; pages still render (theme falls back to `app.js`); rollback path §9.
- **HSTS with a new HTTP-only subdomain** → browsers refuse that subdomain until max-age expires; check DNS before each ladder step (subdomain inventory command below).

```bash
# Subdomain inventory before each HSTS step (replace dig with any DNS tool)
for sub in www api mail app blog store dev tools trading admin; do
  dig +short A "$sub.485tech.com"
done
```
