Skip to content
Go back

Bots Ate 90% of My Worker Quota

By SumGuy 13 min read
Bots Ate 90% of My Worker Quota
Contents

The Login Page That Was Never There

Here’s the number that ruined my afternoon: 94,504 Worker requests, burned before the day was even two thirds over. Normal daily volume across every Worker on the account runs 3,856 to 17,885. The Workers free plan caps you at 100,000 requests a day. At that pace I was going to hit the wall by dinner, on a service that does one thing: return the caller’s IP address as JSON.

The culprit wasn’t a DDoS, a viral link, or some AI crawler indexing the whole internet again. It was one bot, in Singapore, spending the entire afternoon trying to log into a WordPress admin panel that has never existed on that host. The host doesn’t run PHP. It’s forty lines of JavaScript. And it burned through 90% of a day’s free quota anyway, because of two mistakes that had nothing to do with the bot and everything to do with how I’d written the Worker.

The Alert Nobody Wants

The service in question lives at ip.example.com. Free plan, single Worker, does a reverse DNS lookup on your IP and hands it back as JSON. The kind of tiny utility you build in twenty minutes and forget exists until Cloudflare’s usage graph turns into a cliff.

I noticed because the account-wide request count looked wrong for a Tuesday. Not “slightly elevated,” wrong, like someone had left a script running in a loop. Time to find out which Worker was responsible before the free tier cut everyone off at midnight UTC.

Step One: Which Script Is Actually Burning the Quota

Cloudflare’s GraphQL Analytics API has a dataset called workersInvocationsAdaptive that breaks invocations down per script per day. This is the fastest way to answer “which one of these is the problem” without opening a dashboard tab per Worker.

worker_usage.py
import json
import os
import urllib.request
ACCOUNT_ID = "your-account-id"
API_TOKEN = os.environ["CF_API_TOKEN"]
QUERY = """
query($acct:String!,$s:Date!,$e:Date!){
viewer{
accounts(filter:{accountTag:$acct}){
workersInvocationsAdaptive(limit:100, filter:{date_geq:$s, date_leq:$e}){
sum{requests errors subrequests}
dimensions{scriptName date}
}
}
}
}
"""
def run(start, end):
body = json.dumps({
"query": QUERY,
"variables": {"acct": ACCOUNT_ID, "s": start, "e": end},
}).encode()
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/graphql",
data=body,
headers={
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
},
)
data = json.load(urllib.request.urlopen(req))
rows = data["data"]["viewer"]["accounts"][0]["workersInvocationsAdaptive"]
rows.sort(key=lambda r: r["sum"]["requests"], reverse=True)
for r in rows:
d = r["dimensions"]
print(f'{d["date"]} {d["scriptName"]:<20} '
f'{r["sum"]["requests"]:>8} req {r["sum"]["subrequests"]:>8} subreq')
if __name__ == "__main__":
run("2026-08-15", "2026-08-15")

One script accounted for 90,414 of the 94,504 total requests, 95.7% of the day’s traffic. It was the what’s-my-IP Worker. Not the site with the blog. Not the API with actual users. The tiny utility nobody thinks about.

Grouping by datetimeHour instead of date sharpened the picture further: 71,004 requests landed in the 15:00 UTC hour, and 14,111 more in the 16:00 hour. This wasn’t traffic spread evenly across the day. It was two hours of something hammering the door, then apparently getting bored or getting blocked somewhere else.

Small gotcha worth flagging before you go build your own version of this: range limits are per dataset and per plan, and on a free zone they’re tight. Ask for more than a day and the API hands it straight back; what I got was cannot request a time range wider than 1d. Don’t guess at the boundary, though, and don’t take my number as universal. Ask the API: the settings node returns maxDuration (how wide a window that dataset will give you, in seconds) and notOlderThan (how far back it retains). Want a week of history? Loop over windows inside maxDuration and stitch the results together yourself.

Step Two: Who, Exactly

Knowing which script wasn’t enough. I needed to know which client, on which path, doing what. That means switching from the account-level Workers dataset to the zone-level HTTP requests dataset, httpRequestsAdaptiveGroups, and grouping by IP, method, path, and response status.

{
"query": "query($zone:String!,$s:Time!,$e:Time!){ viewer { zones(filter:{zoneTag:$zone}) { httpRequestsAdaptiveGroups(limit:100, filter:{datetime_geq:$s, datetime_leq:$e}, orderBy:[count_DESC]) { count dimensions { clientIP clientCountryName clientRequestHTTPMethodName clientRequestPath edgeResponseStatus cacheStatus } } } } }",
"variables": {
"zone": "<zone-id>",
"s": "2026-08-15T00:00:00Z",
"e": "2026-08-15T23:59:59Z"
}
}

One row dwarfed everything else. A single source IP, 103.253.27.105, geolocated to Singapore, had sent 85,250 POST requests to /blog//wp-login.php. Yes, with the double slash, that’s copied straight out of the logs. Every single one of those came back HTTP 200. Every one had cacheStatus: none, meaning every single request hit the actual Worker, not a cached edge response.

Do the math and it’s uncomfortable: 85,250 out of 94,504 total requests is 90.2% of the entire day’s Worker quota, spent on one bot trying to guess a WordPress password on a server that has never run WordPress, never run PHP, and never had a login page of any kind.

Nobody was hunting this host specifically. This is the same rotation that scans every IP address on port 443 looking for a wp-login.php that responds, day in and day out, forever. “I’m too small to be a target” isn’t protection, because nobody’s choosing you. You’re on a list with a few billion other addresses, and the scan doesn’t care what’s actually running there. It only cares whether something answers.

Why It Didn’t Stop On Its Own

Here’s the part that’s actually on me, and it’s the real lesson of this whole story: two design choices in the Worker turned a routine internet-background-noise scan into a genuine quota emergency.

The Worker said yes to everything. Zero 404s. Zero 405s. In the entire day’s logs, not one request got told “that’s not a thing here.” My Worker didn’t check the HTTP method, didn’t check the path, it just ran its logic and handed back a 200 no matter what you asked for. From the scanner’s point of view, /blog//wp-login.php on this host existed and responded successfully, over and over, all afternoon. Search engines treat a soft 404 as wasted crawl budget. Automated scanners treat it as a green light. There’s no reason for the bot to stop when the server keeps saying “sure, come on in.” A 405 on the first non-GET request at least gives the scanner a reason to move on, instead of a success signal telling it to keep going.

Every one of those requests dragged an outbound DNS query along with it. The Worker does a reverse DNS lookup against https://dns.google/resolve on every invocation, before it returns anything. To be clear about the billing, because I had this wrong at first: Cloudflare does not charge for subrequests. The pricing docs say so outright, and the free plan’s limit on them is a cap of 50 per invocation, not a meter. So the DNS call didn’t double my bill.

What it did do was turn one scan into two piles of pointless work, and the second pile is visible in a place worth knowing about. Sorting the zone’s traffic by client address, the top talker was the scanner. The second was an address I didn’t recognize at all, sitting at 88,915 requests, every one a GET to dns.google/resolve coming back 200. That wasn’t another attacker. That was my own Worker’s egress, showing up in my own analytics as a phantom client, because outbound subrequests get logged under the host they’re sent to. Roughly 89,000 reverse lookups fired off to Google so that a password guesser could receive an IP address it never asked for and would not have read.

That’s the actual failure here: it’s not that a bot found the Worker, it’s that the Worker was built to do maximum work for a request it should have rejected on sight.

Fixing It, In Order of How Much It Actually Helps

1. Guard the Worker itself

This is the cheapest, highest-leverage fix, and it should have been there from day one.

worker.js
// before: happily answers everything, no matter the method or path
export default {
async fetch(request) {
const ip = request.headers.get("cf-connecting-ip");
const arpa = ip.split(".").reverse().join(".") + ".in-addr.arpa";
const ptr = await fetch(`https://dns.google/resolve?name=${arpa}&type=PTR`);
const rdns = await ptr.json();
return new Response(JSON.stringify({ ip, rdns }), {
headers: { "content-type": "application/json" },
});
},
};
worker.js (guarded)
export default {
async fetch(request) {
const url = new URL(request.url);
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method Not Allowed", { status: 405 });
}
if (url.pathname !== "/" && url.pathname !== "/json") {
return new Response("Not Found", { status: 404 });
}
// only now do we spend a subrequest on the DNS lookup
const ip = request.headers.get("cf-connecting-ip");
const arpa = ip.split(".").reverse().join(".") + ".in-addr.arpa";
const ptr = await fetch(`https://dns.google/resolve?name=${arpa}&type=PTR`);
const rdns = await ptr.json();
return new Response(JSON.stringify({ ip, rdns }), {
headers: { "content-type": "application/json" },
});
},
};

Note the in-addr.arpa dance in there. A reverse lookup wants 4.3.2.1.in-addr.arpa, not 1.2.3.4. Hand a bare IP to any DoH resolver and it treats it as a literal hostname and returns NXDOMAIN forever, which is a fun way to run a reverse-DNS service that has never once resolved anything. That snippet is IPv4 only; IPv6 needs the nibble format under ip6.arpa.

The order matters as much as the checks do. Put the guards before the DNS lookup, not after. You still pay for the invocation that returns the 405, since Cloudflare bills on inbound requests to your Worker no matter what it does, but you skip the outbound lookup and the latency and CPU that come with it, and you hand the scanner a stop signal instead of an invitation.

