TL;DR: One finding, maximum impact: a wildcard cross-origin policy on the public data interface. No version check, no exploit — the browser does the work. I show the exact request and the allowlist fix.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A municipal content platform on a supported content system behind a modern reverse proxy. The assessment was narrow by design, examining the public data interface and the cross-origin policy governing browser access to it.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
Cross-origin policy is a browser mechanism, and that is what makes it both powerful and cheap. When a server answers a cross-origin request with a permissive policy, the browser enforces it: the requesting page may read the response. The server did nothing wrong by its own lights, no version is out of date, and no payload is required. A single HTML file on any domain is the whole attack.
The distinction that matters is between a wildcard and a reflected origin. A wildcard on a public, unauthenticated read endpoint is frequently a deliberate convenience: the data is public, so the developer reasons that anyone may fetch it. The reasoning is correct about the data and wrong about the browser. The endpoint is not merely readable, it is readable from a page the visitor is currently looking at, which turns it into a same-origin resource for any script that gets to run there.
This is why a single finding can outrank a long list of version notes. Version findings describe what an attacker could do after winning access. A permissive cross-origin policy decides, for every future injection on every page of the site, whether the attacker gets to read the response without needing a foothold at all. It converts every other bug on the estate from minor to major, which is why it belongs at the top of the report regardless of how little effort it took to find.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| KRITISCH | Wildcard cross-origin policy on the public data interface | Any page on any domain can read the response |
| MEDIUM | Wildcard policy applied to write-capable paths | Permissive header not scoped to the read endpoint |
| MEDIUM | Wildcard combined with credentials on one route | Response readable with cookies attached |
| LOW | No vary header on the reflected origin | Cache poisoning surface |
| LOW | Preflight answered without a route allowlist | Method and header negotiation not constrained |
| INFO | Platform fully patched | No exploitable vulnerability in the application itself |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Check your own interface from a foreign origin
# target: your own lab interface
# 1. what does the server say for a foreign origin
$ curl -s -o /dev/null -D - -H 'Origin: https://evil.example' \
'https://example.org/api/public/items' | grep -i 'access-control'
# 2. does it reflect any origin, including a subdomain that does not exist
$ curl -s -o /dev/null -D - -H 'Origin: https://attacker-controlled.example.org' \
'https://example.org/api/public/items' | grep -i 'access-control'
# 3. and can credentials be attached
$ curl -s -o /dev/null -D - -H 'Origin: https://evil.example' \
--cookie 'session=whatever' 'https://example.org/api/private/items' | grep -i 'access-control'
Detection in your own estate
Sweep your own estate for reflective origin headers
$ for p in /api/public/items /api/private/items /graphql /api/me; do
printf '%s -> %s\n' "$p" \
"$(curl -s -o /dev/null -D - -H 'Origin: https://probe.example' "https://example.org$p" \
| grep -i 'access-control-allow-origin' | tr -d '\r')"
done
# and for the combination that actually matters
$ curl -s -o /dev/null -D - -X OPTIONS -H 'Origin: https://probe.example' \
-H 'Access-Control-Request-Method: DELETE' -H 'Access-Control-Request-Headers: authorization' \
'https://example.org/api/public/items' | grep -i 'access-control'
Remediation
Scope the policy to an allowlist, and never reflect blindly
# an explicit allowlist, evaluated server side
map $http_origin $cors_origin {
default "";
"https://example.org" $http_origin;
"https://www.example.org" $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
# credentials only where the endpoint is genuinely authenticated and the
# origin list is already restricted to hosts you control
# add_header Access-Control-Allow-Credentials "true" always;
Return the correct refusal instead of a permissive header
// an allowlist beats a wildcard, always
const ALLOWED = new Set(['https://example.org', 'https://www.example.org']);
export function cors(req, res, next) {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
// no origin header at all when the request is not from an allowed origin
next();
}
Once an attacker gains access, they can perform the following actions:
Takeaways
- A permissive cross-origin policy is a multiplier on every other bug on the estate.
- Public data does not mean publicly readable from any page.
- Never reflect an origin without an allowlist and without Vary.
- One finding that changes the severity of everything else belongs at the top of the report.