My blog let me read its own system files. Here's the audit that found it, and two more.
I was running a routine security scan on a site I’ve hosted for years, expecting a clean bill of health. Instead, I found three legacy vulnerabilities, the kind that hide behind working software: nothing crashes, nothing errors out, so there’s no signal that tells you to go looking.
One of them was a path traversal gap on our blog that literally let me read private system files via a simple, modified URL. I sat down with Zuri to systematically audit the domain, trace the bypasses, and close the doors before someone else decided to kick them open. Here is the diagnostic run and the exact fixes we applied.
Clickjacking: the invisible overlay trick
The site was missing X-Frame-Options and a proper Content-Security-Policy with frame-ancestors set. Clickjacking is when an attacker loads your site in a hidden iframe and tricks a user into clicking your real page while they think they are clicking something else — a user thinks they’re hitting “Play video” on a shady page, but the click lands on your hidden “Authorise app” button underneath.
The fix is two headers: X-Frame-Options: SAMEORIGIN for older browsers, and Content-Security-Policy: frame-ancestors 'self' for the modern approach. Both go in your server’s response headers. SAMEORIGIN allows the page to be framed only by pages on the same origin, blocking third-party framing entirely.
Good security is never set in stone because software changes with every update. This had been quietly wrong for a long time.
Tricking the gatehouse into letting me wander inside
A path traversal vulnerability is like arriving at an estate gatehouse and asking the guard to see the public noticeboard. But instead of staying in the waiting room, you use a weird, encoded verbal trick (%2F..%2F..%2F) to confuse the guard, slide past their desk, walk straight into the landlord’s private study, and read their personal folders.
The blog ran on Ghost, self-hosted on a VPS, and the subdomain had a vulnerability where a crafted URL like:
/assets/built%2F..%2F..%2Fpackage.json
…returned the actual package.json from the Ghost installation. That file exposes the Ghost version, dependencies, and enough information to fingerprint the exact version and target known vulnerabilities. The underlying issue: Ghost serves its /assets/ directory, and with URL encoding (%2F for /), you could traverse out of that directory to the application root.
There were two layers to fix. First, the subdomain was behind Cloudflare but the proxy was disabled (grey cloud / DNS-only), which meant Cloudflare’s WAF was not in the path at all. Enable the proxy, then add a firewall rule:
(http.host eq "blog.example.com" and (
lower(http.request.uri) contains "%2e%2e" or
lower(http.request.uri) contains "../" or
lower(http.request.uri) contains "package.json" or
lower(http.request.uri) contains ".env" or
lower(http.request.uri) contains ".git"
))
→ Block
Second, even with the proxy enabled, the origin IP had been publicly resolvable for years — almost certainly in historical DNS records. An attacker could still hit it directly and bypass the WAF. The clean solution: restrict the VPS to only accept HTTP/HTTPS traffic from Cloudflare’s IP ranges:
sudo ufw allow 22/tcp
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
sudo ufw allow from $ip to any port 80 proto tcp
sudo ufw allow from $ip to any port 443 proto tcp
done
sudo ufw deny 80/tcp
sudo ufw deny 443/tcp
sudo ufw --force enable
Now the origin is unreachable from anything that is not Cloudflare. The WAF rule is the edge layer; the firewall is the origin layer. The grey cloud vs orange cloud distinction on a subdomain is easy to overlook — most people set up Cloudflare on the root domain and assume subdomains are covered. They are not unless the DNS record is proxied.
The loose lock on our outbound mail room
DMARC was configured, but it was in monitoring mode (p=none), which collects aggregate reports and enforces nothing. The plan was probably to tighten it later, but nobody came back to do that. Having your DMARC at p=none is like having security cameras installed but no physical guard to actually stop thieves — you can see who walked away with your packages, but only after they’ve already driven out of the gates.
Before moving to p=quarantine, I audited the SPF records and found two of them on the root domain (see my other post on this). Enforcing DMARC with a broken SPF configuration would have quarantined legitimate outbound mail. So the order of operations matters:
- Fix SPF first — merge the two records into one.
- Move DMARC to
p=quarantine; pct=10— 10% of failing messages quarantined while you monitor. - Ramp up over 2–4 weeks —
pct=100, then eventuallyp=reject.
The pct tag is underused. It lets you do a staged rollout: enforce on a fraction of failing mail first, watch aggregate reports for false positives, then increase. Going straight to p=reject on a domain with any sending complexity is how you accidentally break your transactional email.
v=DMARC1; p=quarantine; pct=10; rua=mailto:reports@yourdmarc.provider
p=none is better than no DMARC, but barely. It has been sitting at p=none on domains I have seen for three or four years because no one feels the urgency to tighten it. The urgency is that email spoofing — sending email that appears to come from your domain — is trivially easy while p=none is in place. p=reject is the destination. p=quarantine; pct=10 is a safe way to start the journey.
None of these were complicated: DMARC added but not enforced, Cloudflare configured but proxy not enabled on a subdomain, and headers added on some routes but not all. All three were found in a single audit pass, so they are fairly easy to catch, as long as somebody finishes the job.