Here’s the fastest way to check whether your own site has this problem: hit a real path and a garbage path, and diff the bytes.

Terminal window
curl -s https://example.com/real-page | md5sum
curl -s https://example.com/asdfghjkl | md5sum

Identical hashes mean you’ve got a soft 404 somewhere, and something out there is quietly enjoying it.

2. Block the noise at the edge with a WAF custom rule

A Worker-level guard still costs you an invocation. A WAF rule that blocks the request before it ever reaches the Worker costs nothing. This is where you actually want the scanning traffic to die.

(http.request.uri.path contains ".php") or
(http.request.uri.path contains "/wp-admin") or
(http.request.uri.path contains "/wp-includes") or
(http.request.uri.path contains "/wp-content") or
(http.request.uri.path contains "/wp-login") or
(http.request.uri.path contains "xmlrpc") or
(http.request.uri.path contains "/.env") or
(http.request.uri.path contains "/.git/")

Set the action to block. This one rule kills the entire class of WordPress and dotfile scanning traffic that every public IP on the internet gets hit with, whether you run WordPress or not.

Two things will trip you up trying to actually ship this rule, and both cost me time:

The legacy Firewall Rules API, the POST /zones/<zone-id>/firewall/rules and /filters endpoints that every year-old blog post and Stack Overflow answer tells you to use, has been unsupported since 2025-06-15 and won’t take writes any more. Mine came back 403 with firewallrules.api.maintenance_mode in the body. Use the Rulesets API instead, at PUT /zones/<zone-id>/rulesets/phases/http_request_firewall_custom/entrypoint.

That PUT replaces the entire rule list for the phase, not just the rule you’re adding. Send it just your new rule and you will silently delete every other custom rule already on the zone. The correct pattern is read, modify, write:

Terminal window
# 1. get the current entrypoint ruleset for this zone/phase
curl -s -X GET \
"https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/phases/http_request_firewall_custom/entrypoint" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.rules' > existing_rules.json
# 2. append your new rule to the existing array, don't replace it
jq '. + [{
"action": "block",
"expression": "(http.request.uri.path contains \".php\") or (http.request.uri.path contains \"/wp-login\")",
"description": "Block CMS/exploit probe paths",
"ref": "block_cms_probe_paths",
"enabled": true
}]' existing_rules.json > new_rules.json
# 3. PUT the whole array back, existing rules and all
curl -s -X PUT \
"https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/phases/http_request_firewall_custom/entrypoint" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"rules\": $(cat new_rules.json)}"

The ref field on the rule is what saves you from yourself on a re-run. Give the rule a stable name like block_cms_probe_paths and check for it before appending, so running the script twice updates the existing rule instead of stacking a duplicate on top. I learned this the boring way: I added the rule to one zone by hand and forgot the ref, so when the bulk script came through afterward it didn’t recognize the rule it was looking at and cheerfully appended a second identical copy. Harmless, but it ate one of my five rule slots on that zone for nothing.

The free plan also caps you at five custom rules per zone. That’s not a lot once you’re running more than one site, so favor one broad rule over five narrow ones.

One more warning before you copy-paste this rule everywhere: don’t blindly block every .php path across every zone you own. I checked every zone I manage before rolling it out account-wide, and PHP paths returned a 2xx response on more than half of them. Almost all of that turned out to be the same soft-404 catch-all pattern that bit me in the first place, not actual PHP running. Almost all is not all, though. If any zone you manage genuinely runs WordPress, this rule takes it down. Check first.

3. Country-level managed challenge as the backstop

For the region the junk traffic keeps coming from, a managed challenge is the blunt instrument you reach for once the surgical rule above is in place.

(ip.src.country eq "SG"
and not starts_with(http.host, "cdn.")
and not starts_with(http.request.uri.path, "/api/"))

The exclusions aren’t optional decoration. A managed challenge needs a browser capable of solving it, and a lot of legitimate traffic isn’t a browser: cross-origin XHR calls, <img> hotlinks served off a CDN subdomain, package manager pulls, API clients. All of that just fails outright against a challenge page it can’t render. I shipped this exact rule, felt good about it for about a day, then found a comment-count endpoint on a subdomain that sat outside my /api/ exclusion and had quietly stopped working. Widen your exclusions before you widen your block.

What Actually Made the Difference

The WAF rule stopped the exact scan that started this. The Worker guard is the part that matters going forward, because it means the next scanner, and there is always a next one, gets a 404 or a 405 on its first request instead of a green light to keep going for two hours. Free-tier quotas are generous right up until something automated decides your server is worth 85,000 requests of its afternoon. Make sure your server tells it no the first time it asks.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Mimir + Grafana: Long-Term Prometheus Storage

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts