Files
smallwebwaf/README.md
T
clawbot 9501aad890
check / check (push) Successful in 3m31s
Per-client request rate limits over a minute, an hour and a day (closes #43)
Each client, one IPv4 address or one IPv6 /64, is counted in two buckets
per window, the earlier weighted by how much of it the window covers; at
most 20,000 clients are kept, least recently seen dropped first. A
request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000,
50000, or off) gets 429 before reaching the app. Refused requests count,
413s included. A clock set back over a second behind a bucket's start
restarts that window. The log line gains limit_hit and the action
rate_limited.

Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed.
Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests.
Deviation: go.mod and go.sum hand-written; no make target tidies them.

Model: opus-5-5
2026-10-04 02:14:51 +00:00

26 KiB

smallwebwaf

smallwebwaf is a simple, fast, logging web application firewall, MIT-licensed and written in Go by @sneak, for people who host their own services. It runs inside the container of the one application it protects, between your reverse proxy (traefik) and the app: the app's Dockerfile builds FROM the smallwebwaf image, traefik sends the app's requests to smallwebwaf on port 8080, and smallwebwaf passes them on to the app on 127.0.0.1:8081. It needs no setting, and protects the app from the first request with defaults chosen for a service on the open internet. It keeps its state in memory and in JSON files you can read and edit, and writes a detailed JSON log line for every request.

