TL;DR: A German law firm's website did consent better than anything I have measured: a fresh profile loaded zero cookies, zero third-party requests, zero iframes — only the consent manager itself, with fonts served locally. Seven days after the fix for an unauthenticated theme XSS was published, the site still ran the vulnerable version, with no CSP anywhere to contain it. Add zero of six security headers site-wide, an anonymous CSRF-token endpoint on the REST API, publicly readable readme/license files and a DMARC policy that forgives forged client mail. Two teams, two maturity levels, one origin — and the weaker one sets the risk.
The scenario
The client is a German law firm. Client money moves on written instruction, so mail authenticity is not a compliance topic there — it is an operational control. I was asked to review the public web surface: non-intrusive, no payloads against the live host, no login attempts. Headers, DNS, TLS, REST probes, and a headless browser with a fresh profile.
The first test is always consent, because it tells me how the site treats the visitors it does not have to. It came back green in a way I had not seen before: zero cookies, zero third-party requests, zero iframes before any interaction. Only the consent manager itself loaded. Google Fonts served locally. That is textbook — better than textbook, because most sites with a consent tool still fire something.
Then I ran the same fresh profile against the rest of the surface, and the picture split in half.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
Maturity in one dimension does not transfer
Whoever configured the consent stack cared, tested, and understood the topic. No third-party request before consent is not a default — it is a decision that someone made and verified. The same site shipped zero of six baseline security headers on every page. Those two facts coexisted for the same reason they coexist everywhere: privacy and security are different owners, different ticket queues, different definitions of done. A review that stops at the first green area misses the finding. You have to measure every dimension separately, because organizations do not leak maturity sideways.
A patch window is a public countdown, not an internal note
The site's theme was one patch behind an unauthenticated reflected XSS fix that had been public for seven days. Rendering the homepage tells you the version. Mapping that version against current advisories takes minutes. The gap between "fix published" and "installed" is measurable from the outside, and anyone with a scanner computes it automatically. The uncomfortable part is not the vulnerability — it is that a seven-day window cannot be distinguished from an unpatched site by any external observer. The only defense is to close the window before it becomes a statistic.
"The login is hardened" proves capability, not coverage
The login page had X-Frame-Options, a frame-ancestors policy and a Secure test cookie. Every other page had nothing. That asymmetry is diagnostic: someone in the organization knew the header baseline and applied it exactly once, at the page that historically gets attacked. But a reflected XSS does not care about the login form — it executes in the origin of whatever page the victim is on, and a victim who is logged in is exactly the target. Coverage is the metric, not capability.
An anonymous nonce endpoint is a preassembled attack tool
The REST index was 328 KB of route names — including security-plugin and editor namespaces — and one endpoint returned a CSRF nonce plus the admin path to any anonymous caller. User enumeration was properly blocked at the same API, which shows the lockdown was deliberate but incomplete. Token endpoints are the worst thing to leave open: enumeration gives an attacker a map, a nonce gives them a key blank they can cut later.
For some sectors, mail is the perimeter
SPF ended in -all, which is the part everyone gets right. DMARC sat at p=quarantine with no DKIM selector found — so mail that fails alignment is quarantined, not rejected, and a mailbox provider's spam decision becomes the last line of defense. For a law firm, the realistic fraud is not spam: it is a forged instruction to a client, with new bank details. That threat never touches the website. It is decided entirely by DMARC and DKIM, which makes them client-protection controls, not hygiene.
The control that worked — and shaped the engagement
The security plugin rate-limited us off the site after roughly sixty requests. That is a working control, and it is worth saying so: most sites I test do not react at all. It also disciplined the audit — the block was respected, affected probes were re-run after it lifted, and the timeline is documented in the evidence. A defense that changes an attacker's schedule is a defense doing its job.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HOCH | Unauthenticated reflected XSS in the theme, seven days after the public fix — no CSP anywhere to contain it | Version on the wire vs. advisory timeline; lab mirror echoes the payload unescaped |
| HOCH | Zero of six baseline security headers on every page except the login | Header capture of homepage, contact and login in direct comparison |
| MITTEL | REST index (328 KB) anonymous; an endpoint returns a CSRF nonce and admin path without login | REST capture; token endpoint response |
| MITTEL | wp-cron.php publicly reachable — unauthenticated cron trigger | HTTP 200 on the live site |
| MITTEL | DMARC only p=quarantine, no DKIM selector — forged client mail is quarantined, not rejected | DNS TXT records |
| MITTEL | No SRI on the consent vendor's external scripts | Homepage HTML |
| NIEDRIG | readme.html and license.txt open; pages countable (25 pages, 28 posts, 181 media); ftp/dev legacy hosts on foreign servers | Path probes, REST counts, DNS |
| NIEDRIG | CMP storage entry written before consent; 90-day certificate cycle without HSTS | Headless capture, TLS record |
| GOOD | Consent architecture: zero cookies, zero third-party requests, zero iframes before opt-in; hard paths 403; user enumeration blocked three ways; rate limiting worked | Fresh-profile capture; path probes; the block itself |
The attack path
Reproducing it in the lab
Every probe below targets a lab mirror I control. None of it is directed at a live system.
# 1. the control: consent in a fresh profile
TARGET_URL=http://lab.local/ node consent-audit.js
# <- cookies: 0 third-party hosts: only the CMP banner: present
# 2. the same origin, the six headers
curl -sI http://lab.local/ | grep -ciE \
'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'
# <- 0
curl -sI http://lab.local/wp-login.php | grep -iE 'x-frame|content-security'
# <- the login is hardened — capability, not coverage
# 3. version vs. advisory
curl -s http://lab.local/ | grep -oE 'ver=[0-9.]+' | sort -u
# <- theme version one release behind the fix
# 4. the payload, lab only
curl -s 'http://lab.local/?lang=%22%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E' \
| grep -o 'img src=x'
# <- vulnerable build echoes it raw; hardened build returns <img
# 5. the token endpoint
curl -s http://lab.local/wp-json/security-plugin/v1/authenticate
# <- vulnerable: {success, nonce, adminUrl}; hardened: 401
# assertions the lab mirror checks, vulnerable vs. hardened:
# security headers count 7 checks: 0 -> 6
# lang reflection raw -> escaped
# readme.html / license.txt 200 -> 404
# REST user list 200 with names -> 401
# token endpoint 200 + nonce -> 401
# wp-cron.php 200 -> 403
Detecting it in your own estate
- Run the consent test and the security test as separate drills with separate owners. A green privacy result can hide an unpatched theme for months, because nobody re-opens the security backlog after a compliance win.
- Track patch windows as a number: for every component, days between the fix publication and your deployment. Alert on anything above your policy threshold instead of waiting for the next release train.
- Assert the header baseline on every vhost and every route class, not on the login. If the login is the only hardened page, the hardening was done by hand — automate it or it will never spread.
- Inventory REST endpoints by what they return to anonymous callers. Any endpoint returning a nonce, token or URL is a finding, even if user enumeration is blocked elsewhere.
- Treat DMARC and DKIM as client-protection controls: DKIM first, then move to
p=rejectonce the reports stay clean. Quarantine is a suggestion; reject is a decision. - Measure your own defenses from the outside. The rate limit on this site fired and changed the engagement — know the threshold and verify it works before an adversary does.
Remediation
# immediately
1. update the theme to the fixed release; verify the new version on the wire
2. ship the header baseline everywhere (HSTS, CSP, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy) — .htaccess or vhost, not per page
# within one week
3. restrict the REST API to authenticated users; disable vendor and token
endpoints for anonymous callers
4. block readme.html, license.txt and wp-cron.php; switch to DISABLE_WP_CRON
with a system cron
5. enable DKIM at the mail gateway and publish the selector; plan the move
to DMARC p=reject after 30 clean report days
# within one month
6. SRI or CSP hashes for the consent vendor; publish security.txt (200);
add CAA, inventory the legacy hosts, add certificate renewal alerting
Once an attacker gains access, they can perform the following actions:
Takeaways
- Privacy maturity does not transfer to security maturity. They have different owners, different queues, and the weaker one sets the risk.
- A patch window is external, public and countable: version on the wire plus fix publication date. Seven days unpatched and never patched look identical from the outside.
- Hardening one page proves someone cared. Hardening every route proves the control exists. Reflected XSS targets the pages you did not harden.
- Token endpoints are worse than enumeration. A map is reconnaissance; a nonce is an attack tool.
- For professional services, mail authenticity is client protection. SPF with
-allis a start; DKIM andp=rejectare the finish. - Praise what works. The consent layer and the rate limiter genuinely worked — the report says so, because security advice that only counts failures is not advice, it is a scoreboard.