the cloudflare orange cloud was lying to me
i had thenug.net sitting behind Cloudflare for months and assumed the orange cloud meant my WAF, ratelimit, and bot protection were doing work. they weren't. the whole thing was bypassable in one curl command, and i only noticed because i ran an audit on my own infrastructure.
the problem
"Cloudflare is set up" and "Cloudflare is in the enforcement path" are two different things. the orange-cloud toggle makes DNS point to Cloudflare's IPs. it does not make pfSense refuse connections from anywhere except Cloudflare.
my pfSense was forwarding port 443 from any source IP to Nginx Proxy Manager. so anyone who knew the home WAN IP could do this:
curl -sI --resolve thenug.net:443:<my-home-ip> https://thenug.net
that tells curl to ignore DNS, connect directly to my home IP, and pretend it's asking for thenug.net. NPM matched the Host header, forwarded to the blog, and cheerfully served a 200 OK. cloudflare was not consulted. no WAF, no ratelimit, no bot challenge.
the WAN IP is not hard to find. i have jelly.thenug.net as a DNS-only A record because Cloudflare's TOS forbids proxying video, and their DNS-only records just publish the raw IP. one dig jelly.thenug.net and you've got the origin.
why pfsense can't fix it alone
the obvious fix is "restrict port 443 at pfSense to only Cloudflare's IPs." but pfSense works at layer 3/4. it sees IP and port, nothing else. all my sites (the blog, etheriontech.com, jellyfin) come in on 443 to the same internal NPM box. pfSense can't tell a thenug.net request apart from a jellyfin request - they look identical at the packet level. locking 443 to Cloudflare IPs would break jellyfin for anyone not on Cloudflare, which is everyone.
the routing by hostname happens inside NPM, at layer 7. which means the IP allowlist also has to happen there.
the fix
NPM has an Access Lists feature in its UI, but that's 22 CIDR rows entered one at a time in a UI. instead, each proxy host's Advanced tab has a Custom Nginx Configuration box that accepts raw nginx directives. paste the Cloudflare allowlist in there and nginx enforces it before routing.
allow 173.245.48.0/20;
allow 103.21.244.0/22;
allow 103.22.200.0/22;
allow 103.31.4.0/22;
allow 141.101.64.0/18;
allow 108.162.192.0/18;
allow 190.93.240.0/20;
allow 188.114.96.0/20;
allow 197.234.240.0/22;
allow 198.41.128.0/17;
allow 162.158.0.0/15;
allow 104.16.0.0/13;
allow 104.24.0.0/14;
allow 172.64.0.0/13;
allow 131.0.72.0/22;
allow 2400:cb00::/32;
allow 2606:4700::/32;
allow 2803:f800::/32;
allow 2405:b500::/32;
allow 2405:8100::/32;
allow 2a06:98c0::/29;
allow 2c0f:f248::/32;
deny all;
that block goes on the Cloudflare-proxied sites only. jellyfin stays open. now when an attacker tries the same bypass:
curl -sI --resolve thenug.net:443:<my-home-ip> https://thenug.net
# HTTP/1.1 403 Forbidden
# Server: openresty
and the legit path through Cloudflare still works:
curl -sI https://thenug.net
# HTTP/2 200
# server: cloudflare
the Server header is a nice tell. server: cloudflare means the edge intercepted. server: openresty with a 403 means the request reached NPM and got denied. legit traffic gets one, bypass attempts get the other.
gotchas
the Cloudflare IP list changes roughly once a year. pfSense has URL Table aliases that auto-refresh a list from a URL, but NPM's Custom Nginx Configuration is a one-time paste. set a calendar reminder to re-check cloudflare.com/ips-v4 annually. a stale allowlist starts 403-ing real users when Cloudflare adds new edge subnets.
NPM auto-generates a location / block for each proxy host, and it already contains its own add_header directives. nginx's inheritance rule says any add_header in a nested block replaces the parent ones, it does not merge. that means if you try to add a security header via NPM's server-level Custom Nginx Configuration, it gets masked. the fix for response headers is to use Cloudflare Transform Rules instead, which inject at the edge and don't fight NPM.
why not just hide the WAN IP
the cleanest version of this fix is a Cloudflare Tunnel or Tailscale Funnel, where the home IP is never in public DNS at all. both work. but jellyfin for friends rules out tunnels that require visitors to install clients or log in through a magic-link flow, and Cloudflare's TOS blocks proxying video unless it's gated behind authenticated Access. the realistic threat model for a residential self-hoster is "the WAN IP is going to leak anyway" - ISP, email headers, past DNS history, something. hiding it is polish. making sure the defenses actually run is protection.
eli5
i thought i had hired a bouncer (cloudflare) who checked every guest at the club entrance (WAF, ratelimits) before letting them in. turns out the club also had an unlocked side door that opened directly into the dance floor (pfSense forwarding 443 to NPM from any source). anyone who asked around could find the side door (DNS leaking the WAN IP through the jellyfin subdomain) and walk right past the bouncer. the fix wasn't firing the bouncer or moving the club. it was putting a second bouncer (nginx IP allowlist) at the side door who only lets in people wearing a cloudflare wristband. the main entrance still works for everyone. the side door now only opens for cloudflare's actual traffic. the jellyfin room has its own door with its own lock (the app's login), and that's on purpose so friends can still get in.