# Security Headers

Every compiled Quickback Worker sets a baseline of security response-headers on every response: HSTS, `X-Content-Type-Options: nosniff`, a clickjacking CSP (`frame-ancestors 'self' …TRUSTED_ORIGINS`), `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy`, and `Permissions-Policy`. Adding the `security` block lets you override individual headers, ship a fuller Content-Security-Policy, or suppress them all if an upstream gateway handles headers.

## Default behavior

Omit `security` entirely → the baseline above. No config change needed for clickjacking + HSTS protection.

```ts title="quickback.config.ts"
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  name: 'my-api',
  // ...
  // security defaults are applied automatically.
});
```

## Suppress every security header

For Workers fronted by a gateway that owns headers:

```ts title="quickback.config.ts"
export default defineConfig({
  // ...
  security: false,
});
```

The compiler emits a no-op middleware comment so the route table still lines up with the rest of the generated code, but no `c.header(...)` calls fire.

## Per-header overrides

Each field on the `security` block accepts either a typed value or `false` to suppress that specific header. Defaults still apply for fields you don't set.

```ts title="quickback.config.ts"
export default defineConfig({
  // ...
  security: {
    // Suppress just CSP — keeps HSTS, referrer, permissions, etc.
    csp: false,

    // Or override individual fields:
    coop: 'same-origin',
    coep: 'credentialless',
    referrerPolicy: 'no-referrer',
    permissionsPolicy: { geolocation: [], microphone: [], camera: [], payment: [] },
    hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
  },
});
```

## Content Security Policy

`security.csp` accepts a typed object. Each `*Src` field is an array of source expressions. The compiler accepts both the `@`-prefixed sentinels (idiomatic, type-safe) and CSP wire-form keywords (`"'self'"`, `"'unsafe-inline'"`); both normalize to the same output.

### Sentinels

| Sentinel | Wire form | Notes |
|---|---|---|
| `@self` | `'self'` | Same-origin. |
| `@none` | `'none'` | Empty-set keyword. |
| `@unsafe-inline` | `'unsafe-inline'` | Allows inline scripts/styles — defeats most XSS mitigation when used in `script-src`. |
| `@unsafe-eval` | `'unsafe-eval'` | Allows `eval()` and friends. |
| `@trustedOrigins` | The `…TRUSTED_ORIGINS` constant at runtime | Resolves to `auth.trustedOrigins` as configured in `quickback.config.ts`. |
| `@nonce` | `'nonce-…'` per request | Reserved for the per-request nonce middleware shipping in 0.10.15. Using this in 0.10.14 is a compile-time error. |

Unknown `@…` tokens are rejected at compile rather than silently stringified into the wire CSP.

### Example: paste-in CSP for a Quickback SPA project

The bundled CMS and Account SPAs load Google Fonts cross-origin, so the strict `'self'`-only CSP would white-screen them on load. This config is a known-working starting point for Quickback projects with the bundled SPAs:

```ts title="quickback.config.ts"
export default defineConfig({
  // ...
  security: {
    csp: {
      defaultSrc: ['@self'],
      // Tailwind dynamic styles + bundled Google Fonts CSS need
      // unsafe-inline + the gstatic / googleapis hosts. Tightening
      // styles to nonces ships in 0.10.15.
      styleSrc: ['@self', '@unsafe-inline', 'https://fonts.googleapis.com'],
      scriptSrc: ['@self'],
      fontSrc: ['@self', 'data:', 'https://fonts.gstatic.com'],
      imgSrc: ['@self', 'data:', 'blob:', 'https:'],
      connectSrc: ['@self', '@trustedOrigins'],
      frameAncestors: ['@self', '@trustedOrigins'],
      formAction: ['@self'],
      baseUri: ['@self'],
      objectSrc: ['@none'],
      upgradeInsecureRequests: true,
    },
  },
});
```

### Troubleshooting CSP violations

If the browser console shows CSP errors after enabling `security.csp`, match the directive named in the error against this table — it maps the failure mode back to the directive that needs widening.

