~/blog/legacy-municipal-cms-security-headersPOSTED
by Shady Nathan Tawfik · May 2, 2026 · 4 min

A Legacy Municipal CMS With Perfect Transport Security And No Clickjacking Protection

HSTS was immaculate on a German municipal court portal. One missing header still allowed framing, and the CSP permitted script injection outright.

Interactive[blog-2.0]

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.

TL;DR: HSTS was immaculate on a German municipal court portal. One missing header still allowed framing, and the CSP permitted script injection outright.

Findings

SeverityFindingEvidence
HIGHContent security policy allows inline and dynamic scriptscript-src contained the inline and the dynamic evaluation keywords
MEDIUMNo frame protection on administrative pathsClickjacking surface across every logged-in view
MEDIUMStyle policy also permissiveInline styles permitted, weakening the style boundary
LOWLegacy frame options header on the login page onlyInconsistent policy between login and application
LOWServer banner discloses the web server versionVersion information in every response
INFOStrict transport security correctly configuredLong max-age with subdomain and preload intent

The attack path

Third party pageInvisible iframeAdmin view without frame policyUser clicks decoyFramed button receives the clickAction executed in victim session

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

~/code/bash bash
# 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

~/code/bash bash
# 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

~/code/nginx nginx
# 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:

Content security policy allows inline and dynamic script
No frame protection on administrative paths
Style policy also permissive

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.

For educational purposes only. Every reproducible command targets my own lab environment (localhost), never a live system. © 2026