Status: the first milestone is built (#13), and the rate limits of the second (#14). smallwebwaf passes each request to the app and the app's answer back, unchanged, within its timeouts and size limits, works out each client's address, refuses a client that sends too many requests, and writes a JSON log line for every request. The country lists and the image an app builds on come with the rest of milestone 2, and the rest of the design after that, in the order of the build order in SPEC.md. The survey of existing tools that led to the design is in EVALUATION.md.

Getting started

smallwebwaf is one Go binary. Until milestone 2 brings its image, build and run it from a clone, with Go installed:

git clone https://git.eeqj.de/sneak/smallwebwaf.git
cd smallwebwaf
make build
SWWAF_UPSTREAM_URL=http://127.0.0.1:3000 ./bin/smallwebwaf

It then listens on port 8080 and passes every request to the app at SWWAF_UPSTREAM_URL, here an app on port 3000; with no setting at all, to an app on 127.0.0.1:8081. On SIGTERM or SIGINT it stops taking requests and gives those in progress five seconds to finish.

What it does so far

  • Passes each request to the app and the app's answer back unchanged: method, path, query, headers, body and status. Bodies stream through in both directions and are never held whole in memory. A WebSocket, or any other upgraded connection, passes through, and the timeouts do not cut it.
  • Works out the client's address. A TCP peer outside SWWAF_TRUSTED_PROXIES is the client, and the forwarded headers it sends are replaced, not passed on. For a peer inside it, X-Forwarded-For is read from the right, and the first address outside SWWAF_TRUSTED_PROXIES is the client; if every address in it is inside, the leftmost is, and with no header the peer is. The app sees what it would see from traefik directly: the same Host, the same X-Forwarded-Proto, and X-Forwarded-For with the peer added at the end.
  • Enforces the four timeouts and the two size limits below. A limit passed before the response has started gets smallwebwaf's own answer: 408 for a client too slow to send its request, 413 for a request body that is too large, 504 for an app too slow to answer, and 502 for a response that is too large or an app that cannot be reached. A request that announces a body over the limit is refused before anything reaches the app. While a request body is still on its way, a request timeout that runs out answers 408 if smallwebwaf was waiting for the client to send more, and 504 if it was waiting for the app to take what it had. Once the response has started, a limit can only cut the connection.
  • Counts each client's requests over a minute, an hour and a day. A request that takes the client over one of the rate limits below is refused with 429 before anything reaches the app, and so is each request after it until the client is back under every limit. A client is one IPv4 address, or one IPv6 /64, since one abuser usually holds a whole /64. Refused requests count too, so a client that keeps sending too fast stays refused until it slows down. Each window is counted in two fixed buckets, the earlier one weighted by how much of it the window still covers. At most 20,000 clients are kept, the least recently seen dropped first, and only in memory: a restart starts every client afresh.
  • Writes a line in the request log for each request (see "Request log" below).

Settings

Each setting is an environment variable, and each has a default, so none has to be set. A setting that is set but invalid stops the start with a message naming it, and the effective settings are logged at start.

  • SWWAF_LISTEN_ADDR (default :8080): where smallwebwaf listens.
  • SWWAF_UPSTREAM_URL (default http://127.0.0.1:8081): the app, as http or https, a host and an optional port, and nothing more.
  • SWWAF_TRUSTED_PROXIES (default 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, the private address ranges): the netblocks whose X-Forwarded-For is believed. A list given replaces the default; set but empty, it trusts nothing.
  • SWWAF_CLIENT_REQUEST_TIMEOUT (default 60s): how long a client may take to send its request line and headers, and then, from the end of the headers, its body.
  • SWWAF_CLIENT_RESPONSE_TIMEOUT (default 30m): how long the response may take to reach the client, from the end of the request to the last byte.
  • SWWAF_UPSTREAM_REQUEST_TIMEOUT (default 60s): how long connecting to the app and sending it the whole request may take.
  • SWWAF_UPSTREAM_RESPONSE_TIMEOUT (default 30m): how long the app may take to send its whole answer, from the end of the request to the last byte.
  • SWWAF_REQUEST_MAX_BYTES (default 100M): the largest request body.
  • SWWAF_RESPONSE_MAX_BYTES (default 5G): the largest response body.
  • SWWAF_RATE_LIMIT_PER_MINUTE (default 1000), SWWAF_RATE_LIMIT_PER_HOUR (default 10000) and SWWAF_RATE_LIMIT_PER_DAY (default 50000): the most requests a client may make in a minute, an hour and a day. The defaults are several times what one busy person produces, since a browser loading a heavy page makes a few hundred requests and several people often share one address.

Durations are in Go's syntax, with d for days (90s, 15m, 7d). Sizes are bytes, with an optional K, M or G, which are powers of 1024 (1K is 1024 bytes). Rate limits are whole numbers of requests. Netblocks are in CIDR form, and a bare address stands for itself alone. off switches a timeout, a size limit or a rate limit off.

Four limits are fixed rather than settings. The request line and headers may take up to 32 KiB, above which the answer is 431 and nothing reaches the app. A kept-open connection that sends nothing for 120 seconds is closed. That is longer than the 90 seconds after which traefik closes a connection it is not using, so traefik never sends a request on a connection smallwebwaf is closing. At most 20,000 clients are kept for the rate limits, and an IPv6 client is counted by its /64.

Request log

smallwebwaf writes one JSON object per line on stdout for every request, refused ones included:

{"type":"request","time":"2026-10-03T12:00:00.123Z","client_ip":"203.0.113.9","peer_ip":"172.18.0.2","method":"GET","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"upstream_status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","action":"forward","duration_total":3.217,"duration_upstream_total":3.104}
  • time is when the request arrived, in UTC. peer_ip is the TCP peer, normally traefik. path and query are as the client sent them.
  • status is what the client was sent, 0 if nothing was; upstream_status is what the app answered, and is left out when the app did not answer.
  • request_bytes and response_bytes count body bytes.
  • action is forward for a request passed to the app, rate_limited for one refused for a rate limit, too_large for a request or response over its size limit, timed_out for one that ran out of time, and upstream_error when the app could not be reached or its answer broke off.
  • limit_hit is there for a request refused for a rate limit, and names the window whose limit it went over: minute, hour or day, the shortest if it went over several.
  • aborted is there, and true, when the client went away early.
  • duration_total and duration_upstream_total are in milliseconds.

No body and no other header is logged. smallwebwaf's own messages (start, the settings, stop, errors) share the stream as JSON lines marked "type":"process".

Go's HTTP server, on which smallwebwaf is built, reads a request's line and headers before smallwebwaf sees the request, and some requests end there, without a line in the log: headers over 32 KiB, which it answers 431, headers slower than SWWAF_CLIENT_REQUEST_TIMEOUT, whose connection it closes without an answer, and requests it cannot read at all, which it answers itself, mostly with 400.

Why

Small self-hosted sites now receive a great deal of traffic nobody asked for: scrapers that ignore robots.txt and crawl every commit of every repository on a public git server, vulnerability scanners walking through lists of WordPress and .env paths, and credential-guessing bots. Most of it comes from a small number of hosting networks and countries. A single-person operation has no abuse desk and no CDN contract; it needs something small that can be put in front of one service and left alone.

The existing tools each solve part of this. Rule-based firewalls catch attack payloads but do not limit request rates. Rate limiters count requests but cannot tell a residential visitor from a rented server farm. The products that do most of it want several containers, a database and a web console. None of them can say "clients from these networks are banned after half as many requests as anyone else", which is the most useful thing to be able to say when nearly all abuse comes from a known list of AS numbers. EVALUATION.md goes through the candidates one by one.

smallwebwaf is meant to fill that gap:

  • protect a service from misbehaving scrapers and scanners with per-client request and byte limits over a minute, an hour and a day;
  • lower those limits for the countries and AS numbers that abuse commonly comes from, so their clients are banned after fewer requests than others;
  • ban abusers: briefly at first, longer each time they come back, and permanently when they keep at it; a scanner's first probe bans it for seven days;
  • log everything in a form that is easy to search and ship elsewhere;
  • stay small enough to understand: one binary in the app's own container, environment variables, no database, no required setting.

Proposed features

  • Reverse proxy for one application, streaming in both directions, with WebSocket support. One smallwebwaf per app, inside the app's own container.
  • Internet-ready out of the box: no setting is required, and every setting has a default chosen for a service facing the internet in 2026. Every setting's name starts with SWWAF_, so that it cannot clash with the app's own.
  • Real client address worked out from X-Forwarded-For, trusting only the proxy networks you list, by default the private address ranges. IPv6 clients are counted by /64 by default.
  • Size and time limits on requests and responses, with the time limits both between the client and smallwebwaf and between smallwebwaf and the app: by default a request may take 60 seconds and 100 MiB, a response 30 minutes and 5 GiB.
  • Rate limits per client on requests per minute, per hour and per day, and on bytes per minute, per hour and per day, on by default and set well above what real visitors need.
  • Netblocks that bypass rate limiting, netblocks that bypass everything, and netblocks that are always refused.
  • AS number and country lookup for every client, on by default through the free GeoJS web service, which is sent the address of every new visitor. The IPinfo Lite database file, which you download and mount, can be used instead, or lookups switched off (see "Country and AS number lookup" below).
  • Country lists: SWWAF_DENIED_COUNTRIES refuses every request from the countries listed, SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES every request from anywhere else. Such a request gets the answer a banned client gets as soon as the client's address has been looked up, before its body is read and without the rule files or the Core Rule Set looking at it, and no ban is made.
  • Biased limits: listed AS numbers and countries get a percentage of every limit, for example 50 percent for common abuse-source networks, so their clients are banned after fewer requests. Zero percent is a zero allowance: the first request breaks the limit and bans the client.
  • Attack detection:
    • a directory of plain text rule files, one regex per line, for catching scanning and penetration probes; easy to edit by hand, and picked up while running;
    • the OWASP Core Rule Set, run by the Coraza engine, refusing the requests it flags; it reads the URL and headers of every request, and request bodies only once you switch that on, since on a code forge they are full of code it would take for attacks;
    • trap paths, and a ban for a client that the rule files or the Core Rule Set refuse again and again.
  • Bans:
    • a clear sign of attack, such as a probe for a .env file or a scanner's user agent, bans for seven days on the first request, and any further request during those days makes the ban permanent;
    • breaking a limit bans for an hour; breaking one again within a day of a ban ending triples the length, and a ban that would last longer than seven days is permanent instead;
    • every ban carries notes on why it was made, to help decide whether to lift it.
  • IP reputation: downloadable blocklists, DNS blocklists, AbuseIPDB, and an optional feed of decisions from a CrowdSec engine. Lookups happen in the background and never delay a request. None is on until you add it.
  • Alerts on attacks and bans to a generic webhook, Slack or ntfy, with a cooldown and an hourly cap so a wide attack cannot flood the channel.
  • Anomaly alerts when requests or bytes per minute or hour cross a threshold you set, for a single client, its surrounding netblock, an AS number, a named netblock or the whole service.
  • Observe mode: log and alert on every decision while refusing nothing.
  • Request log: one JSON object per request on stdout with the usual web log fields, the decision taken and why, AS number and country, and timings. Optionally also sent to a remote syslog server.
  • Prometheus metrics, for a scraper that holds the metrics token.
  • State (bans with their notes, each client's counters and history, the GeoJS answers, the reputation cache, the alerting state) held in memory and kept in readable JSON files, written regularly and at every stop, so a restart loses nothing. Edit a file, or add a rule file, and the running smallwebwaf picks up the change. Nothing is read from disk while serving a request. The files come in milestone 3 or later (see the build order in SPEC.md); until then the rate counters and the GeoJS answers are kept in memory only, and a restart loses them.
  • Health checks, the metrics, and listing, adding and lifting bans or asking why a given address was refused, all on the one port every request uses: under /_smallwebwaf/ on the app's own address, through traefik like any other request. The metrics need the metrics token, and the ban management the admin token.

Not planned: TLS termination, routing for several apps, browser challenges (captcha or proof of work), a web console, or defence against floods large enough to fill the host's network link.

How it works, in short

For each request smallwebwaf:

  • works out who the client really is;
  • lets it straight through if it is on the bypass list, refuses it if it is on the deny list or currently banned;
  • looks up its AS number and country, and refuses it if that country is denied, or is not among the only ones allowed;
  • checks for a cached reputation verdict;
  • picks the client's limit percentage from those;
  • checks the minute, hour and day request counters against the limits, and bans the client if it breaks one;
  • checks the request against the rule files and the Core Rule Set, and bans the client at once for a clear sign of attack;
  • forwards it to the app and streams the response back, within the size and time limits;
  • counts the bytes and any refusal by the rule files or the Core Rule Set, bans the client if it broke a limit, updates its history, sends any alerts that are due, and writes the log line.

A minimal deployment is the app's own Dockerfile, built on the smallwebwaf image, with no setting. That image is built on Ubuntu 26.04 LTS, the newest long-term support release of Ubuntu, pinned by digest, and moves to the next one when it ships. It has nixpkgs installed, so the app adds the packages it needs from nixpkgs. Beyond its FROM line the app's Dockerfile adds the app's binary, any packages it needs, and the app's runit service, which starts the app as a user of its own, listening on 127.0.0.1:8081:

# The smallwebwaf image, pinned by digest.
FROM <registry>/smallwebwaf:<pinned digest>

# Packages the app needs, if any, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.git

# The app's binary, and a user of its own to run it.
COPY app /usr/local/bin/app
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app

# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run

with app.run beside the Dockerfile, where --listen and --trusted-proxies stand for the app's own options:

#!/usr/bin/env bash
set -euo pipefail

main() {
    sleep 1
    exec chpst -u app:app /usr/local/bin/app \
        --listen 127.0.0.1:8081 \
        --trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
}

main "$@"
  • The image's entrypoint, runsvinit, has runit start smallwebwaf and the app side by side, each as its own user, and start either again a second after it exits. Leave out ENTRYPOINT and USER from the app's Dockerfile.
  • nix-env -iA nixpkgs.<name> installs a package from the nixpkgs in the image, and the app finds it on its PATH. That nixpkgs is fixed at one commit, so the same smallwebwaf image always gives the app the same packages; newer ones come with a newer smallwebwaf image.
  • Deploy it as you deploy any app, with traefik's labels on this one container pointing at port 8080. upaas needs no change for this.
  • The app has to trust 127.0.0.1 and ::1 for forwarded headers, besides the private address ranges, since the requests it gets now come from smallwebwaf on loopback. An app left at the usual default, the private ranges alone, sees every visitor as 127.0.0.1.
  • Port 8080 is the only one the app must leave free: the health check, the metrics and ban management are all on it, under /_smallwebwaf/. The image's health check passes while smallwebwaf answers and the app accepts connections. SWWAF_LISTEN_ADDR can move smallwebwaf to another port, which the app then leaves free instead; the health check follows it, and traefik's labels must point at it. The address part of SWWAF_LISTEN_ADDR stays empty (for example :9000, never 127.0.0.1:9000), so smallwebwaf keeps listening on every address: traefik reaches it on the container's address, and the health check on 127.0.0.1.
  • smallwebwaf keeps its state files in /var/lib/smallwebwaf. Mount a volume there to keep bans and client history when a deploy replaces the container; without one, it still starts.

A rule file is one rule per line: a name, what to match against, what to do, and a regex.

env-file       path        ban  (?i)^/\.env(\.[a-z]+)?$
scanner-agent  user_agent  ban  (?i)\b(sqlmap|nikto|nuclei|wpscan)\b

SPEC.md has the full design: the deployment, every environment variable, the ban rules, the rule file format, the state files, the log fields, the metrics, failure behaviour and the build order.

Country and AS number lookup

smallwebwaf looks up the AS number and country of every client, for the request log, the metrics and the ban notes, and for the country lists and biased limits when you set them. It works with no setup: by default it asks the free GeoJS web service, which needs no account and no file. This means that, by default, the address of every new visitor is sent to GeoJS. Each answer is kept in memory for seven days, and many addresses are asked about in one request; writing the answers to disk, so that they survive a restart, comes in milestone 3 or later (see the build order in SPEC.md). GeoJS publishes no rate limit but may block a caller it thinks asks too much; while it is not answering, new visitors count as coming from an unknown country, which SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuses.

To keep your visitors' addresses on your own host, set SWWAF_LOOKUP_SOURCE=off, or use the database file instead of GeoJS: SWWAF_LOOKUP_SOURCE=file reads the free IPinfo Lite database (ipinfo_lite.mmdb). You download it with your own IPinfo account, mount the directory that holds it into the container, point SWWAF_LOOKUP_DB_PATH at the file and refresh it when you choose; smallwebwaf never downloads it itself, and reads it again when you replace it. It has to be the directory rather than the file itself: docker does not show a single mounted file being replaced, so a refresh would go unseen. IPinfo releases it under the Creative Commons Attribution-ShareAlike 4.0 International License and asks for attribution, in its own words on https://ipinfo.io/lite: "The attribution requirements can be met by giving our service credit as your data source. Simply place a link to IPinfo on the website, application, or social media account that uses our data." Its example of such a credit is a link mentioning "IP address data is powered by IPinfo". A service that uses the database through smallwebwaf should carry that link.

Neither source can place a private address, so a client on one, such as a visitor on your local network, another container or your monitoring, has no country: SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuses it unless you list it in SWWAF_ALLOW_NETS. Such addresses are never sent to GeoJS. In milestone 2, which has no SWWAF_ALLOW_NETS, neither country list checks such a client; the refusal comes with SWWAF_ALLOW_NETS in milestone 3 or later.

How the code is laid out

  • cmd/smallwebwaf: the binary, which only calls internal/smallwebwaf.
  • internal/smallwebwaf: the process: it reads the settings, listens, serves requests until SIGTERM or SIGINT, and stops.
  • internal/config: reads the settings, the one place they are read.
  • internal/proxy: what happens to each request: it works out the client, runs the checks, passes the request to the app and the answer back with the standard library's httputil.ReverseProxy within the timeouts and size limits, and writes the request's log line. Its check method is where a request is refused before anything reaches the app: for a rate limit, for an announced body over the size limit, and, with the rest of milestone 2, for the country lists.
  • internal/ratelimit: counts each client's requests and tells when one takes it over a rate limit.
  • internal/requestlog: the lines on stdout: the request log line and the process's own messages.

Besides the Go standard library, github.com/hashicorp/golang-lru/v2 keeps the table of clients to 20,000, dropping the least recently seen.

Entrypoints

This repository adheres to the Scripts to Rule Them All standard: the scripts in script/ are the entrypoints for working on it, and the Makefile targets are thin shims that call them. The scripts are POSIX sh, so that they run in minimal containers.

  • script/bootstrap: installs what the other scripts need on the host: make, git, Go for gofmt, node and yarn, and prettier.
  • script/setup: readies a fresh clone: runs script/bootstrap, then script/install-precommit.
  • script/projectname: prints the project's name, smallwebwaf, which script/docker and the others tag their images with.
  • script/test: runs the tests, as the test phase of the Dockerfile.
  • script/lint: runs golangci-lint, as the lint phase of the Dockerfile.
  • script/fmt: formats the Go code with gofmt and the Markdown with prettier.
  • script/fmt-check: checks the formatting, and changes nothing.
  • script/check: runs script/test, script/lint and script/fmt-check.
  • script/docker: builds the image, whose build runs the tests and the linter first.
  • script/cibuild: what CI runs: script/bootstrap, script/check, then the image build.
  • script/precommit: run by the git pre-commit hook; runs script/check.
  • script/install-precommit: installs that hook; make hooks runs it.
  • script/build: builds bin/smallwebwaf on the host, with Go installed, for working on the code by hand; make build runs it.
  • script/run: builds bin/smallwebwaf with script/build and runs it; make run runs it.

TODO

  • Milestone 2: the country lists and the image an app builds on (#14); its rate limits are built.
  • The rest of the design, in the order of the build order in SPEC.md.

Documents

License

MIT. See LICENSE.

Author

@sneak