Files
smallwebwaf/README.md
T
sneak 01049aae4e
check / check (push) Successful in 1m56s
Deploy model: listen port, token files, state directory owner (closes #33)
SWWAF_LISTEN_ADDR may set another port: the health check takes its port
from it, and traefik's port label must name the same one. A token file
is made on the host owned by uid 65532 with mode 0400 and its directory
mounted read-only; through upaas, that directory is one of the app's
volume mounts. The run script of smallwebwaf makes the state directory
and every file in it belong to the smallwebwaf user.

Model: opus-5-5
2026-10-03 15:46:20 +00:00

434 lines
23 KiB
Markdown

# smallwebwaf
`smallwebwaf` is a simple, fast, logging web application firewall, written in Go
by [@sneak](https://sneak.berlin), 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
(https://git.eeqj.de/sneak/smallwebwaf/issues/13). `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, and writes a JSON log line for
every request. Rate limits per client, the country lists and the image an app
builds on come with milestone 2
(https://git.eeqj.de/sneak/smallwebwaf/issues/14), and the rest of the design
after that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey
of existing tools that led to the design is in [`EVALUATION.md`](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:
```sh
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 milestone 1 does
- 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.
- 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.
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). Netblocks are in CIDR form, and a bare address stands for itself alone.
`off` switches a timeout or a size limit off.
Two 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, and 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.
## 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, `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.
- `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`](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.
- 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`:
```dockerfile
# 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:
```bash
#!/usr/bin/env bash
set -euo pipefail
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
```
- 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.
- `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`](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. 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.
## 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
milestone 2's rate limits and country lists refuse a request.
- `internal/requestlog`: the lines on stdout: the request log line and the
process's own messages.
Only the Go standard library is used.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/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: rate limits per client, the country lists and the image an app
builds on (https://git.eeqj.de/sneak/smallwebwaf/issues/14).
- The licence (https://git.eeqj.de/sneak/smallwebwaf/issues/15).
- The rest of the design, in the order of the build order in
[`SPEC.md`](SPEC.md).
## Documents
- [`SPEC.md`](SPEC.md): the design.
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
and misses, and why none was adopted.
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
## Author
[@sneak](https://sneak.berlin)