caddy-waf packs a WAF, blacklists and rate limiting into one Caddy module
Caddy WAF (Regex Rules, IP and DNS filtering, Rate Limiting, GeoIP, Tor, Anomaly Detection)
At a glance
- What is it?
- fabriziosalmi/caddy-waf is a Go middleware for the Caddy web server, registered as http.handlers.waf, that inspects requests and responses in four phases, scores regex matches against a threshold, enforces IP, DNS, ASN and country lists, and limits request rates. It is a single Caddy module, licensed AGPL-3.0, and it makes several deliberate choices about bounded memory and fail-open behaviour.
- Who is it for?
- caddy-waf fits teams already running Caddy who want one module covering request inspection, blacklist enforcement and rate limiting without a separate reverse proxy layer, and who can accept AGPL-3.0 obligations. Skip it if you need response body inspection at scale, since Phase 4 buffering stops at the configured limit, or if your deployment cannot make the GeoIP and blacklist data files available at runtime.
- Can I use it commercially?
- Yes, with strict conditions. AGPL-3.0 is a network copyleft licence: if people use a modified version over a network, for example as a hosted service, you must offer them its source code under the same licence.
- Is it still maintained?
- Yes. The repository last received commits 6 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One module, one handler ID, many security jobs
The project is a Web Application Firewall middleware for the Caddy web server, written in Go, and it is registered with Caddy under a single module ID, `http.handlers.waf`.
github.com/caddyserver/caddy/v2 v2.11.7
github.com/fsnotify/fsnotify v1.10.1
github.com/oschwald/maxminddb-golang v1.13.1
github.com/phemmer/go-iptrie v0.0.0-20240326174613-ba542f5282c9That single registration is what makes the project interesting. Instead of sitting in front of Caddy as a separate proxy, the middleware is compiled into the server and configured either in the Caddyfile or directly as JSON, so the request path stays inside one process with no extra hop and no second configuration surface.
The README lists the module as registered in Caddy's package registry, which means it is selectable on Caddy's own download page as a build package, and the Go module path is `github.com/fabriziosalmi/caddy-waf`. The version constant lives in `caddywaf.go` as `wafVersionDefault`.
The scope is broad for something called a WAF. The project description enumerates regex rules, IP and DNS filtering, rate limiting, GeoIP, Tor blocking and anomaly detection, and the repository topics place it alongside OWASP, geo-blocking, ASN blocking and rate limiter tags. The top-level tree shows how much of the work is tooling rather than the handler itself, with scripts such as `get_blacklisted_ip.py`, `get_blacklisted_dns.py`, `get_caddy_feeds.py` and `get_owasp_rules.py`, a `caddytest.py` harness, a `check_waf_config.py` validator and a `benchmark.py`.
The licence is AGPL-3.0, and the README flags it explicitly as a copyleft licence with a note to check it suits your deployment before integrating.
Four phases, a score, and two possible reactions
The inspection model is the core mechanism, and the README describes it as requests and responses inspected across four well-defined phases.
Phase 1 covers request headers and pre-request checks. Phase 2 reads the request body. Phase 3 looks at response headers. Phase 4 inspects the response body. The order matters, because a rule in Phase 1 can stop a request before the body is ever read.
Rules are regular expressions, and the matching engine is Go's `regexp` package, which is RE2 based and gives a linear-time guarantee. The README states plainly that there is no catastrophic backtracking, and compiled patterns are cached per rule ID. That is a meaningful difference from a rule engine built on a backtracking engine, where a badly written pattern is a denial of service vector against your own firewall.
Each rule carries a `score`, and those scores accumulate into a per-request total. A request is blocked when the total reaches `anomaly_threshold`, so a single noisy pattern does not take down a request on its own. Each rule also has a `mode` of `block` or `log`, and `block` short-circuits the request while `log` records the match and continues.
That combination is the most operationally useful part of the design. A new rule set can be loaded in log mode first, and the metric that tells you whether it is safe to enforce is the score distribution you observe, not a guess. The sample provisioning log in the README shows a configuration with an anomaly threshold of 5 and 33 loaded rules, split across Phase 1 and Phase 2, with no Phase 3 or Phase 4 rules in that particular set.
Five ways to exclude a request before the rules run
The middleware layers several lists on top of the regex engine, and each one uses a different lookup structure.
The IP blacklist handles plain addresses and CIDR ranges for both IPv4 and IPv6, and stores them in a prefix trie through the `go-iptrie` library, so a lookup is a trie walk rather than a scan of the list. The DNS blacklist is an exact, case-insensitive host lookup, which is a simpler structure and a correspondingly simpler matching rule.
GeoIP country blocking and whitelisting read a MaxMind GeoLite2 Country MMDB, and ASN blocking reads a MaxMind GeoLite2 ASN MMDB. Tor exit node blocking works by periodically fetching the list from the Tor project's bulk exit list endpoint, and the sample log shows the count of exit nodes that were picked up on a run.
The sample log also shows the scale these lists reach, with an IP blacklist of 223770 valid entries and a DNS blacklist of 854479 entries loaded, and it includes a warning line for the case where the GeoIP database is missing, saying that country blacklisting and whitelisting will be disabled. That degradation is deliberate and there is a switch for it: `geoip_fail_open` controls whether a GeoIP lookup failure blocks the request or allows it through.
Which way you set that switch is a policy decision with real consequences. Failing open keeps traffic flowing when the database is missing or unreadable, at the cost of a silent gap in the filtering. Failing closed protects the policy at the cost of turning a data file problem into an outage. The README offers the switch and does not tell you which to pick.
Rate limiting per IP and per path, plus a metrics endpoint
Rate limiting is per IP, sliding-window, and can be narrowed to specific paths using regular expression patterns. The sample provisioning log shows the shape of that configuration, with a limit of 100 requests in a 10 second window, a cleanup interval of 300 seconds, and a path pattern of `/api/v1/.*` with matching limited to those paths rather than all of them.
The cleanup interval is the detail that matters for a long-running server. Sliding window state has to be garbage collected, and a background sweep is how the memory stays bounded when the client population is large. The middleware exposes a JSON metrics document at the configured `metrics_endpoint` path, and the rule hit counts behind it are per-rule counters using `atomic.Int64` values stored in a `sync.Map`, which the README calls wait-free counters. A metrics endpoint is not a dashboard, but it is enough to scrape hit counts per rule and see which patterns are firing, which is the raw signal for tuning an anomaly threshold.
Logging is asynchronous, with a buffered log channel and a synchronous fallback when the buffer fills, so a slow log sink degrades into blocking rather than into dropped audit records.
Building it into Caddy with xcaddy
The requirements are specific. Go 1.26.0 or newer, because `go.mod` declares `go 1.26.0` and the README notes this is propagated from `caddy/v2`. Caddy v2.11.x or newer. And `xcaddy`, the tool that builds a Caddy binary with plugins included.
module github.com/fabriziosalmi/caddy-waf
go 1.26.0The recommended build path installs `xcaddy` and builds Caddy with the module included.
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/fabriziosalmi/caddy-waf
./caddy list-modules | grep waf # expect: http.handlers.wafThe last line is the check worth running, because a build that silently did not include the plugin produces a server that starts and then refuses the config. The module list is where you confirm the handler is present.
There is a quick script path as well, which pipes the install script from the repository into bash.
curl -fsSL -H "Pragma: no-cache" \
https://raw.githubusercontent.com/fabriziosalmi/caddy-waf/refs/heads/main/install.sh | bashThe README says the script ensures Go and `xcaddy` are installed, clones the repository, downloads the GeoLite2 database, builds Caddy with the module, and starts the server. The third path builds from a local clone, which is what you want when you intend to change the code.
git clone https://github.com/fabriziosalmi/caddy-waf.git
cd caddy-waf
go mod tidy
wget https://git.io/GeoLite2-Country.mmdb # optional, only for GeoIP
xcaddy build --with github.com/fabriziosalmi/caddy-waf=./
./caddy fmt --overwrite
./caddy runThe README marks the `caddy add-package` route as not preferred, noting that Caddy's maintainers advise preferring `xcaddy` or the container image.
The container path and the data files it deliberately leaves out
The `Dockerfile` and `docker-compose.yml` in the repository document the operational decisions, and they are unusually candid about what went wrong before.
The image is published as `ghcr.io/fabriziosalmi/caddy-waf`, and the compose file pins an exact release rather than `latest`, with the comment explaining that this project has shipped security fixes and that `latest` leaves no way to name the image you tested against. The commented `build: .` alternative is noted as actually compiling the working tree since v0.4.0.
image: ghcr.io/fabriziosalmi/caddy-waf:0.4.14The compose file mounts the Caddyfile and the rules file read-only, but mounts the IP and DNS blacklists read-write, with the comment explaining that the WAF watches these and reloads on change, so a host-side updater has to be able to rewrite them. That is the hot reload feature in practice, and it is the reason the volumes are not read-only.
The compose file also warns about Caddy's admin API on port 2019, which is unauthenticated and can reconfigure or stop the server, and notes it is left at Caddy's default localhost bind so other containers on the network cannot reach it.
The `Dockerfile` explains that GeoLite2 is not baked into the image, because fetching it at build time from a community mirror of uncertain freshness made the artefact vary by build date and embedded a data file of unknown provenance. Country and ASN filtering is optional, and an operator supplied, versioned database is mounted at runtime instead. The same reasoning applies to the cross-compilation comment, which notes that Go cross-compiles on the build platform rather than under emulation, so an arm64 image does not cost a QEMU emulated compile.
Bounded memory, guarded panics, and a copyleft licence
The engineering notes are where this project is most careful, and they are the section to read before deploying it.
Bodies are bounded on both sides. Request bodies are read through `io.LimitReader` with `max_request_body_size` defaulting to 10 MiB, and then restored with `io.MultiReader` so downstream handlers still see the full body. The response body is only held in memory when a Phase 4 rule exists to inspect it, and never past `max_response_body_size`, also 10 MiB by default. Beyond that limit, or as soon as the upstream flushes, the WAF releases what it holds and streams the rest, so memory does not scale with response size.
Matching a body without copying it uses `unsafe.String` to expose the body to rule matching, which avoids an allocation per request. That is a performance choice with a readability cost, and it is the kind of thing to look at in review rather than accept on description.
`ServeHTTP` installs a deferred recovery that returns `500 Internal Server Error` on a panic, which means a rule bug degrades one request rather than taking the server down. The sensitive data redaction feature is on by default, redacting values in logs by key name and by value content, and it is disabled with `redact_sensitive_data off`.
The licence is the last thing to settle. AGPL-3.0 is copyleft, the README says so in as many words, and the decision belongs to your organisation rather than to this project. On maintenance, the repository is not archived, the last push was on 2026-10-17, and the tagged releases move quickly, with v0.4.14 on 2026-09-05, v0.4.15 on 2026-09-08 and v0.4.16 on 2026-10-05. Patch releases inside a 0.4 line every few weeks is the pattern, and the `CHANGELOG.md` and `SECURITY.md` files are the places to read about what changed.
Editorial conclusion
caddy-waf fits teams already running Caddy who want one module covering request inspection, blacklist enforcement and rate limiting without a separate reverse proxy layer, and who can accept AGPL-3.0 obligations. Skip it if you need response body inspection at scale, since Phase 4 buffering stops at the configured limit, or if your deployment cannot make the GeoIP and blacklist data files available at runtime. Verify first that your Caddy version is v2.11.x or newer and your Go toolchain is 1.26.0 or newer, then load the rule set in log mode before switching a rule to block.
Frequently asked questions
How do I install caddy-waf into a Caddy server?
The recommended route is `xcaddy build --with github.com/fabriziosalmi/caddy-waf`, and the README requires Go 1.26.0 or newer, Caddy v2.11.x or newer, and `xcaddy`. You can confirm the plugin was included by running `./caddy list-modules | grep waf`, which should print `http.handlers.waf`.
How does the anomaly scoring decide whether to block a request?
Each rule contributes its `score` to a per-request total, and the request is blocked when the total reaches `anomaly_threshold`. Each rule has a `mode` of `block` or `log`, where `log` records the match and lets the request continue.
Which lists does caddy-waf filter against?
It supports an IP blacklist of plain addresses and IPv4 or IPv6 CIDR ranges, a case-insensitive exact match DNS blacklist, GeoLite2 Country and ASN databases for country and ASN rules, and a periodic fetch of the Tor bulk exit list. Rate limiting is per IP on a sliding window, with optional per-path regex matching.
What happens to the GeoIP filtering if the database is missing?
The README's sample log shows a warning that country blacklisting and whitelisting will be disabled when the GeoLite2 database is not found. The `geoip_fail_open` setting controls whether a GeoIP lookup failure blocks the request or lets it through.
Can I use caddy-waf in a commercial deployment?
The project is licensed under AGPL-3.0, and the README notes that this is a copyleft licence and advises checking that it suits your deployment before integrating. The README does not give legal advice on specific deployments, so that review is yours to do.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/fabriziosalmi-caddy-waf)Was this useful?