Reading time: 11 min read

Hardening a SitecoreAI donation site: CSP and Turnstile

A log-first rollout pattern: capture real-traffic data before any control is allowed to block anything, applied first to Content Security Policy and then to bot protection.

Portrait photo of Sohrab Saboori, article author

Never turn on a blocking control blind

A donation platform is two sites wearing one domain. It is a payment-adjacent application handling personal data, where script injection and bots are real threats. And it is a marketing site, full of third-party tags: a tag manager, analytics, ad pixels, several payment-wallet SDKs. Those tags change without a code deploy, and nobody on the engineering team fully controls them.

That tension shaped how we hardened a headless SitecoreAI donation site. The rule we kept coming back to: never turn on a blocking control blind. Every control ships in a logging mode first. The logs get mined against real traffic, the findings get applied, and only then does enforcement flip on, behind a kill switch, with logging kept on forever.

We applied that pattern twice, to Content Security Policy and to bot protection. Here is how both went, including the parts that surprised us.

CSP on a statically generated site

Modern CSP advice says: use nonces and 'strict-dynamic'. A nonce has to be unique per request, which means the page has to be rendered per request; Next.js is explicit that nonce-based CSP requires dynamic rendering and disables static generation and ISR [1]. Our pages are SSG/ISR. Every visitor gets the same HTML, so there is nothing per-request to stamp a nonce into.

The realistic options were: move rendering to per-request SSR and pay for it on every page view forever, or accept a host-allowlist CSP with 'unsafe-inline' still present. We chose the allowlist and stayed honest about what it buys. Our script-src still carries 'unsafe-inline', and for now 'unsafe-eval' as well; both are listed here as known weaknesses, not recommendations, and MDN is blunt that 'unsafe-inline' defeats much of the purpose of a CSP [2]. What the allowlist does buy is this: inline injection is not prevented, but injected code cannot load second-stage payloads from non-allowlisted origins. The protective value concentrates in a strict script-src list, so that is where we spent the effort.

The policy ships from the headers() function in next.config.js on every route [3], in two headers.

Content-Security-Policy carries a minimal, always-enforced part: frame-ancestors pinned to the SitecoreAI editing hosts, for clickjacking.

Content-Security-Policy-Report-Only carries the full policy. It blocks nothing and reports everything to report-uri /api/csp-report. Report-only supports every directive except sandbox [4], so the whole policy can soak in this mode.

One note on the reporting directive itself. report-uri is deprecated in favour of report-to, and browsers that support report-to ignore report-uri when both are present. MDN's advice is to declare both until report-to has full cross-browser support [5]. We started with report-uri because every browser we saw honoured it; adding a Reporting-Endpoints header and report-to is on the list.

Report-only is useless without classification

The first thing you learn running report-only CSP in production: the raw report stream is overwhelmingly noise. Browser extensions violate your policy. Browsers still holding pages from last week's deployment report against a policy you already fixed. Some reports are not even about your policy.

If a human has to read every report, the soak dies of fatigue in a week. So the collection endpoint classifies every report at ingest and tags the log line:

import type { NextApiRequest, NextApiResponse } from 'next';
// Stream the body ourselves so we can cap it at 16 KB.
export const config = { api: { bodyParser: false } };
const NOISE_SCHEMES = ['chrome-extension', 'moz-extension', 'safari-extension'];
const NOISE_HOSTS = ['vercel.live', 'pusher.com']; // preview-deploy toolbar
// Documented decisions NOT to allowlist. Each entry has a written rationale
// in the rollout doc. Reports for these log as [accepted], so [NEW] stays
// purely actionable.
const ACCEPTED: { reason: string; matches: (url: URL) => boolean }[] = [
  {
    reason: 'legacy page double-loads a library from a public CDN; content cleanup pending',
    matches: (url) => url.hostname === 'unpkg.com',
  },
  {
    reason: 'remarketing pings go to the visitor country TLD; accepted loss',
    matches: (url) =>
      /(^|\.)google\.[a-z.]+$/.test(url.hostname) &&
      url.pathname.startsWith('/ads/ga-audiences'),
  },
];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const report = extractReport(await readBody(req, 16 * 1024));
  if (isNoise(report)) {
    console.warn(`[csp-report][noise] ${compact(report)}`);
  } else if (acceptedReason(report)) {
    console.warn(`[csp-report][accepted] ${compact(report)}`);
  } else if (await allowedByCurrentPolicy(report)) {
    // Allowed by the policy THIS deployment serves: the report came from a
    // browser still holding a page served under an older deployment.
    console.warn(`[csp-report][known] ${compact(report)}`);
  } else {
    // A real gap in the allowlist. The only tag that needs action.
    console.warn(`[csp-report][NEW] ${JSON.stringify(report)}`);
  }
  res.status(204).end();
}

