JIT
← Notebook

Two gates in front of the website

Defense-in-depth at the homelab edge with CrowdSec: known-bad IPs dropped at the door, then an in-band WAF reads what's left. And why it's deliberately fail-open for now.

The website runs behind Traefik, which already gives it TLS and clean routing. But TLS only proves who you’re talking to, not what they’re sending. I wanted two more gates in front of the site: one that throws out visitors with a bad reputation before they cost me anything, and one that actually reads the request and refuses the obviously malicious. Both are CrowdSec.

Client inbound HTTPS request Traefik, TLS termination reverse proxy · writes a JSON access log ① Bouncer, IP reputation source IP vs the ban cache (refreshed 60s) local scenarios + CrowdSec community blocklist ✗ known-bad IP, dropped allowed ② AppSec WAF · Coraza (in-band) reads the request against WAF rules today: virtual patching (known-CVE signatures) ✗ exploit signature, 403, inline clean Website, served · 200 CrowdSec agent parses the access log → runs scenarios → writes ban decisions CrowdSec CAPI (cloud) community blocklist, auto-pulled anonymised signals shared access log ban decisions
Two gates per request: reputation drops known-bad IPs, then the WAF reads what's left and blocks exploit signatures inline. Clean traffic reaches the site. Underneath, the access log becomes ban decisions that, with the community blocklist, keep refreshing the first gate.

Gate one, reputation, at the door

The first gate is the cheapest. A CrowdSec bouncer plugin sits inside Traefik and, for every request, checks the source IP against a cache of ban decisions it refreshes each minute. Two things fill that cache: local scenarios that read Traefik’s own access log and ban IPs caught probing (the crowdsecurity/traefik and http-cve rules), and the CrowdSec community blocklist: addresses other installs have already reported, pulled down automatically. It’s the bouncer checking your face against the list at the door. A known-bad IP never reaches the app. Blunt and fast, which is exactly right for the bulk of internet noise.

Gate two, a WAF that reads the request

Reputation can’t catch a clean-looking IP sending a dirty request. That’s the second gate: CrowdSec’s AppSec component, a WAF built on the Coraza engine. Where gate one checks your face, this is the bag scanner reading what you’re actually carrying. The same bouncer forwards each allowed request to it for in-band inspection. The verdict comes back before the request reaches the site. Today it runs virtual-patching rules: 182 signatures for specific, known CVE exploits, the near-zero-false-positive kind. Match one and you get a 403, inline. The test is satisfying: GET /200, GET /.git/config403.

# Gate 2 (WAF) live: a clean request passes, an exploit probe is blocked inline.
curl -sI https://www.jit-c.eu/            | head -1   # HTTP/2 200
curl -sI https://www.jit-c.eu/.git/config | head -1   # HTTP/2 403  (CrowdSec AppSec)

# Gate 1 (IP reputation): what's banned right now, local scenarios + community blocklist?
cscli decisions list

The honest part: fail-open, and a crash on day one

First deploy, CrowdSec crash-looped. The stock config referenced a rule set nothing had installed, and it refused to start. The site never noticed. The bouncer is fail-open: if the WAF is unreachable, traffic still flows. The bouncer was passed out on the pavement and the party inside carried on. I’d rather serve an unfiltered request than give a misbehaving security tool a kill switch over my own website. The fix was a small self-contained config that loads only the rules I actually have; it came up clean.

A WAF that takes your site down when it breaks isn’t protecting your site. Start fail-open; earn fail-closed.

Fail-open is deliberate for now. Next: the OWASP Core Rule Set as a broader net, then a default-deny allowlist of what the app legitimately does, and only then the flip to fail-closed, once I’ve watched it long enough to trust it. One loose end: the community blocklist updates itself, but the rule signatures don’t. A scheduled refresh is on the list. A virtual patch you never update is just a comment.