| Console error | Directive | Why it happens | Fix |
|---|---|---|---|
| `Loading the stylesheet 'https://fonts.googleapis.com/...' violates 'style-src ...'` | `styleSrc` | Bundled CMS/Account SPAs `<link>` to Google Fonts CSS. | Add `'https://fonts.googleapis.com'` to `styleSrc`. |
| `Refused to load the font 'https://fonts.gstatic.com/...' because it violates 'font-src ...'` | `fontSrc` | The CSS file from googleapis loads `.woff2` files from gstatic. | Add `'https://fonts.gstatic.com'` to `fontSrc`. |
| `Connecting to 'https://api.your-domain/...' violates 'connect-src 'self''` | `connectSrc` | API on a sibling subdomain (e.g. SPA on `app.example.com`, API on `api.example.com`) — `'self'` only covers exact origin. | Use `connectSrc: ['@self', '@trustedOrigins']` so the same allowlist `cors()` enforces gets reused. |
| `Refused to apply inline style because it violates 'style-src ...'` | `styleSrc` | TanStack Router / Tailwind's `style="..."` attributes are inline. | Add `'@unsafe-inline'` to `styleSrc` until 0.10.15 ships per-request nonces. |
| `Refused to load the image '...' because it violates 'img-src ...'` | `imgSrc` | User-uploaded avatars / R2-hosted assets / `data:` URIs from blob previews. | Widen `imgSrc` to `['@self', 'data:', 'blob:', 'https:']` (or list specific hosts). |
| `Refused to connect to 'wss://...' because it violates 'connect-src ...'` | `connectSrc` | Realtime / Durable Object WebSockets. | The `@trustedOrigins` sentinel covers HTTPS only — add the explicit `wss://` origin if your DO lives elsewhere. |

