TL;DR: A legacy municipal CMS answered with strong headers except for two gaps: unsafe-inline in the CSP and no frame protection. I show how I verified both, why they matter together, and how to fix them without breaking the legacy backend.
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 German regional court portal running a proprietary municipal CMS from the mid-2010s, hosted on Apache behind a CDN. The assessment covered response headers, frame policy, TLS configuration and the JavaScript delivery chain.
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:
A strict transport policy and a missing frame policy are independent problems, and fixing the first tells you nothing about the second. HSTS only tells a browser to refuse plaintext for a domain. Frame protection is what stops another origin from rendering your interface inside an invisible container and relaying your clicks.
The second half of the failure is the content security policy. When a script source list contains the inline and the dynamic evaluation keywords, the policy stops being a boundary. It becomes a comment. Any injection point that reaches an HTML sink then executes with full page privileges, and the browser will happily run it because the policy said so.
Municipal estates are a good example of why this matters. A single framed administrative page inside a third-party site can be used to trigger a legitimate-looking approval while the user believes they are somewhere else entirely. Nothing in the audit requires a vulnerability in the CMS itself, which is precisely why it survives for years.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HIGH | Content security policy allows inline and dynamic script | script-src contained the inline and the dynamic evaluation keywords |
| MEDIUM | No frame protection on administrative paths | Clickjacking surface across every logged-in view |
| MEDIUM | Style policy also permissive | Inline styles permitted, weakening the style boundary |
| LOW | Legacy frame options header on the login page only | Inconsistent policy between login and application |
| LOW | Server banner discloses the web server version | Version information in every response |
| INFO | Strict transport security correctly configured | Long max-age with subdomain and preload intent |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Frame the application yourself and see whether the browser refuses it
# target: a page on YOUR OWN local lab
$ cat > /tmp/lab.html <<'EOF'
<iframe src="http://localhost:8080/admin/" width="600" height="400"></iframe>
EOF
$ python3 -m http.server 9000 --directory /tmp
# open http://localhost:9000/lab.html
# if the frame renders, the application is framable
Detection in your own estate
Header presence and values
# read-only header probe against a host you own or are authorised to test
$ curl -sI https://example.org/ | grep -iE \
'strict-transport|content-security-policy|x-frame-options|x-content-type|referrer-policy|permissions-policy'
# presence check, exit code 0 when every header is present
$ for h in strict-transport-security content-security-policy x-frame-options \
x-content-type-options referrer-policy; do
curl -sI https://example.org/ | grep -qi "^$h" && echo "ok $h" || echo "MISS $h"
done
Remediation
Ship the full baseline, and make the CSP mean something
# nginx: send the full baseline and stop proxy layers from stripping it
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
# a CSP without unsafe-inline and without unsafe-eval, no wildcard CDNs
add_header Content-Security-Policy \
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
Once an attacker gains access, they can perform the following actions:
Takeaways
- Transport security and frame protection are separate controls; audit them separately.
- A content security policy that permits inline script is documentation, not defence.
- Frame protection belongs on every path, including authenticated administrative views.
- Version banners cost nothing to remove and remove an entire recon step for an attacker.