I'd just put a Proxmox server behind a Cloudflare Tunnel — two public hostnames, one for SSH and one for the web UI — and I was quietly pleased with myself. I already run a Zero Trust setup on the domain, a wildcard application that demands an email one-time-code before it lets anyone near anything. So the new box was protected too. Obviously.
It was not protected. And the way I found out is worth writing down, because the failure is silent and the test for it is genuinely easy once you know what you're looking at.
The niggle
I was testing the new web UI hostname and ran a plain curl at it — no browser, no cookies, nothing. It cheerfully handed me back the Proxmox login page. HTTP 200.
That shouldn't happen. If Cloudflare Access were sitting in front, an unauthenticated request should never reach the origin — it should be bounced to the login screen. Getting the actual Proxmox page from a credential-free curl is the sound of a stable door swinging in the wind.
My first instinct was to explain it away — "this machine's already authenticated, it's passing through." Plausible! And wrong, as it turned out, which is the useful bit.
The insight: where Access actually sits
Cloudflare Access runs at the edge, before your request is proxied down the tunnel to your origin. So for a hostname an Access policy actually covers, an unauthenticated request gets a 302 redirect to your-team.cloudflareaccess.com and never touches the tunnel. You can test it without even involving the origin:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -i https://your-host.example.com
302 → *.cloudflareaccess.com— Access is enforcing. Good.200(origin), or530(tunnel down) — your request reached the tunnel layer, which means Access did not stop it. Not protected.
That last point is the neat one: even with my origin offline later (tunnel down, returning 530), the test still worked — because a 530 only happens after a request has cleared the edge. If Access were guarding the hostname I'd have got the login redirect regardless of whether the origin was up. A 530 to an unauthenticated caller is itself a finding.
The confounder: don't test from your own machine
Here's the trap I nearly fell into. If you test from a device that's enrolled in WARP, or that's already completed the email-code dance in a browser, your requests carry a valid Access session — so they sail straight through and everything looks protected. False negative.
Two things to check on your testing machine first:
curl -s https://www.cloudflare.com/cdn-cgi/trace | grep -E 'warp=|gateway=' # want warp=off gateway=off
ls ~/.cloudflared/ # cached *-token files = you hold sessions
In my case the laptop wasn't on WARP — but ~/.cloudflared/ held an org-level Access token and a token for a different host entirely. So my "I'm already authenticated, it's fine" hunch was half right: I held a session, but for other applications. The new box had no application at all. Test from somewhere with an empty ~/.cloudflared, no WARP, and no browser cookies.
The actual cause
This is the part worth tattooing somewhere. A wildcard Access application does not automatically protect every subdomain you later create. And creating a tunnel route — cloudflared tunnel route dns <tunnel> <hostname> — only writes the CNAME. It does not create an Access application. So my two new hostnames had a working tunnel, a working DNS record, and precisely nothing standing between the public internet and a Proxmox login (and, for the SSH hostname, only the root password). The wildcard app I was relying on simply didn't match them in the way I'd assumed.
The fix
Add a real Access application for each hostname (Zero Trust → Access → Applications → Add → Self-hosted):
- One application per hostname (or a wildcard you've verified matches), Allow policy, with an identity requirement — email OTP at minimum, ideally device posture or a group.
- Keep the session short.
- Re-run the
curltest from a clean machine afterwards and confirm you get the302to*.cloudflareaccess.com.
For SSH hostnames specifically, the Access app is the front door but it isn't the only lock — make sure the origin itself isn't accepting password root logins behind it. Belt and braces.
How to audit the whole lot
If you've made this mistake once, you've probably made it more than once, so audit everything rather than spot-fixing. Briefly:
- List your DNS records and flag every CNAME pointing at
*.cfargotunnel.com— those are tunnel-backed hostnames. - List your tunnels and their ingress rules (note: locally-managed tunnels keep their ingress in
/etc/cloudflared/config.ymlon the origin, not in the dashboard API — you have to read the box). - List your Access applications and their policies — note any
bypassdecisions, which are effectively "no protection" (sometimes that's deliberate; document why). - Cross-reference: for every tunnel-backed hostname, is there an Access app, what's the policy, and does it require MFA? Anything fronting SSH, RDP or a hypervisor UI without an Allow+MFA policy is exposed.
- Verify each one empirically with the clean-machine
curltest. Config and reality don't always agree.
The Cloudflare API (read-only token) makes steps 1–3 scriptable; I'll spare you the JSON here.
The lesson
The uncomfortable bit isn't that I'd misconfigured something — it's that I'd assumed a protection existed and never tested the assumption, because the dashboard looked tidy and the tunnel worked. A tunnel coming up is not the same as a door being locked. Test the lock, from outside the building, with no keys in your pocket.
Ta ta for now, and do go and curl your own hostnames — you might get a surprise.
No comments:
Post a Comment