The general rule: `'self'` means *exact origin* (scheme + host + port). Anything cross-origin needs an explicit host or the `@trustedOrigins` sentinel. The [paste-in recipe above](#example-paste-in-csp-for-a-quickback-spa-project) is a known-good baseline for a stock Quickback project — start there and tighten, rather than starting from `'self'`-only and chasing console errors.

### Auto-derivations

The compiler applies these so config stays minimal:

- **`csp.frameAncestors` defaults to `['@self', '@trustedOrigins']`** when `csp` is set but `frameAncestors` is omitted. Same allowlist as CORS.
- **`X-Frame-Options` derives from `csp.frameAncestors`** (`['@none']` → `DENY`, contains `@self` → `SAMEORIGIN`, otherwise omitted — XFO can't represent arbitrary origins so CSP becomes the truth there). Override with `security.frameOptions`.
- **`corp: 'same-origin'` is auto-emitted when `coep` is set.** `require-corp` without CORP breaks the ASSETS binding fetches on Cloudflare; the compiler couples them so users don't have to remember.

### CSP auto-config (0.10.15+)

When you opt into a `csp` object, the compiler additively widens directives based on which Quickback features your project has enabled. **Additive** means anything you already wrote stays; we only push values that aren't already present.

| Trigger | Auto-added | To directive | Why |
|---|---|---|---|
| `cms: true` OR `account: true` | `'unsafe-inline'` | `styleSrc` | Tailwind / TanStack Router emit inline `style=` attributes |
| `cms: true` OR `account: true` | `https://fonts.googleapis.com` | `styleSrc` | Bundled SPAs `<link>` to Google Fonts CSS |
| `cms: true` OR `account: true` | `https://fonts.gstatic.com` | `fontSrc` | The googleapis CSS loads `.woff2` from gstatic |
| `csp` set (always) | `@trustedOrigins` | `connectSrc` | XHR/fetch must reach the same allowlist `cors()` permits — covers cross-subdomain APIs without re-listing them |
| `cms: true` OR `account: true` | `https://${domain}` for each `cms.domain` / `account.domain` / `domain` / inferred unified domain | `connectSrc` | The Vite bundle hardcodes `VITE_QUICKBACK_API_URL` at compile time, so the SPA fetches that exact URL even when the page is served from a different origin (e.g. localhost dev hitting prod-baked SPA). Emitted with the host's first letter capitalised (`https://Api.example.com`) so `wrangler dev`'s response-header rewrite can't mangle it — see [the `wrangler dev` note below](#why-the-spa-baked-origin-shows-up-capitalised-in-csp). |
| `cms: true` OR `account: true` | `'sha256-…'` (one exact hash per bundled SPA) | `scriptSrc` | The worker injects `<script>window.__QUICKBACK_RUNTIME={...}</script>` into every served SPA shell (runtime-configured SPAs). The blob is fixed at compile time, so an exact script hash keeps strict `script-src` policies intact — no `'unsafe-inline'`. |

**Scaffold rule:** if you set `defaultSrc` but omit the target directive (e.g. you wrote `defaultSrc: ['@self']` and nothing for `connectSrc`), auto-config creates the new directive seeded with your `defaultSrc` values plus the addition. `defaultSrc` itself stays clean. So `defaultSrc: ['@self']` + auto-config produces:

```
default-src 'self'
style-src   'self' 'unsafe-inline' https://fonts.googleapis.com
font-src    'self' https://fonts.gstatic.com
connect-src 'self' @trustedOrigins https://api.your-app.com
```

Each directive is scoped to exactly what it needs. `defaultSrc` is *not* widened with style-only or connect-only hosts — that would let those hosts also reach `script-src` / `img-src` / `media-src` via fall-back, which is wider than the bundled SPA actually needs. (This rule changed in 0.10.17 — earlier versions incorrectly pushed additions into defaultSrc.)

**Visibility:** every applied addition prints to stderr at compile time — one line per addition with the directive, value, and reason. No silent magic. Loop's compile output now looks like:

```
[security] auto-added 'unsafe-inline' to csp.styleSrc — account: true → bundled Account SPA → inline style attributes from Tailwind / TanStack Router
[security] auto-added https://fonts.googleapis.com to csp.styleSrc — account: true → bundled Account SPA → loads Google Fonts CSS cross-origin
[security] auto-added https://fonts.gstatic.com to csp.fontSrc — account: true → bundled Account SPA → woff2 font files served from gstatic.com
[security] auto-added @trustedOrigins to csp.connectSrc — CORS-trusted origins must be reachable from XHR/fetch (matches cors() allowlist)
```

**Opt-out:** `security: { cspAutoConfig: false, csp: { ... } }` for projects fronted by a CDN that injects its own CSP, or teams that want strict from-scratch control. With auto-config off, every value above must be authored by hand.

#### Why the SPA-baked origin shows up capitalised in CSP

Projects with `domain: "..."` + bundled CMS / Account SPAs will see `connect-src 'self' https://Api.example.com …` in their served CSP — note the capital `A`. That's deliberate (added in 0.10.19) and works around a `wrangler dev` quirk, not a typo on our end.

`wrangler dev` rewrites the configured `custom_domain` host string in **every** response header value (Location, Content-Security-Policy, X-…) to the local hostname, with a case-sensitive substring matcher. Without the case-shift, the served CSP becomes `connect-src 'self' https://localhost:8787 …` and the bundled SPA's hardcoded `VITE_QUICKBACK_API_URL` fetches get blocked because the prod URL no longer appears in the allowlist.

CSP host-source matching is ASCII case-insensitive per CSP3 (`Matching Source Expressions`), so browsers treat `https://Api.example.com` as identical to `https://api.example.com` for the purpose of CSP checks. Wrangler's matcher is case-sensitive, so the capitalised form slips past the rewrite and survives into the served header. End result: the SPA's lowercase fetch target matches the capitalised CSP entry, auth round-trips work in dev, and prod CSP behaviour is unchanged (browsers don't care).

If wrangler ever fixes the response-header rewrite, the capitalised form remains a valid CSP entry — no follow-up needed.

With auto-config on, the [paste-in recipe above](#example-paste-in-csp-for-a-quickback-spa-project) becomes much shorter — most users only need:

```ts
security: {
  csp: {
    defaultSrc: ['@self'],
    scriptSrc: ['@self'],
    imgSrc: ['@self', 'data:', 'blob:', 'https:'],
    objectSrc: ['@none'],
    upgradeInsecureRequests: true,
  },
}
```

The compiler fills in the SPA-specific font hosts, inline-style allowance, and trusted-origin connect rules from the project's own feature flags.

## Cross-origin window/embedder isolation

```ts title="quickback.config.ts"
security: {
  coop: 'same-origin',          // Defends against window.opener back-references from popups.
  coep: 'credentialless',       // Required for SharedArrayBuffer / cross-origin isolation.
  // corp auto-emits as 'same-origin' here.
}
```

## HSTS

Default: `max-age=31536000; includeSubDomains; preload` (HSTS preload-list eligible).

```ts title="quickback.config.ts"
security: {
  hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
}
```

The generated middleware automatically suppresses HSTS on `localhost`, `127.0.0.1`, and `*.localhost` regardless of this config. Without that gate, `wrangler dev` would pin the developer's browser to HTTPS for `maxAge` seconds, which is observable across every project on that machine.

To submit to the [HSTS preload list](https://hstspreload.org), you need:

- `maxAge` ≥ 31536000 (one year)
- `includeSubDomains: true`
- `preload: true`

The compiler warns at compile time if `preload: true` without the other two — the preload list rejects either anyway.

## Permissions Policy

Map of feature name → list of allowed origins.

```ts title="quickback.config.ts"
security: {
  permissionsPolicy: {
    geolocation: [],                        // disabled for everyone
    microphone: ['self'],                   // allowed only on own origin
    camera: ['self', 'https://meet.example.com'],
    payment: [],
  },
}
```

The compiler encodes the wire syntax (which differs from CSP — values use `()`, not space-separated tokens, with bareword `self` and quoted URLs).

## Custom response headers

`security.headers` sets arbitrary response headers on every response, alongside the managed security headers. Keys are header names; values are static strings or templates that interpolate per-deploy Worker env vars via the `{{env.VAR_NAME}}` token.

```ts title="quickback.config.ts"
security: {
  headers: {
    // Stamp the deployed build identity. Read from Worker vars at runtime,
    // so the value tracks whatever you inject at deploy time.
    'X-Server-Version': '{{env.SERVER_VERSION}}+{{env.SERVER_BUILD}}',

    // Static value — always emitted.
    'X-Powered-By': 'Quickback',
  },
}
```

The canonical use case is exposing a build/version identity on every response so clients (`curl -I`, mobile apps, dashboards) can read the running build without a body request — and so you can detect drift between what's deployed and what git/CF report.

### Omit-if-unset

A header whose value references env vars is emitted **only when every referenced var is set** (non-`undefined`, non-empty string) at runtime. A version header on a Worker that forgot to inject the var simply doesn't appear — never `X-Server-Version: undefined`. Static headers (no `{{env…}}`) are always emitted.

The value is read from `c.env` per request, so you don't declare these vars at compile time — inject them as Worker vars (`[vars]` in `wrangler.toml`, or `--var` at deploy) and the header picks them up.

### Generated middleware

For the config above, the compiler appends this to the security-headers middleware — no post-compile patching:

```ts
// Custom response headers (security.headers).
{
  const v0 = (c.env as unknown as Record<string, string | undefined>)["SERVER_VERSION"];
  const v1 = (c.env as unknown as Record<string, string | undefined>)["SERVER_BUILD"];
  if (v0 != null && v0 !== '' && v1 != null && v1 !== '') {
    c.header("X-Server-Version", `${v0}+${v1}`);
  }
}
c.header("X-Powered-By", "Quickback");
```

### Rules

- **Header names** must be valid HTTP field tokens. Names already managed by a dedicated `security` field — `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, `X-Content-Type-Options`, `Cross-Origin-Opener-Policy`/`-Embedder-Policy`/`-Resource-Policy`, `Referrer-Policy`, `Permissions-Policy` — are rejected; use that field instead so the two emitters don't race.
- **Only `{{env.VAR_NAME}}`** interpolation is supported (`VAR_NAME` is a valid env-var identifier). A malformed token (`{{env.FOO}`, `{{ENV.FOO}}`) is a compile-time error rather than leaking literal braces into the wire header.

## Compile-time lint rules

The compiler walks the resolved config and emits warnings (or errors) for known footguns. Errors block compile; warnings print to stderr.

| Code | Severity | Trigger |
|---|---|---|
| `csp/nonce-not-yet-supported` | error | `@nonce` sentinel used in 0.10.14. Per-request nonce middleware ships in 0.10.15. |
| `csp/unsafe-inline-with-nonce` | error | `@unsafe-inline` and `@nonce` in the same directive (CSP3 silently ignores `unsafe-inline` when nonce is present). |
| `csp/empty-resolved-directive` | error | `*Src` array contains only `@trustedOrigins` and `auth.trustedOrigins` is empty — would emit a directive Chrome reads as `'none'`. |
| `csp/unsafe-inline-script` | warn | `@unsafe-inline` in `scriptSrc` defeats most XSS mitigations. |
| `csp/open-source` | warn | Wildcard `*` or scheme-only `https:` / `http:` in any `*-src`. |
| `csp/unsafe-eval-with-data` | warn | `@unsafe-eval` combined with any `data:` source — trivial bypass. |
| `csp/missing-default-src` | warn | Other `*-src` set without `defaultSrc`. |
| `csp/upgrade-with-http` | warn | `upgradeInsecureRequests: true` plus an `http://` source elsewhere. |
| `csp/report-uri-only` | warn | `reportUri` set without `reportTo` (deprecated CSP2 path). |
| `hsts/preload-needs-long-max-age` | warn | `preload: true` with `maxAge < 31536000`. |
| `hsts/preload-needs-include-subdomains` | warn | `preload: true` without `includeSubDomains`. |

## Verification

Spin up the deployed Worker (or `wrangler dev`) and check the response headers:

```bash
curl -sI https://my-api.example.com/health \
  | grep -E '^(content-security-policy|x-frame-options|strict-transport-security|cross-origin-opener-policy|cross-origin-embedder-policy|cross-origin-resource-policy|referrer-policy|permissions-policy):'
```

Use [securityheaders.com](https://securityheaders.com) for an external grade — Quickback's defaults are A on the baseline and A+ once a fuller `csp` is configured.
