|
| 1 | +# Case study — 1-hour production observation |
| 2 | + |
| 3 | +Real-world data from the first hour after the per-IP pass was enabled on a Laravel-fronted reference site behind Cloudflare. All site/customer identifiers anonymized; only public hostile-source data (scraper IPs, ASNs, user-agents) is shown. |
| 4 | + |
| 5 | +## TL;DR |
| 6 | + |
| 7 | +| Metric | Value | |
| 8 | +|--------|-------| |
| 9 | +| **Bot IPs banned in the first run** | 402 | |
| 10 | +| **New IPs caught in the next 5 cron ticks** (50 min) | 4 | |
| 11 | +| **`nginx 444` responses** (actual blocks fired) | 89 | |
| 12 | +| **Subnet-pass candidates above threshold** during the hour | 0 | |
| 13 | +| **False positives observed** | 0 | |
| 14 | +| **Customer support tickets / regressions** | 0 | |
| 15 | +| **Server load impact** | ~10-15 sec per cron run, no nginx reload errors | |
| 16 | + |
| 17 | +## Background |
| 18 | + |
| 19 | +The site was already running the **subnet pass** (`/24` aggregation) with default settings, but had recently noticed a wave of distributed Chinese-cloud bots hitting `/<some-public-url>/<UUID>` pages — each IP making 1-2 requests over a 24h window. None of these triggered the subnet pass because no single `/24` accumulated enough volume. |
| 20 | + |
| 21 | +The per-IP pass was enabled with the default threshold (`per_ip_threshold=9`). |
| 22 | + |
| 23 | +## T+0 — first scan after enabling per-IP |
| 24 | + |
| 25 | +Output of `autoblock --show-per-ip` on the 30-minute window of access logs immediately after enabling: |
| 26 | + |
| 27 | +``` |
| 28 | +Per-IP pass: threshold=9, candidates=402 |
| 29 | +SCORE REQS IP ASN reasons |
| 30 | +----- ---- ------------------ ----------------------------------- ------------------------------------------------ |
| 31 | + 13 20 202.46.62.24 BAIDU Beijing Netcom noassets,noref,4xx,upath,cloud,ua:oldchrome |
| 32 | + 13 15 202.46.62.81 BAIDU Beijing Netcom noassets,noref,4xx,upath,cloud,ua:oldchrome |
| 33 | + 13 15 202.46.62.117 BAIDU Beijing Netcom noassets,noref,4xx,upath,cloud,ua:oldchrome |
| 34 | + 13 15 202.46.62.20 BAIDU Beijing Netcom noassets,noref,4xx,upath,cloud,ua:oldchrome |
| 35 | + ... (~30 more from 202.46.62.0/24 at score 11-13) |
| 36 | + 10 6 104.210.56.225 MICROSOFT-CORP-MSN-AS-BLOCK noassets,noref,cloud,ua:oldchrome (HubSeedsBot/1.0) |
| 37 | + 10 7 95.211.164.101 LEASEWEB-NL-AMS-01 Netherlands noassets,noref,upath,cloud (VacancyValidator/1.0) |
| 38 | + 10 8 182.242.168.225 CHINANET-BACKBONE noref,4xx,upath,cloud,ua:oldchrome |
| 39 | + ... (~370 more) |
| 40 | +``` |
| 41 | + |
| 42 | +All 402 entries written to `/etc/nginx/blocked-ips.conf`, nginx reloaded automatically by the script. |
| 43 | + |
| 44 | +## T+10 to T+50 — five cron ticks |
| 45 | + |
| 46 | +The script runs every 10 minutes. Each tick re-scans the 30-minute window and either adds new candidates or refreshes the TTL of existing ones. |
| 47 | + |
| 48 | +``` |
| 49 | +T+10: +1 new block | 402 existing IPs extended | 511 subnets scanned |
| 50 | +T+20: +1 new block | 403 existing IPs extended | 498 subnets scanned |
| 51 | +T+30: +2 new blocks | 404 existing IPs extended | 553 subnets scanned |
| 52 | +T+40: +0 new blocks | 406 existing IPs extended | 563 subnets scanned |
| 53 | +T+50: +0 new blocks | 406 existing IPs extended | 604 subnets scanned |
| 54 | +``` |
| 55 | + |
| 56 | +**Four new scrapers caught autonomously:** |
| 57 | + |
| 58 | +``` |
| 59 | +T+10 2a09:bac5:50ee:3032::4cd:b IPv6 cloud, score 9 noassets,extref,upath,cloud |
| 60 | +T+20 45.148.10.21 score 10 26 req, 4xx probing, old Chrome |
| 61 | +T+30 139.199.162.133 score 10 cloud + old Chrome |
| 62 | +T+30 100.24.210.54 AMAZON-AES, score 10 cloud + old Chrome |
| 63 | +``` |
| 64 | + |
| 65 | +Each was first observed in the access log between scan ticks, scored above the threshold once it had enough requests to evaluate, and added to the block list at the next tick. |
| 66 | + |
| 67 | +## Actual nginx blocks fired (`HTTP 444`) |
| 68 | + |
| 69 | +Counting `444` responses in the access log over the first hour: |
| 70 | + |
| 71 | +``` |
| 72 | +89 total 444 responses to bot retry attempts |
| 73 | +
|
| 74 | +Top hit IPs: |
| 75 | + 5 45.148.10.21 (the new scraper caught at T+20 — its remaining attempts blocked) |
| 76 | + 3 100.24.210.54 |
| 77 | + 2 54.193.6.55 |
| 78 | + 2 202.46.62.88 |
| 79 | + 2 202.46.62.79 |
| 80 | + ... (~15 more Baidu Netcom IPs at 2 hits each) |
| 81 | +``` |
| 82 | + |
| 83 | +Pattern: most scrapers came back 2-5 times within the hour, each time getting `444` (silent close, zero response body). The Baidu Netcom subnet is "polite" enough to keep some interval between attempts; the more aggressive ones (AWS-hosted, generic VPS) retry harder. |
| 84 | + |
| 85 | +## Score signal distribution across the 402 first-pass IPs |
| 86 | + |
| 87 | +| Signals present | Count | Typical source | |
| 88 | +|-----------------|-------|----------------| |
| 89 | +| cloud + ua:oldchrome + noref + noassets + upath + 4xx (score 13) | ~120 | Coordinated probing botnets (Baidu Netcom, parts of CHINANET) | |
| 90 | +| cloud + ua:oldchrome + noref + noassets (score 10) | ~180 | One-shot cloud scrapers (AWS, Azure, GCP, OVH, Hetzner) | |
| 91 | +| cloud + ua:oldchrome + upath (score 8-9) | ~70 | Low-volume scanning from cloud | |
| 92 | +| cloud + ua:headless (score 6, didn't pass) | not blocked | Edge cases — held under threshold for safety | |
| 93 | + |
| 94 | +The signals composed naturally: every IP had a different combination, and the score reflected how confident the fingerprint was. |
| 95 | + |
| 96 | +## What the subnet pass would NOT have caught |
| 97 | + |
| 98 | +The Baidu Netcom subnet `202.46.62.0/24` — the largest single concentration in the data — **did** previously trigger the subnet pass after the operator manually added a one-off `/24` block (because the original cron was misconfigured and scanning the wrong access log). After fixing that, the subnet pass would have caught it eventually — but only as one block of the entire `/24`. |
| 99 | + |
| 100 | +The per-IP pass produced: |
| 101 | +- **30+ separate `/32` bans** with individual evidence trails (score, signals, request count, ASN) |
| 102 | +- The remaining 370 unrelated scrapers from **other cloud sources** — none of which would have triggered the subnet pass because each one came from a different `/24` with only 1-5 requests. |
| 103 | + |
| 104 | +That's the architectural difference between concentration-based and behavior-based detection. |
| 105 | + |
| 106 | +## Resource cost |
| 107 | + |
| 108 | +- **Cron run time**: 8-15 seconds per tick (down from ~30 sec when the script was hitting the larger main access log without any tuning; jumped up because parsing a 12 MB log on each scan and running ip-api batches takes time). |
| 109 | +- **nginx reload**: handled inside the script after each successful write; no errors, no observable downtime. |
| 110 | +- **`blocked-ips.conf` file size**: ~50 KB after first hour (~400 entries × ~120 bytes per line including comment). |
| 111 | +- **Cleanup cron**: at 03:30 UTC daily, removes expired entries (default 7-day TTL). Tested working. |
| 112 | + |
| 113 | +## What this doesn't show |
| 114 | + |
| 115 | +- **Long-term FP rate**. The 0% false-positive number is after manual review of the top 35 candidates against ip-api.com. A small fraction of edge-case real users (VPN exit + old browser + ad blocker + direct link, no referer) could theoretically score 8-10. Recommend monitoring `444` responses by country/device in Cloudflare analytics for the first week. |
| 116 | +- **Comparison against other tools**. We didn't run Cloudflare Bot Fight Mode or fail2ban in parallel for A/B benchmarking. Just internal validation against ip-api.com reputation flags. |
| 117 | +- **Effect on actual server load**. The site was already healthy before per-IP enable; nothing to "rescue" — this is preventive defense, not incident response. |
| 118 | + |
| 119 | +## Recommended defaults from this data |
| 120 | + |
| 121 | +For a similar setup (Laravel/PHP CMS behind Cloudflare, mostly residential ISP traffic + cloud-hosted scrapers): |
| 122 | + |
| 123 | +```ini |
| 124 | +per_ip_enabled=true |
| 125 | +per_ip_threshold=9 # default; ~1.5% of IPs flagged |
| 126 | +per_ip_ttl_days=7 |
| 127 | +per_ip_chrome_min_version=142 # ~6mo behind current Chrome stable |
| 128 | +internal_ref_hosts=your-domain.com # critical for noref/extref signals |
| 129 | +self_ips=YOUR.SERVER.IP # exclude your own IP for health-checks |
| 130 | +``` |
| 131 | + |
| 132 | +Threshold 9 was tested against this traffic profile. Sites with heavy power-user / VPN traffic might raise to 10 (cuts to ~80 IPs in first run, very conservative). Sites that want maximum coverage and have CF + Turnstile or similar as fallback might drop to 8 (extends to ~600 IPs but admits some borderline cases). |
0 commit comments