The Fix for One Security Finding Created the Next One
A rate limiter protecting an API key check had been keyed on the wrong address for most of its life. The fix for that, applied earlier, was reasonable on its face and wrong in a way that took a second, separate finding to surface. This is a story about both findings, because the second one only exists because of how the first one was closed.
Finding one: one attacker, many innocent customers
The traffic path is client to Cloudflare to an internal proxy to the application. The application was keying its rate limiter on req.ip — the address of whoever actually opened the connection to the proxy. In production, that is always the Cloudflare edge server, not the end caller, because Cloudflare terminates the connection and makes its own one onward.
That has a specific, bad consequence: every customer whose traffic happens to transit the same Cloudflare point of presence shares one rate-limit bucket. A single attacker sending roughly thirty failed requests a minute from behind that PoP is enough to start rejecting unrelated, paying customers who had nothing to do with it — while the attacker’s own traffic, if it crosses multiple PoPs, spreads across several buckets and evades the limit it was supposed to enforce. Keying on the edge address punishes the innocent and barely slows the actual attacker.
The first fix, and the assumption it rested on
The response was to prefer a different header, CF-Connecting-IP, which Cloudflare sets at its own edge to the real caller’s address. That is a correct instinct — the header genuinely does carry the right information, for traffic that actually came through Cloudflare.
The fix trusted the header unconditionally, and the comment explaining why said the quiet part out loud: “the origin should not be publicly routable.” Not “is not.” Should not be. That is a statement about how the infrastructure was intended to be configured, not a verified fact about how it actually was.
The check that turned the assumption into a finding
The way to know whether an origin is reachable directly is to try it directly. A request straight to the origin’s IP address, with the right Host header set by hand, returned a working response — /health, 200, no Cloudflare in the path at all.
Once direct access to the origin is possible, CF-Connecting-IP stops being Cloudflare’s header and becomes an ordinary one that anyone can set to anything, because nothing enforces that it only arrives alongside real Cloudflare traffic. The validator checking its format made this worse rather than catching it: a regex intended to accept IP addresses, /^[0-9a-fA-F:]+$/, also accepts bare hex-looking strings with no dots or colons at all. a1. ff. dead. None of those are addresses. All of them passed.
And this was not a header feeding a cosmetic log field. It was the only rate limiter standing in front of the API key check’s hash comparison — the thing meant to slow down repeated guessing. A caller who knew the origin’s address could set that one header to a fresh nonsense value on every request and never hit the same bucket twice.
The actual fix: verify the claim, don’t trust the source
The header cannot be made trustworthy by itself — it is just a string an HTTP client sets. What can be verified is whether the specific connection carrying it actually came from a real Cloudflare edge server, because Cloudflare publishes the IP ranges its edges use.
The fix checks req.ip — the address that really opened the connection, which cannot be spoofed by a request header — against those published ranges. Only when that check passes does the code read CF-Connecting-IP at all. A direct hit on the origin, from any address outside Cloudflare’s own ranges, gets the old, safer-but-coarser per-edge bucketing instead of a forgeable header. Traffic that genuinely transits Cloudflare gets the precise per-caller bucketing the header was always meant to provide.
This is the same guarantee an infrastructure team would normally express as a single line in a web server’s config file — telling it which upstream ranges to trust for a forwarded-address header. It was written as code here on purpose, after a config-file version of the identical guarantee had already gone silently inert elsewhere in this same project, undetected for a full review cycle, because nothing ever exercised the failure path to notice.
Designing for the day the list is wrong
Cloudflare’s published ranges can change. The fix has an explicit answer for what happens when an address legitimately transiting Cloudflare is not yet in the list the application knows about: it fails to the old per-edge bucketing, not to trusting the header anyway. Being left out of the list degrades precision. It does not create a new forgery path. That asymmetry was a deliberate choice, not a side effect — a missing range should cost you accuracy, never safety.
What the test suite checks, and why those specific cases
The new tests do not only confirm that a real Cloudflare address passes and an arbitrary one fails. They check the edges on purpose: an address one number below the start of a known range, and one number above the end of a different range, to catch an off-by-one in how the ranges are compared. They check that an IPv4 address wrapped in its IPv6-mapped form is still recognised as the same address, because a comparison that only understands one representation would quietly fail for the other. And they feed the exact malformed strings that broke the old validator — a1, ff, dead, an empty string, a string of only spaces — and assert each one is rejected rather than guessed at.
That last group matters more than it looks. The old defect was not a missing feature. It was a validator that accepted things it should never have accepted, silently, for as long as nobody tried. Writing the test for the exact failure that already happened once, rather than a generic “valid input works” case, is what turns a fix into something that cannot regress back to the same mistake unnoticed.
The general lesson
A security fix closes the gap it was written for. It does not automatically close every gap adjacent to it, and the comment justifying the first fix here — “the origin should not be publicly routable” — is exactly the kind of statement that reads as a finding when written down and functions as an unverified assumption until someone actually tries the thing it claims cannot happen. The second finding did not come from a different vulnerability class. It came from treating the first fix’s own stated justification as a claim worth testing rather than a reason to stop looking.
Ash Ganda is the founder of Ganda Tech Services. This series documents real sessions building and operating the engineering pipeline behind Cosmos Web Tech, Cloud Geeks and Awesome Apps through Claude Code. Part 6: The CLAUDE.md That Travels With Me, and the One That Doesn’t.
Digital Transformation Roadmap 2026
A 12-month framework for Australian SMBs ready to modernise — phases, tools, and milestones.
Almost done
Check your inbox and click the confirmation link to get your download.