Only Cloudflare talks to my server

·8 min read·dev

Outmuscle Me still runs on a single Ubuntu server, pretty much the setup from Sidekiq, systemd and Capistrano. Rails, Postgres and the background jobs share one box and Cloudflare sits in front of it. DNS points at Cloudflare, Cloudflare talks to the server, and in my head that meant nobody else did.

Then CVE-2026-98432 came out. It's a use-after-free in the Linux kernel's IPv4 TCP handshake, rated 9.8, and Ubuntu didn't have a fixed kernel yet. A bug in the handshake doesn't care about logins or valid requests. Anyone who can open a TCP connection to the machine can try it. So I went to check who could.

Who can reach it

Cloudflare hides the server's IP, but hiding it doesn't block anything. The IP leaks through old DNS records, mail headers or someone scanning the hosting provider's ranges, and once you have it you can skip Cloudflare completely. Two curl commands tell you if that works:

# port 80, straight to the server
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: outmuscle.me" http://<server-ip>/

# port 443, with the real hostname so TLS works
curl -sk --resolve outmuscle.me:443:<server-ip> https://outmuscle.me/

Port 80 answered with a redirect to https. Port 443 went through the TLS handshake and then nginx said:

400 No required SSL certificate was sent

That's Authenticated Origin Pulls. Cloudflare sends a client certificate with every request and nginx turns away anyone who doesn't have one, so nobody could use the app around Cloudflare. That part was fine. The problem is the order. The TCP handshake happened first, then TLS, and only after both did nginx say no. For a bug that lives in the TCP handshake, that's too late.

Let only Cloudflare in

The fix is old and boring. Ports 80 and 443 accept connections only from Cloudflare's IP ranges, and the firewall drops everything else before the kernel sets up a connection. Cloudflare publishes the ranges at cloudflare.com/ips, right now 15 for IPv4 and 7 for IPv6.

They don't change often, but they do change, and a list I paste in once will be wrong one day without me noticing. A friend of mine sent me a script he runs from cron.daily that keeps ufw in sync with Cloudflare's list. I liked the idea a lot, so here's my version of it:

#!/bin/bash
# /etc/cron.daily/cloudflare-ufw
# Lets only Cloudflare reach ports 80 and 443 by keeping ufw in sync with
# the IP ranges Cloudflare publishes. No dot in the name, run-parts skips those.
set -euo pipefail

ports=80,443
mark=cloudflare

log() { logger -t cloudflare-ufw "$*"; }

