zio-http middleware that pairs the BadActor detector with a request pipeline:
Extract a client IP from the request (default: last value of X-Forwarded-For, falling back to request.remoteAddress).
Decide whether the request looks "suspect" (default: WordPress / PHP probing patterns in the path).
Call BadActor.checkReq. If the IP is now banned, fail the request with a slow random-byte stream (a tarpit) so the attacker burns time reading garbage.
how to derive the client IP from a request. Defaults to forwardedFor. If None is returned the request is short-circuited with 400 Bad Request — there is no IP to track, so letting it through would silently bypass the guard.
suspect
predicate that decides whether a request should count toward the ban window. Defaults to defaultSuspect.
Default IP extractor. Reads the last value of X-Forwarded-For, which is conventionally what a single-hop reverse proxy (Heroku, Fly, Cloud Run, single CDN) writes when it forwards to your origin. Falls back to request.remoteAddress when the header is absent.
Default IP extractor. Reads the last value of X-Forwarded-For, which is conventionally what a single-hop reverse proxy (Heroku, Fly, Cloud Run, single CDN) writes when it forwards to your origin. Falls back to request.remoteAddress when the header is absent.
Multi-hop deployments where the client-supplied X-Forwarded-For should not be trusted will want to override this with a custom extractor that picks the appropriate value (typically the first one your trust boundary added).
any path containing wp-includes, wp-admin, or wp-content
This is the dominant signal for opportunistic scanning against a small Scala/JVM HTTP service. Override with your own predicate when serving paths that legitimately contain those substrings.
Default response for banned IPs: 200 OK with application/json Content-Type and a gibberishStream body. Returning 200 (rather than 403 / 429) is deliberate — a non-success status is a strong signal to stop, but a 200 with garbage bytes keeps the scanner busy.
Default response for banned IPs: 200 OK with application/json Content-Type and a gibberishStream body. Returning 200 (rather than 403 / 429) is deliberate — a non-success status is a strong signal to stop, but a 200 with garbage bytes keeps the scanner busy.
30-second stream of 1KB random-byte chunks emitted at 10 chunks/sec. Used as the body of the default banned-actor response: the connection stays open and the client keeps reading useless data instead of moving on to its next probe.
30-second stream of 1KB random-byte chunks emitted at 10 chunks/sec. Used as the body of the default banned-actor response: the connection stays open and the client keeps reading useless data instead of moving on to its next probe.