Security

Security

White Tree Digital is a read-only marketing site with two write-shaped surfaces — draft preview and the /api/audit lead magnet — and the security model is built around protecting exactly those two. Draft mode is gated by a signed companion cookie so a forged perspective cookie can't steal the production read token; the audit endpoint is hardened against quota-burning scripts; and the audit's full findings never cross the wire to the browser.

There is no user auth, no session, no database the site writes to. The threats that matter are: (1) someone forging their way into draft mode to read unpublished drafts and bypass the edge cache, (2) someone burning the metered PageSpeed Insights quota that powers the audit tool, and (3) leaking the full audit report (a deliverable behind an email gate) to anyone who reads a network response. Each has a specific, code-level defense documented below.

The draft-mode HMAC gate

Draft preview works by setting a sanity-preview-perspective cookie. SSR routes that see it switch to the stega-enabled preview client, which attaches the production SANITY_API_READ_TOKEN and reads unpublished drafts straight from Sanity (bypassing the CDN). That is a lot of power to hang on one cookie — so the perspective cookie alone is not trusted.

The problem: the perspective cookie's name comes from a public constant (@sanity/preview-url-secret/constants), and it must be set httpOnly:false because the Presentation Tool overlay reads it from JavaScript. Both facts mean its presence is trivially forgeable. A forged perspective cookie would otherwise get the token-attached preview client and bypass the edge cache.

The defense is a second, signed companion cookie. At enable.ts time the site mints sanity-preview-auth — an httpOnly cookie whose value is an HMAC over an expiry timestamp:

/** `${expiryEpochMs}.${hmac(expiryEpochMs)}` */
export async function createDraftAuthValue(secret: string): Promise<string> {
  const exp = Date.now() + MAX_AGE_MS;
  const key = await importKey(secret);
  const sig = await crypto.subtle.sign('HMAC', key, enc.encode(String(exp)));
  return `${exp}.${toBase64Url(sig)}`;
}

The HMAC key is SANITY_API_READ_TOKEN — already a runtime secret on both sides of the handshake, so the gate needs no new secret to provision. The cookie has an 8-hour expiry (MAX_AGE_MS = 8 * 60 * 60 * 1000) baked into the signed payload, so verifyDraftAuthValue rejects anything past exp before it even checks the signature. Only enable.ts can mint a valid value, and only after validatePreviewUrl confirms the one-time preview secret from Studio.

The middleware enforces this first thing on every request, before any route reads a cookie:

const validateDraftCookie = defineMiddleware(async (context, next) => {
  if (context.cookies.has(perspectiveCookieName)) {
    const token = env.SANITY_API_READ_TOKEN ?? import.meta.env.SANITY_API_READ_TOKEN;
    const auth = context.cookies.get(draftAuthCookieName)?.value;
    const valid = token && auth ? await verifyDraftAuthValue(auth, token) : false;
    if (!valid) {
      context.cookies.delete(perspectiveCookieName, {path: '/'});
      context.cookies.delete(draftAuthCookieName, {path: '/'});
    }
  }
  return next();
});

A perspective cookie that arrives without a valid signed companion — missing, forged, or expired — gets both cookies deleted from the request before any route or the edge cache sees them. The request is silently demoted to published content. The signature check (crypto.subtle.verify) runs before the edge-cache middleware in the sequence(...), so a forged cookie never reaches the draft-bypass branch of the cache either.

Order matters: the gate runs before the cache

onRequest = sequence(validateDraftCookie, edgeCache). The draft gate must run first — the edge cache decides whether to bypass based on isDraftMode(cookies), and if a forged perspective cookie reached that check before being stripped, it would skip the cache and pull the token-attached client. Never reorder the sequence so the cache runs before the validator.

The full draft-mode handshake (URL contract, the stega-enabled preview client, the click-to-edit overlay) lives on Presentation Tool & draft mode. This page covers only the security gate.


/api/audit hardening

The audit endpoint (src/pages/api/audit.ts) is the one route that calls an external, metered API on behalf of an anonymous visitor: every audit triggers a 30–60s PageSpeed Insights run against a quota'd key (25k/day free). Without protection, a trivial script could exhaust the quota in minutes and the lead magnet would start erroring for real prospects. Three layers guard it.

1. Same-origin Origin check

The audit widget always posts same-origin, so a missing or foreign Origin header is a script, not a visitor:

const origin = request.headers.get('origin');
if (!origin || new URL(origin).host !== requestUrl.host) {
  if (!import.meta.env.DEV) return json({error: 'forbidden'}, 403);
}

This is spoofable by a determined attacker, but it stops the naive curl loop. It's disabled in dev so local testing tools work.

2. Per-IP rate limit (in-isolate)

A sliding window keyed on cf-connecting-ip allows 5 audits per 10 minutes per IP, returning 429 with Retry-After: 600 past the limit:

const RATE_WINDOW_MS = 10 * 60_000;
const RATE_MAX_PER_WINDOW = 5;
const RATE_MAX_TRACKED_IPS = 10_000;

The state is a Map that lives in the Worker isolate, and the map is cleared wholesale if it grows past RATE_MAX_TRACKED_IPS (a crude memory cap). Because isolates reset and a request can land on any one, this is a bar-raiser, not a guarantee — it catches slow drips but a burst across cold isolates could slip through.

3. The Cloudflare WAF backstop

The real backstop is a Cloudflare rate-limiting rule, configured in the dashboard (not in code), per _LAUNCH-RUNBOOK.md §3:

  • Match: URI Path equals /api/audit AND Method equals POST
  • Rate: 3 requests per 10 seconds per IP (the free-plan minimum period)
  • Action: Block for the maximum duration available

The in-code limiter handles slow drips; the WAF rule kills burst scripts. They're complementary, not redundant.

The in-isolate rate limit is not a hard guarantee

RATE_MAX_PER_WINDOW = 5 is per-isolate state in a Map — it does not survive isolate recycling and isn't shared across isolates. Don't treat it as the only line of defense. The Cloudflare WAF rule (launch runbook §3) is the durable backstop; if you add or change the route, re-verify that rule still targets it.


The teaser is a lossy security gate

The audit runs ~26 deterministic checks server-side, producing a full RanCheck[] with every check's id, status, value, and fix copy. None of that detail crosses the wire. The endpoint returns only the teaser:

return json(teaser, 200); // ONLY the teaser shape leaves the Worker

The gate lives in the type. A TeaserFinding deliberately carries no id, status, value, fix, or pass copy — only the symptom and a locked count:

// The ONLY shape that crosses the wire to the browser. The gate lives in this
// type: a finding deliberately carries NO id, status, value, fix, or pass copy.
export interface TeaserFinding {
  group: string;
  headline: string;
  line: string;
  subIssues: number; // locked count rendered as "<lock> N issues"
}

A technical visitor reading the network response sees the symptom plus a locked issue count — never the gated detail. The full report (the deliverable behind the email gate) is computed inside the Worker and discarded after the teaser is built. The same constraint flows downstream: the HubSpot submission (hubspot.ts) can only send teaser-level fields (audit_score, audit_total_issues, audit_finding_1..3) because that's all the client ever has.

This is enforced by a test invariant (checks.test.ts) — the build treats a teaser that leaks gated detail as a security regression, not a cosmetic bug. The scoring, check registry, and teaser selection are documented on Audit tool: checks, scoring & teaser.

The DEV debug readout never ships

/api/audit?debug=1 returns a rich inspection payload (per-check status/value, psiOk, htmlOk, requestHosts, etc.) — but the whole branch is gated on import.meta.env.DEV, which is compile-time false in production, so it's dead-code-eliminated and never reaches the production bundle. Don't gate it on a runtime flag instead; the compile-time elimination is what keeps it from shipping.


Secrets handling

Two layers of environment configuration, with a hard rule about where secrets live. See Environment variables for the full table.

LayerMechanismExamples
Public, build-timeimport.meta.env.PUBLIC_* (baked into the bundle)PUBLIC_SANITY_PROJECT_ID, PUBLIC_HUBSPOT_PORTAL_ID, PUBLIC_SITE_URL
Runtime secretsimport { env } from 'cloudflare:workers'SANITY_API_READ_TOKEN, PSI_API_KEY

Runtime secrets are read from .dev.vars locally and from an encrypted Secret in the Cloudflare Workers dashboard in production. Server code reads them via the Workers runtime env, falling back to import.meta.env for local dev:

const psiKey = env.PSI_API_KEY ?? import.meta.env.PSI_API_KEY;
if (!psiKey) return json({error: 'config'}, 500);

SANITY_API_READ_TOKEN does double duty: it reads drafts and is the HMAC key that signs the draft-auth cookie. PSI_API_KEY powers the server-side PSI call — and because the call is server-side, the Google Cloud key restriction must be set to None (no Referer header is sent; a referrer restriction would reject every request).

Secrets never go in .env, and never get committed

SANITY_API_READ_TOKEN and PSI_API_KEY live in .dev.vars locally (a Cloudflare convention, distinct from .env) and as encrypted Secrets in the CF dashboard — never in .env, never committed. Both .env and .dev.vars are in .gitignore. There used to be a PUBLIC_PSI_API_KEY plaintext var; it was deleted because a PUBLIC_* PSI key would ship to the client and be visible to anyone. If you ever see a real key/token/deploy-hook value in a file, treat it as a leak — rotate it, don't document it.

The Cloudflare deploy hook for the public site (a secret POST URL fired by Studio's "Deploy" tool to rebuild static content) is also a secret — it lives as SANITY_STUDIO_DEPLOY_HOOK_URL / DEPLOY_HOOK_URL and must never be committed. See Deployment and Two-build split.


Disposable-email blocklist

The audit's email gate (WebsiteAudit.tsx) screens out throwaway/disposable email providers before a lead is submitted — a soft anti-abuse measure on the lead funnel. It blocks disposable providers only, never free providers like gmail.com, yahoo.com, or icloud.com.

The blocklist (disposable-email-domains) is a 2.4 MB JSON, so it must never be imported statically — that would inline it into the client:load island and ship 2.4 MB to every visitor on first paint. Instead it loads as a lazy async chunk, warmed when the email gate appears:

let loadPromise: Promise<Set<string>> | null = null;

function loadDomains(): Promise<Set<string>> {
  loadPromise ??= import('disposable-email-domains').then(
    (m) => new Set<string>((m.default as string[]).map((d) => d.toLowerCase())),
  );
  return loadPromise;
}

The check fails open: a chunk-load failure must never block a real lead, so isDisposableEmail returns false when the list can't load. WebsiteAudit.tsx calls preloadDisposableList() the moment the teaser arrives (when the email gate appears), so the list is usually resident before the visitor finishes typing.

Never import the blocklist statically

A top-level import 'disposable-email-domains' ships 2.4 MB to every visitor and blows the <2 MB first-load budget. Always go through the dynamic import(...) in disposable.ts. It's an anti-abuse nicety, not a hard gate — keep it failing open.


Author-supplied HTML is rendered as-is

Two section types let editors paste raw markup that the site renders via set:htmlcodeBlock (Studio schema codeBlock) and ctaSection's embed mode (the HubSpot/Tally form embed). There is no sanitization: pasted HTML, embeds, and <script> tags render verbatim. This is acceptable only because the Studio is single-author (the one-person studio's own content), so the trust boundary is the editor, not the public. The Studio schemas carry warnings to that effect.

Paste embed/HTML only from trusted sources

codeBlock and the ctaSection embed field render their content as-is, including <script> tags, with no sanitization. They're safe because only the site owner authors content. If the Studio ever gains multiple/untrusted authors, these fields become a stored-XSS vector and need sanitization or removal first.


Where this lives

  • website/src/middleware.tsvalidateDraftCookie (the HMAC gate, runs first) + edgeCache, wired as sequence(validateDraftCookie, edgeCache).
  • website/src/lib/draft-auth.tscreateDraftAuthValue / verifyDraftAuthValue, the sanity-preview-auth cookie, the HMAC keyed on SANITY_API_READ_TOKEN, 8h expiry.
  • website/src/pages/api/draft-mode/enable.ts — mints the signed companion cookie after validatePreviewUrl confirms the Studio preview secret.
  • website/src/pages/api/audit.ts — same-origin check, per-IP rate limit, PSI_API_KEY sourcing, DEV-only debug readout, and return json(teaser, 200).
  • website/src/components/audit/teaser.tsTeaserFinding / AuditTeaser — the only shape that crosses the wire.
  • website/src/components/audit/disposable.ts — the lazy-loaded, fail-open disposable-email blocklist.
  • website/src/components/audit/hubspot.ts — submits teaser-level fields only (gated detail can't reach it).
  • _LAUNCH-RUNBOOK.md §2–§3 — the CF env/secret mirror and the WAF rate-limit rule for /api/audit.
  • website/CLAUDE.md — "Draft mode + Presentation Tool", "Environment", "Free Website Audit funnel".

Related pages: Presentation Tool & draft mode · Audit tool: checks, scoring & teaser · Environment variables · Deployment.

Previous
Accessibility