Back to deblo
deblo

One Forged Header Reset Every IP Limit: Our Origin Answered Without Cloudflare

Before adding abuse limits, we measured: our server answered outside Cloudflare and trusted a forgeable header. The fix, its production proof, and our own incident.

Juste Thales Gnimavo & Claude | November 13, 2026 8 min deblo
EN/ FR/ ES
deblodeblo-duosecuritycloudflarerate-limitingabuse-preventionip-spoofingreverse-proxyprod-proofclaude-codeclaude-opus-5.5

By Juste Thales Gnimavo (CEO, ZeroSuite) and Claude Opus 5.5 (Claude Code)

Déblo Duo is a real-time interpreter app: each person speaks their language and hears the other in theirs. Why Déblo Duo exists explains the product. One of its modes is Scan and Talk: your screen shows your code, the person in front of you scans it, and your phone rings like an incoming call. That mode is open to strangers by design, so it needs limits against abuse. This post covers two sessions of November 6, 2026: the one that was supposed to add those limits, and the one that followed after the first found that every IP limit we had could be reset with a single forged header.

Measure before you limit

The queued prompt asked for limits per IP address on Scan and Talk: how many new devices may knock on one owner's door from one address, and how many refusals an address may collect before it is muted. The prompt also assumed something about our infrastructure: that every request reached our application through Cloudflare, so the header Cloudflare sets with the visitor's address, CF-Connecting-IP, could be trusted.

Claude checked that assumption before writing a limit. It sent a request straight to our origin server, bypassing Cloudflare. The origin answered 200, with the same build hash as through Cloudflare.

That meant two things. Our server answered anyone who reached it directly. And our application read CF-Connecting-IP from whoever sent it, because nothing between the internet and the application checked who was allowed to set that header. A request sent directly to the origin with a made-up address in that header would be counted under the made-up address. Every IP limit in the product started from zero for each new value.

Adding a new IP limit on top of that would have been decoration. So the session started with the address itself.

One helper that decides where a request came from

The fix is a single function, client_ip(request), in a new module. It answers one question: which hop actually handed us this request, and can we believe what that hop says?

  • It takes the hop that reached our reverse proxy. Our proxy appends the address that contacted it to the right end of X-Forwarded-For, so the rightmost entry is written by our own infrastructure, not by the client.
  • It honours CF-Connecting-IP only if that hop is inside Cloudflare's published address ranges, and only if the value parses as an IP address.
  • In every other case, it limits on the hop itself.

The Cloudflare ranges are copied into the code, IPv4 and IPv6, as published on November 6. We chose that over downloading them at boot: no network dependency at startup, and Cloudflare announces changes long in advance.

There was a second fix on the table, on the network side rather than in the code. It is an infrastructure change, level 1 under our rules, so it belongs to the CEO and is tracked on its own. The code no longer depends on it: the limits hold whatever path a request takes.

The Scan and Talk limits went on that helper. They are counted per owner and per address, not per owner alone, because a global budget per owner would let a script drain it and lock out the shop's real customers. They are wider than the per-device limits, because customers behind the same mobile operator often share one public address. A device that has already queued at that owner recently is not counted again, so a real customer who rescans keeps their place.

The proof in production, first phase

The code went to production at 11:02 UTC. At 11:03 UTC, Claude sent two requests: one directly to the origin with a forged CF-Connecting-IP, one through Cloudflare. Then it read the counters.

Both requests were counted under the test machine's real address. There was no counter at all for the forged address, and none for the proxy's internal address, which also showed that the path through Cloudflare still honoured the header.

The limits were then proven on a CEO test account, three minutes later. New random devices from one address were accepted up to the cap, then refused with 429 scan_rate_limited. A device already known to that owner could still rescan and got the same request back. After a run of refusals from one address, even a known device from that address was refused. Nothing was billed. The side effects were a handful of requests and one notification on the test account's devices. The requests nobody answered expired after a minute.

Six raw reads, one helper

The first session fixed the Duo paths, because its helper replaced Duo's own internal function. The same raw read existed elsewhere. A second session, later the same day in a chain of sessions Juste had approved, moved every remaining one onto client_ip(). There were six:

WhereBeforeWhat it fed
chat, guest checkraw CF-Connecting-IPaddress sent to Turnstile
chat, guest capraw CF-Connecting-IPthe guest chat limit
voice, call startraw CF-Connecting-IPreading the daily guest voice budget
voice, call endraw CF-Connecting-IPincrementing that same budget
contact formfirst entry of X-Forwarded-Forthe contact form limit
ad attributionraw CF-Connecting-IPthe address sent with conversion events

The contact form was the worst of the six. It trusted the leftmost entry of X-Forwarded-For, which is the entry the client writes. That one was forgeable even through Cloudflare.

The voice pair was subtle in another way. Start and end both read the same raw header, so they agreed with each other. They were consistent and both forgeable. Now they compute the same key from the same helper.

One read stayed as it was, on purpose: an internal admin tool that uses the address only to write an audit log entry, with no limit and no decision attached.

The proof in production, second phase

The second deployment was live at 11:36 UTC. To prove the voice path, Claude set the guest voice budget of the test machine's real address to exhausted, then sent two call requests: one directly to the origin with a forged CF-Connecting-IP, one through Cloudflare.

direct origin, forged CF-Connecting-IP : 402 {"detail":"guest_voice_daily_limit"}
via Cloudflare                         : 402 {"detail":"guest_voice_daily_limit"}

Both were counted under the real address. Before the change, the forged request would have read the empty budget of the made-up address and been given a call. Two scans of the counters found no key for the forged address. The real counter was then restored.

The incident during the proof

The log has a section titled « Incident pendant la preuve (de mon fait) »: incident during the proof, my own doing. It is Claude's, and it belongs in this post.

Twice, the step that set the budget to exhausted failed, and Claude probed without checking that it had worked. The first attempt aimed a command at a database port that is not reachable from outside. The second stored a remote command in a shell variable that the shell did not split into arguments. Both failed quietly. So the probes did what an unlimited guest would get: they opened four real guest voice sessions.

Claude closed all four through the normal end-call route within 15 seconds. All four ended after 11 to 12 seconds with zero credits charged, checked in the database. The real cost was a few seconds of an empty voice room and of a worker being dispatched, with nobody on the call. Their 45 seconds were added to the guest budget of Claude's own test address, not to anyone else's.

The lesson is written in the log as it was applied on the third attempt: « la mise en place passe dans un appel séparé, relue, avant toute sonde ». Setup runs as its own step and is read back before any probe. A probe that runs on top of an unchecked setup is not testing the limit. It is testing whatever state the setup happened to leave.

What stays open

Two paths are not proven in production: the guest chat limit and the contact form. The chat refuses a guest request without a valid Turnstile token before it ever reaches the counter, so a scripted probe cannot get that far. The contact form increments its counter after sending a real email to Juste, so proving it means sending Juste a real email from a forged request. Both call the same helper as the voice path proven above. That is a good reason to expect them to behave. It is not proof, so they stay open in the log with the exact observation each one needs.

What we take from it

The prompt was right about what to build and wrong about the ground it stood on. Had the session trusted its own brief, it would have shipped careful limits that a single header could reset. The cheapest step of the day was one request sent straight to our own server, before writing any code.

The incident is the same lesson pointed at ourselves. An assumption that is not checked, whether in a brief or in a setup command, does not stay an assumption. It becomes whatever the system does next.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles