Plate № 27 · Ops · security / audit
A pattern from the gf.cx specimen book
Security-header audit · count the live response, own the headers once
"Auditing and assessment is a vital skill for sites."
Why it's an audit, not a glance
Security response headers fail in two quiet ways, and a glance catches neither. A header can be missing — no Content-Security-Policy, so a scanner marks the site down and nobody notices until the grade drops. Or a header can be duplicated — the same X-Content-Type-Options: nosniff emitted twice because two layers each set it. A browser tolerates the duplicate, so the page looks fine; but it's a signal of drift — two sources both think they own the header, and the day their values disagree you get undefined behaviour and a confusing debug.
Neither shows up if you read the headers with your eyes — a duplicated header reads as "yes, it's there." The audit that catches both is mechanical: don't read the headers, count them.
The method — count, don't eyeball
curl -sSI the live URL and count each security header. Absent (0) means missing; more than one means two emitters. And the pattern of which headers doubled fingerprints the culprit before you've opened a single config panel:
for h in content-security-policy strict-transport-security x-content-type-options \
x-frame-options referrer-policy permissions-policy; do
printf '%-30s %s\n' "$h" "$(curl -sSI https://dare.co.uk/ | grep -ci "^$h:")"
done
content-security-policy 1
strict-transport-security 1
x-content-type-options 2 ← two emitters
x-frame-options 1
referrer-policy 1
permissions-policy 1
The read: only X-Content-Type-Options is doubled; the other three appear exactly once. So the second emitter produces only that one header — which is the exact signature of Cloudflare's Managed Transform → "Add security headers" (it adds precisely nosniff and nothing else). Before touching any dashboard, the counts have already told you there are two sources and named one of them.
The reading — what a doubled header tells you
| Count pattern | What it means |
|---|---|
| a header at 0 | no emitter — missing; a scanner will dock you |
| every header at 1 | one clean owner — the goal state |
| one header doubled, rest single | a second source that emits only that header — often a managed/default layer bolted on beside your own rule |
| several doubled together | two full header-setting layers overlapping (e.g. a Transform Rule and a _headers file both firing) |
The fix — one owner, set not add
A duplicate is almost always an append where an overwrite was meant. Cloudflare's response-header transform has two operations: add appends a header (HTTP permits repeats), set replaces it. When a custom Transform Rule uses add and a managed layer already emitted the same header, you get two. Switch the rule to set and it collapses to one — set overwrites whatever the earlier layer left, so the rule becomes the single effective owner regardless of order.
Single-owner principle: exactly one source of security headers per zone — a custom Transform Rule or Managed Transforms or a_headers/_workerlayer — never two. And for a single-value header,set, neveradd.
On dare.co.uk the fix was one API call. Read the rule (a per-rule GET is refused for this token scheme — read the whole ruleset and filter), then PATCH every header to set:
ZID=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=dare.co.uk" \
| jq -r '.result[0].id')
# the custom rules live in the response-header transform phase entrypoint
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"$API/zones/$ZID/rulesets/phases/http_response_headers_transform/entrypoint"
# switch add → set (overwrite) on the culprit rule
curl -X PATCH -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
"$API/zones/$ZID/rulesets/$RULESET/rules/$RULE" --data '{
"action": "rewrite",
"expression": "true",
"action_parameters": { "headers": {
"X-Content-Type-Options": { "operation": "set", "value": "nosniff" },
"X-Frame-Options": { "operation": "set", "value": "SAMEORIGIN" },
"Referrer-Policy": { "operation": "set", "value": "strict-origin-when-cross-origin" },
"Permissions-Policy": { "operation": "set", "value": "geolocation=(), microphone=(), camera=()" }
}}
}'
Re-run the count. It flaps 2 / 1 for a minute as the ruleset version rolls out edge-node by edge-node, then settles at 1:
for i in $(seq 1 20); do curl -sSI "https://dare.co.uk/?n=$i$RANDOM" \
| grep -ci '^x-content-type-options:'; done | sort | uniq -c
19 1 ← converged (single, fixed)
1 2 ← one stale edge, clears within ~1-3 min
The result — a measured climb to A+
The dedup isn't only cosmetic. The duplicated, trailing-whitespace header was itself failing a test on Mozilla's HTTP Observatory (v5). The only change between the first two live scans was add → set plus trimming the value — one test flipped and the grade moved. Two later, separate passes — hash-pinning the CSP's inline scripts to drop 'unsafe-inline', then a TLS-floor + HSTS-subdomains hardening — carried it the rest of the way to A+:
emitted twice, value
"nosniff "set)one clean
nosniffinline scripts hash-pinned,
'unsafe-inline' droppedWhat was the last deduction — the CSP's 'unsafe-inline' — has since been closed: every executable inline script is hash-pinned (a per-script sha256 in script-src), so 'unsafe-inline' could be dropped, and a min-TLS-1.2 + HSTS-includeSubDomains pass cleared the last transport-layer flags. dare.co.uk now scores A+ · 115 · 10/10. See the live Observatory scan ↗. The lesson the numbers make concrete: a duplicate header isn't cosmetic — a scanner docks you for it — and the climb from B to A+ is a ladder of single-owner, structural fixes, each worth points.
The trap — the invisible second owner
The reason this needs an audit and not a code review: one of the two owners is often nowhere in your repo. On dare.co.uk the custom Transform Rule was findable via the API — but the second nosniff came from a Managed Transform, a dashboard toggle in a different part of Cloudflare, invisible to grep across _headers, _worker.ts, and wrangler.jsonc (all three were clean). You cannot reason your way to "there are two emitters" from the source tree; only the live response shows the truth. That's the whole case for counting the wire over reading the config.
And the same token that edits your custom rule may not reach the managed layer — here the managed_headers endpoint returned "request is not authorized." So don't plan the fix as "turn off the duplicate at its source"; plan it as "make my one owner authoritative with set," which you can do, and which is robust even if the managed layer is later toggled off.
Gotchas
- Sample, don't single-shot. A ruleset edit rolls out per-POP over ~1-3 minutes; a lone
curlcan hit a stale edge and read the old count. Loop 15-20× and tally — a single check will lie to you during convergence. cf-cache-status: HITis a red herring. The response-header transform runs at the edge on cache hits too, so a cached body doesn't mean cached headers. Don't purge trying to "fix" a count that's really just mid-propagation.- Per-rule
GETis refused for scoped tokens (10405 — method not allowed for this authentication scheme). Read the whole ruleset (GET …/rulesets/{id}) and filter by rule id;PATCH …/rules/{id}still works to write. - Trailing whitespace hides in header values. The culprit rule had
"nosniff "and"SAMEORIGIN "— invisible on the wire, real in the config. Rewriting tosetis a free moment to trim them; audit values, not just presence. - Grade externally, too.
securityheaders.comnow 403s automated requests; use the Mozilla Observatory API —POST https://observatory-api.mdn.mozilla.net/api/v2/scan?host=<host>→{grade, score, tests_passed}. A CSP that needs'unsafe-inline'costs two points but is not a duplicate — know which deductions are structural before you chase them.
Where it applies
- Any zone behind Cloudflare where headers can be set in more than one place — a Transform Rule, Managed Transforms, a Pages
_headersfile, a Worker — i.e. most of them - Portfolio-wide sweeps: run the count across every host and flag any header at
0(missing) or>1(doubled) — the same two-line loop is your drift detector - Post-migration checks — moving a site behind a new proxy is exactly when a second, forgotten header layer starts overlapping the first
- Skip the count only when a single layer provably owns every header and nothing else can inject — rare enough that it's cheaper to just run the loop
Reusable elements
The named, copyable pieces — lift any one without the others:
- the count loop —
curl -sSI | grep -ci "^$h:"over the header list;0= missing,>1= duplicated. The whole audit in two lines. - the fingerprint read — which headers doubled names the second emitter (one header alone doubled → a managed/default single-header layer).
- the single-owner rule — one header source per zone; the others get removed, not reconciled.
set-not-add— for single-value headers, overwrite semantics make your rule authoritative and dedup by construction.- the sample-until-settled check — loop the count 15-20× post-edit; a ruleset change is only "done" when the tally stops flapping.
- the Observatory grade —
observatory-api.mdn.mozilla.net/api/v2/scanfor an external second opinion the browser-side count can't give.
Reference
- Source
- Dan, 2026-07-25 — "auditing and assessment is a vital skill for sites", after a security sweep flagged dare.co.uk's headers.
- In use / example
- dare.co.uk —
X-Content-Type-Optionswas emitted twice (a custom Transform Rule withaddplus the "Add security headers" Managed Transform); fixed to a singlenosniffby switching the rule toset. That flipped the Observatoryx-content-type-optionstest and lifted the grade from B / 75 to B+ / 80 (CSP had shipped the day prior); a later CSP hash-pin (dropping'unsafe-inline') plus a min-TLS-1.2 + HSTS-subdomains pass carried it to A+ · 115 · 10/10. - Reusable elements
- the count loop · the fingerprint read · the single-owner rule ·
set-not-add· the sample-until-settled check · the Observatory grade (listed above). - Kin
- Visual verification · external probe and Diagnostic probes panel — the same "assess from outside, on the live thing" instinct, applied to render and to health.
- Origin
- A duplicate header a code review would never have found — because the second owner lived in a dashboard toggle, not the repo. The audit's rule: count the wire, not the config; own each header once.