v4=$(curl -fsS --max-time 30 https://www.cloudflare.com/ips-v4)
v6=$(curl -fsS --max-time 30 https://www.cloudflare.com/ips-v6)

# The files have no trailing newline, so join them with printf
published=$(printf '%s\n%s\n' "$v4" "$v6" | sort -u)

# A short or garbled list would remove most of the rules, so refuse it
if [ "$(wc -l <<< "$published")" -lt 10 ] || grep -qvE '^[0-9a-f:.]+/[0-9]+$' <<< "$published"; then
  log "unexpected Cloudflare IP list, leaving ufw alone"
  exit 1
fi

# Ranges our own rules allow today, read from the commands that added them
allowed=$(ufw show added | sed -n "s/^ufw allow from \([^ ]*\) .* comment '$mark'\$/\1/p" | sort -u)

# Add before removing, so Cloudflare never loses access halfway through
for range in $(comm -13 <(echo "$allowed") <(echo "$published")); do
  ufw allow from "$range" to any port "$ports" proto tcp comment "$mark" > /dev/null
  log "allowed $range"
done

for range in $(comm -23 <(echo "$allowed") <(echo "$published")); do
  ufw --force delete allow from "$range" to any port "$ports" proto tcp > /dev/null
  log "removed $range"
done

ufw show added prints every rule as the command that created it, comment included:

ufw allow from 173.245.48.0/20 to any port 80,443 proto tcp comment 'cloudflare'

The script picks out the lines with its own comment, compares those ranges with Cloudflare's list and adds or removes the difference. Rules without the comment it never touches. show added reads ufw's own rule files, so it also works when ufw is disabled, which ufw status does not.

New ranges go in before stale ones come out, so a Cloudflare range is never blocked halfway through a run. If the download fails or the list looks off, the script exits and leaves the firewall as it is. An empty list would otherwise read as "remove every Cloudflare rule", which is a creative way to take a site down. It logs to the journal instead of printing, because cron mail on my server goes nowhere. journalctl -t cloudflare-ufw shows what it did.

Order matters

As root, put the script in place, make it executable and run it once by hand:

vim /etc/cron.daily/cloudflare-ufw
chmod 755 /etc/cron.daily/cloudflare-ufw
run-parts --test /etc/cron.daily   # cloudflare-ufw should be in the list
/etc/cron.daily/cloudflare-ufw
journalctl -t cloudflare-ufw       # 22 "allowed" lines

Only after that remove the rules that let everyone in on 80 and 443. The other way round, Cloudflare can't reach you either until cron runs, and that's an outage. Mine had piled up:

[ 1] 443                        ALLOW IN    Anywhere
[ 2] 80/tcp                     ALLOW IN    Anywhere
[ 5] Nginx HTTP                 ALLOW IN    Anywhere
[ 6] Nginx Full                 ALLOW IN    Anywhere

Four rules saying roughly the same thing, and the same four again for IPv6. Delete them by name, not by number. ufw renumbers after every delete, so the second ufw delete 2 removes a different rule than you think. A delete by name also takes the IPv6 copy with it:

ufw delete allow 'Nginx Full'
ufw delete allow 'Nginx HTTP'
ufw delete allow 80/tcp
ufw delete allow 443

None of this touches SSH, it isn't on 80 or 443, so you can't lock yourself out. Then check from a machine outside:

curl -s -o /dev/null -w '%{http_code}\n' --max-time 6 -H "Host: outmuscle.me" http://<server-ip>/
# 000, curl gave up after 6 seconds

curl -s -o /dev/null -w '%{http_code}\n' https://outmuscle.me/
# 200

Direct connections time out and the site still answers through Cloudflare. The mobile app uses the same hostname, so it didn't notice anything.

The CVE itself

Locking the web ports shrinks what's exposed, but SSH still has to be reachable and it runs on the same kernel. Until the fixed kernel is out, the interim mitigation from the Atlas write-up is an nftables rule that drops IPv4 TCP packets carrying IP options, plus turning MPTCP off:

nft add table ip cve_2026_98432
nft add chain ip cve_2026_98432 pre '{ type filter hook prerouting priority -300; }'
nft add rule ip cve_2026_98432 pre meta l4proto tcp ip hdrlength gt 5 counter drop

echo 'net.mptcp.enabled = 0' > /etc/sysctl.d/90-disable-mptcp.conf
sysctl -w net.mptcp.enabled=0

Normal traffic, SSH included, doesn't use IP options, so nothing legitimate gets dropped. The write-up is clear that this lowers the risk and doesn't remove it. The kernel update is the real fix. Rules added with nft like this are gone after a reboot, which is fine when the reboot is into the fixed kernel. Otherwise, run the three nft lines again.

A few things I learned along the way

The allowlist and Authenticated Origin Pulls share a gap. Cloudflare's IPs are the same for every Cloudflare customer, and so is the certificate global AOP uses. Anyone can point their own Cloudflare zone at your IP and get through both. Uploading your own origin pull certificate at the zone level is what ties it to your account.

Cloudflare's ips-v4 file has no newline at the end, so curl ... | wc -l says 14, not 15. Worth knowing before you write a sanity check against that number.

ufw allow 443 without a protocol opens UDP 443 as well. nginx wasn't serving HTTP/3, so that rule opened a port for nothing.

run-parts silently skips files in cron.daily that have a dot in the name or aren't executable. run-parts --test /etc/cron.daily lists what it will actually run.

A ufw delete without the comment still removes a rule that has one. For deletes ufw matches everything except the comment, it's right there in backend_iptables.py.

Don't make the nft rule permanent through the stock /etc/nftables.conf. That file starts with flush ruleset, and on current Ubuntu ufw's rules live in nftables too, through iptables-nft. Starting the nftables service runs that file and wipes ufw's rules along with everything else.

Summary

  • Cloudflare in front doesn't stop anyone with the IP from talking to the server directly

  • ufw lets 80 and 443 in only from Cloudflare's published ranges

  • A daily cron keeps those ranges current, adding before it removes

  • Run the script once, then delete the open rules by name

  • For CVE-2026-98432, drop IPv4 TCP packets with IP options and turn MPTCP off until the fixed kernel is out