Four tags, one of which matters.

[NEW] means the blocked load is not allowed by the policy this deployment serves. A real allowlist gap. The full report JSON gets logged.

[known] means the load is allowed by the current policy; the report is an echo from an older deployment. One compact line, ignore.

[noise] covers extensions, preview-deployment toolbars, and local dev websockets. Ignore.

[accepted] marks a documented decision not to allowlist, with the reason inline. Ignore, but visibly.

The [known] classification is the clever bit and worth explaining. The endpoint parses its own deployment's policy, from the same config that emits the header, and replays each report against it, including wildcard semantics and CSP's directive fallback chains. That last part matters because a violation report names the effective directive, not the one you wrote: a report will say script-src-elem even when your policy only sets script-src or default-src [6]. Everything still logs under some tag, so a misclassification can never hide a finding, but daily review is just filtering the logs for [NEW].

Two hardening details on the endpoint itself, since it is unauthenticated by nature. The body is streamed with a hard 16 KB cap. Anything unparseable is logged through JSON.stringify, so a crafted body with newlines cannot forge extra log lines.

This endpoint is the "log method" at the center of the whole rollout: one dumb collector that grabs the data, and a classifier that turns it into a work queue.

What the logs taught us

Weeks of real-traffic soak produced findings that code review never would have.

Wildcards never match the apex domain. A host source such as *.analytics.google.com permits subdomains only [2], so it does not cover analytics.google.com, and in our reports GA4 posted its collect calls to the apex. Several third parties needed both the wildcard and an explicit apex entry.

SDKs phone home in undocumented ways. One wallet SDK loads its own button fonts from its CDN. Another pings a different company's apex domain for cross-wallet detection. A third fetches a web manifest nobody mentions. Every one of these surfaced as a [NEW] report, not from documentation.

Some host sets cannot be enumerated. Ad remarketing beacons go to the visitor's country domain: google.ca, google.de, and so on for every geo. You cannot allowlist the set. We wrote it down as an accepted loss (remarketing data for foreign visitors only; donations and analytics unaffected) and taught the classifier to tag those reports [accepted].

A tag manager means your policy changes without deploys. Marketing adds a pixel in the tag manager and a new origin appears in production the same afternoon. The container has to be audited before enforcement, and drift has to be expected forever after. That is exactly why the report endpoint stays on after enforcing.

Dead weight shows up too. The soak confirmed a payment widget script that loaded globally but was used by no page. Instead of allowlisting its three origins, we removed the script. Sometimes the fix for a CSP report is deleting code.

A pragmatic retreat on connect-src

Nine of our first ten soak findings were connect-src: analytics geo endpoints, SDK beacons, apex-versus-wildcard traps. Meanwhile the directive that actually gates code execution is script-src. And connect-src had a scarier property. Some payment rails on this platform have no end-to-end test environment; only real donors exercise them. An over-tight connect-src was therefore the directive most likely to silently break a flow nobody could test.

So we made a deliberate trade: connect-src 'self' https:. Data channels flow to any HTTPS host; executable code stays gated by the strict script-src allowlist, along with frame-src, object-src 'none', and base-uri. This weakens the exfiltration story, and we said so plainly in the security doc rather than pretending otherwise. For a donation platform where the untestable flows are payments, it was the right call. The same logic applied to form-action: omitted until every payment provider's POST target is confirmed from telemetry, because a wrong guess there blocks a donation mid-payment.

Textbook CSP is a means, not the goal. The goal is "injected code can't execute or load payloads, and nothing legitimate breaks."

Enforcement as an environment flag

Promotion to enforcement is a build-time environment flag, set per environment.

Flag unset gives you the enforced frame-ancestors part only, with the full policy in report-only. Nothing blocks.

CSP_ENFORCE=true gives you one enforced Content-Security-Policy carrying the full policy plus upgrade-insecure-requests [7], still with report-uri. From here, a [NEW] line means something on the page is actually broken. The same log stream changes meaning from "gap" to "incident".

