Honeypots & threat-based banning
Turning predictable attacker probes into instant long bans, without writing a single line of honeypot code.
A FastAPI app doesn't have /wp-login.php. A Python service doesn't have /phpmyadmin. Anyone hitting these routes is fingerprinting your stack with known scanner playbooks.
The probe itself is the signal
Probe = hostile. Single access. No threshold needed. No need for volume. The URL itself is the smoking gun. This is qualitatively different from rate-limit thinking; there's no "how often" to argue about.
Common scanner targets (all are honeypots in the example app):
/wp-login.php · /admin.php - WordPress admin probes
/phpmyadmin - DB admin panel probes
/.env · /.env.production - config exfiltration
/.git/config - repo exfiltration
/.aws/credentials - cloud key exfiltration
/server-status - Apache status leak
Threat-based ban vs rate-limit ban
Both end in a ban. They get there by counting different things.
| Rate-limit ban | Threat-based ban | |
|---|---|---|
| Trigger | too many requests in window | N detections of a specific attack class |
| Counter | requests per IP, regardless of content | detections per IP, per class |
| Knob | auto_ban_threshold + auto_ban_duration | threat_ban_config["<class>"] |
| Use case | noisy traffic of any shape | known-bad signals; honeypots |
A single high-confidence signal can outweigh thousands of borderline ones. That's the whole game.
There's no honeypot code path
Honeypots work because the route's path matches detection patterns. Same pipeline as any other threat detection - no special-casing:
GET /.env → path matches file-inclusion patterns → detection emitted with file-inclusion context → counter increments → ban if > thresholdHoneypots are an emergent property of routes whose paths happen to match patterns the detector is already looking for. Define more honeypot routes by adding more decorators - no library changes needed.
Four detection groups, four config keys
Detection patterns are organized into four groups. Each group has one corresponding key in threat_ban_config.
| Detection group | Example patterns | threat_ban_config key |
|---|---|---|
| SQLi | ' OR 1=1 --, UNION SELECT ..., '; DROP TABLE | "sqli" |
| Command injection | ; rm -rf /, $(curl http://...), `whoami` | "cmd_injection" |
| SSRF | http://localhost, http://169.254.169.254, file:// | "ssrf" |
| File inclusion | ../../etc/passwd, /admin.php, /.env, /wp-login.php | "file_inclusion" |
Honeypot paths all fall in the file-inclusion group. That's the only key that matters for honeypot-driven bans.
threat_ban_config - threshold and duration per class
A dict of class-name → policy. Each entry independent. Keys you omit silently disable threat-based ban for that class. Detections still flow to the dashboard, but no auto-ban kicks in.
threat_ban_config={
"sqli": {"threshold": 2, "duration": 7200}, # 2 hits → 2h ban
"cmd_injection": {"threshold": 1, "duration": 86400}, # 1 hit → 24h ban
"ssrf": {"threshold": 2, "duration": 7200},
"file_inclusion": {"threshold": 2, "duration": 7200},
}threshold - number of detections (of this class, from this IP) before the ban fires. Counter is bounded. Entries age out on the counter's TTL. Not a lifetime tally. threshold=1 means one detection = ban.
duration - ban length in seconds. Fixed. No exponential backoff. A second ban is the same length as the first.
Honeypot routes - stacked decorators on one handler
FastAPI stacks decorators on a single function. The body is irrelevant. What matters is that the path matches detection patterns the middleware already scans for.
@app.get("/admin.php")
@app.get("/wp-login.php")
@app.get("/wp-admin/admin-ajax.php")
@app.get("/phpmyadmin")
@app.get("/server-status")
@app.get("/.env")
@app.get("/.env.production")
@app.get("/.git/config")
@app.get("/.aws/credentials")
async def honeypot(request: Request):
return {"trapped": True, "path": request.url.path}Adding a new honeypot path is one line: another @app.get(...) above the function. No middleware change, no config change. Response body is noise - return 200 with bait JSON, 404, or 418 - equivalent for security purposes.
Honeypots want file_inclusion threshold = 1
With threshold=2: GET /admin.php → detection → counter=1, no ban. GET /.env → detection → counter=2, BAN. Two free probes.
With threshold=1: GET /admin.php → detection → counter=1, BAN. First probe is the last.
If you have legacy paths that might trigger file-inclusion patterns, keep threshold=2, or rename them first.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Detections show in dashboard but no ban applied | Detection's class isn't a key in threat_ban_config - detection-only mode for that class | Add the missing key with appropriate threshold and duration |
Real users hit /admin.php (legacy redirect) | A path you still serve was added to the honeypot stack | Move legitimate traffic to a different route first. Never honeypot a path that has real users. |
| Internal scanners and monitoring keep tripping honeypots | Vulnerability scanners and uptime monitors probe common paths | Whitelist scanner IPs, or point internal monitoring at a route that bypasses the middleware |