Staging enforces first and sits through a full round of acceptance testing. Production is promoted only after its own [NEW] stream has been quiet, accepted patterns aside, for a couple of weeks of real traffic. Real donors, real geos, and real tag-manager campaigns generate coverage no test plan can.

Bot protection, same playbook

The second application of the pattern was bot protection on form submissions, using Cloudflare Turnstile. The lesson that matters is server-side: the widget only gates the submit button. A bot that POSTs your API directly never sees the widget, and a token can simply be forged, which is why Cloudflare requires the server to validate every token against its siteverify endpoint [8]. So every form-submit proxy route verifies the token server-side before forwarding the submission.

Verification runs in one of three modes, resolved per form family. Static forms and payment forms flip independently, because payment flows have a nasty edge: a donor returning from a wallet-payment redirect legitimately arrives without a fresh token.

import type { NextApiRequest } from 'next';
export type TurnstileMode = 'off' | 'log' | 'enforce';
type Family = 'static' | 'payment';
type Outcome = 'off' | 'pass' | 'fail' | 'missing-token' | 'no-secret' | 'siteverify-error';
function resolveMode(family: Family): TurnstileMode {
  const raw =
    family === 'payment'
      ? process.env.TURNSTILE_MODE_PAYMENT
      : process.env.TURNSTILE_MODE_STATIC;
  // ANY other value (including unset) resolves to 'log': a missing env var
  // must degrade to observing, never to blocking donors.
  return raw === 'off' || raw === 'enforce' ? raw : 'log';
}
// verifyToken() POSTs the token to Cloudflare's siteverify endpoint with a
// 5-second timeout and maps the result to an Outcome. Omitted here.
export async function turnstileGate(req: NextApiRequest, family: Family, route: string) {
  const mode = resolveMode(family);
  if (mode === 'off') return { allow: true, outcome: 'off' as Outcome };
  const outcome: Outcome = await verifyToken(req);
  // One structured line per verification. The rollout is driven entirely
  // by filtering the logs on [TURNSTILE].
  console.warn('[TURNSTILE]', JSON.stringify({ route, family, mode, outcome }));
  // Only an explicit rejection or an absent token blocks, and only in
  // enforce mode. Infrastructure problems ('no-secret', 'siteverify-error')
  // always pass: fail-open by design, so a verifier outage can never take
  // donations down with it.
  const reject = mode === 'enforce' && (outcome === 'fail' || outcome === 'missing-token');
  return { allow: !reject, outcome };
}

Two safety properties are doing the real work.

Misconfiguration degrades to logging, not blocking. An unset or mistyped env var on one deployment resolves to log. The failure mode of sloppy ops is "we observe", never "donors bounce".

Infrastructure failures fail open. Secret missing, verifier unreachable, timeout: the submission passes, in every mode. Bot protection that can take your payment flow down with it has negative value. Only an explicit "this token is bad" verdict, or no token at all, blocks, and only in enforce mode.

The log lines then run the rollout exactly like the CSP reports did. One form showing 100% missing-token told us the client wiring for that form had been missed, a bug found by the log mode before enforce mode would have turned it into lost submissions. Spikes of the timeout-or-duplicate error code map to token expiry on wallet-redirect round-trips: a Turnstile token is valid for 300 seconds and single-use [8], and a payment redirect can outlast that. That is why the payment-form family enforces last.

The pattern, extracted

  1. Ship the control in a logging mode against real production traffic.
  2. Classify at ingest. Separating actionable from known, noise, and accepted is what keeps the soak sustainable.
  3. Apply the findings: allowlist entries, client fixes, sometimes deleting dead code.
  4. Enforce behind a flag, per environment, staging first, with the log stream still on.
  5. Keep logging forever. Third parties drift; yesterday's clean policy is next quarter's incident.

None of this is exotic, which is rather the point. The logs did the security review for us. Every allowlist entry on the platform traces back to either a report or a written decision, and that audit trail was the cheapest documentation we have ever produced.

Sources

  1. How to set a Content Security Policy (CSP) for your Next.js application — Next.js Documentation
  2. Content-Security-Policy — MDN Web Docs
  3. headers — Next.js Documentation (next.config.js, Pages Router)
  4. Content-Security-Policy-Report-Only — MDN Web Docs
  5. CSP: report-uri — MDN Web Docs
  6. CSPViolationReportBody: effectiveDirective property — MDN Web Docs
  7. CSP: upgrade-insecure-requests — MDN Web Docs
  8. Server-side validation — Cloudflare Turnstile Documentation