rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 8m17s

`smallwebwaf` with no command now prints the commands and exits with status 2.
The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The
passing-on code, its settings and its log fields and metric go. internal/proxy
keeps the checks, answers, reports and own endpoints; its tests run through the
real rproxy. The documents describe the pair.

Judgement call: clients.json keeps token_refused, since state files refuse unknown fields.
Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests.

Model: opus-5-5
This commit is contained in:
2026-11-05 08:54:11 +00:00
committed by sneak
parent 17c5dd2438
commit e3b159db07
71 changed files with 2652 additions and 5492 deletions
+215 -137
View File
@@ -1,19 +1,29 @@
# smallwebwaf
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by
[@sneak](https://sneak.berlin), for one self-hosted web app. It runs inside the
app's own container, in front of the app. It refuses the requests of scrapers,
scanners and other abusers before they reach the app.
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
requests of scrapers, scanners and other abusers before they reach an app.
A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it
accepts requests on the app's behalf, passes them to the app, and passes the
app's answers back.
refuses the ones that look like abuse. `smallwebwaf` is one binary with two
commands that do this together:
It needs no setting. Every `SWWAF_` setting has a default chosen for a service
on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and
why. The reference documents under [`docs/`](docs/) are listed under "Documents"
below.
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. It accepts
requests on the app's behalf and asks `servewaf` about each one. It passes the
allowed ones to the app, and the app's answers back. It keeps nothing, and
never touches disk.
- `smallwebwaf servewaf` decides. It runs the checks, and keeps the bans,
counters and history. One `servewaf` serves many sites.
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy`
waits at most 2 seconds for `servewaf`'s answer, and without one passes the
request to the app.
`rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf`
is. In a container of its own, it also needs `SWRP_UPSTREAM_URL`, which says
where the app is. Every other setting has a default chosen for a service on the
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
reference documents under [`docs/`](docs/) are listed under "Documents" below.
## What it protects against
@@ -54,7 +64,7 @@ below.
For the operator, it:
- writes one JSON log line for every request, saying what it did and why (see
- logs each request as JSON lines, saying what it did and why (see
[`docs/request-log.md`](docs/request-log.md));
- sends alerts to a webhook, Slack or ntfy (see
[`docs/alerts.md`](docs/alerts.md));
@@ -65,8 +75,8 @@ For the operator, it:
Not built, and not planned:
- TLS and routing for several apps. Both stay with traefik, the reverse proxy in
front of the container.
- TLS and routing by host name. Both stay with traefik, the reverse proxy in
front of `rproxy`.
- Browser challenges such as captchas.
- A web console.
- Defence against floods large enough to fill the host's network link.
@@ -84,14 +94,68 @@ cd smallwebwaf
make docker
```
Push the image to a registry your hosts pull from. Then build the app's own
image on it. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
by digest, and adds:
Push the image to a registry your hosts pull from.
The image runs `rproxy` by default. To run `servewaf` from the same image, set
the container's `SMALLWEBWAF_COMMAND` to `servewaf`.
### The usual setup: three containers
The usual setup is three containers on one private network:
- the app;
- `rproxy`, in front of the app, which traefik routes to;
- `servewaf`, with a volume for its state files.
```sh
docker network create site
docker run --detach --name servewaf --network site \
--env SMALLWEBWAF_COMMAND=servewaf \
--volume servewaf-state:/var/lib/smallwebwaf \
<registry>/smallwebwaf@sha256:<digest>
docker run --detach --name app --network site \
<registry>/app@sha256:<digest>
docker run --detach --name rproxy --network site \
--env SWRP_WAF_BACKEND=http://servewaf:8080 \
--env SWRP_UPSTREAM_URL=http://app:8081 \
<registry>/smallwebwaf@sha256:<digest>
```
Here the app listens on port 8081, at its container's address on that network.
Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `servewaf`. Anything on that network can reach the app around
`rproxy`, and can ask `servewaf` questions.
With no setting, `servewaf` sends the address of every new visitor to GeoJS, a
web service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
database file on your own host, looks addresses up in that file (see "Lookup of
AS number and country" in [`SPEC.md`](SPEC.md)).
### Many sites, one `servewaf`
Run one `rproxy` for each app, all with `SWRP_WAF_BACKEND` naming the same
`servewaf`. It keeps one table of clients and bans for all of them:
- A client banned through one site is refused on all of them.
- A client's requests on all of them count toward one set of limits.
- Log lines, alerts and ban notes name the site by its host.
### `rproxy` in the app's container
An app's image can also be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app. `servewaf` still runs in a container of its own. The app's
`Dockerfile` starts `FROM` the `smallwebwaf` image, named by digest, and adds:
- the app's binary;
- the packages the app needs, from the nixpkgs in the image;
- a user for the app;
- a `run` script that starts the app listening on `127.0.0.1:8081`.
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`.
```dockerfile
# The smallwebwaf image, pinned by digest.
@@ -126,38 +190,43 @@ main "$@"
```
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `smallwebwaf` and the app side by side, each as its own user.
Deploy the app's image as you deploy any app. Point traefik's labels at port
8080 (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Mount a
volume at `/var/lib/smallwebwaf`, so that bans and history survive a deploy. For
example, with no setting:
```sh
docker run --detach --name app \
--volume app-smallwebwaf:/var/lib/smallwebwaf \
<registry>/app@sha256:<digest>
```
With no setting, the address of every new visitor is sent to GeoJS, a web
service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
database file on your own host, looks addresses up in that file (see "Lookup of
AS number and country" in [`SPEC.md`](SPEC.md)).
entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set.
`deploy/example-app` is such an app. `make example-app` builds it and checks it
(see "Checking that it works" below). To run `smallwebwaf` outside the image
while working on it, see "Entrypoints" below.
(see "Checking that it works" below). To run `rproxy` and `servewaf` on the host
while working on them, see `script/run` under "Entrypoints" below.
### When `servewaf` is down, slow or overloaded
`SWRP_DEFAULT_DISPOSITION` decides what `rproxy` does with a request when
`servewaf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWRP_TIMEOUT_MS`, 2 seconds by default.
Its default, `allow`, passes the request to the app without `servewaf`'s answer.
`deny` answers `503` instead. Either way the request gets no report, so its
bytes are not counted and it is not added to its client's history. When
`servewaf` does answer, a `deny` or an error goes to the visitor as `servewaf`
gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part.
## What happens to a request
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step
refuses a request, the steps after it are skipped, apart from step 15, and
A request goes through fifteen steps, in this order. Once `servewaf` refuses a
request, the steps after that are skipped, apart from the report of step 15, and
nothing of the request reaches the app. "What happens to a request" in
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
`rproxy` takes the first two:
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
2. The client is identified.
2. The visitor's address is worked out, and `servewaf` is asked about the
request.
`servewaf` takes the checks:
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
4. The bans.
5. The lookup of the client's AS number and country.
@@ -167,31 +236,36 @@ nothing of the request reaches the app. "What happens to a request" in
9. The rate limits.
10. The trap paths.
11. The rule files.
12. The Core Rule Set.
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`.
12. The header limits, and then the Core Rule Set.
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
that prefix.
`rproxy` takes the rest, with `servewaf` counting at the end:
14. The request is passed to the app.
15. After the answer: the byte limits, the error burst and the client's history.
15. After the answer, `rproxy` sends `servewaf` a report. For an allowed
request, `servewaf` counts the byte limits and adds the request to the
client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
Core Rule Set match answers `403`. A missing or wrong token gets `401`. A
request over a size or time limit gets the status for that limit, such as `413`
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)).
Core Rule Set match answers `403`. `rproxy` itself answers `405` to a method it
does not pass on, and the status for a size or time limit, such as `413` for a
body too large (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
## Settings you are likely to touch
Each setting is an environment variable of the app's container, such as
`docker run -e SWWAF_MODE=observe ...`, read at start.
Each setting is an environment variable, read at start. Those of `servewaf`
start with `SWWAF_`, and those of `rproxy` with `SWRP_`.
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the
values it takes. The ones most deployments touch:
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a
request only for a size or time limit or a missing or wrong token. A request
it would have refused is passed to the app, and its log line says what
`enforce` mode would have done. For trying a configuration before enforcing
it.
- `SWWAF_MODE=observe`: `servewaf` refuses nothing and makes no ban of its own.
A request it would have refused is passed to the app, and its log line says
what `enforce` mode would have done. For trying a configuration before
enforcing it. The limits of `rproxy` still apply.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
clients are refused only for a size or time limit or a missing or wrong token.
clients skip `servewaf`'s checks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
byte limits, such as a machine that talks to the app all day.
- `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as
@@ -201,39 +275,49 @@ values it takes. The ones most deployments touch:
refuses your app's ordinary requests. The log line names the rules a request
matched. Keep the six default ids in a list you give (see
[`docs/settings.md`](docs/settings.md)).
- `SWWAF_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`: raise them for an app that takes uploads
over 100 MiB or slower than 60 seconds.
- `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`: raise them for an app
that takes uploads over 100 MiB or slower than 60 seconds.
- `SWRP_DEFAULT_DISPOSITION`: `allow` (the default) or `deny`, as above.
- `SWRP_TRUSTED_PROXIES`: the netblocks whose `X-Forwarded-For` `rproxy`
believes, by default the private address ranges.
- `SWWAF_LOOKUP_SOURCE`: `geojs` (the default), `file` or `off`, as above.
- `SWWAF_ADMIN_TOKEN` and `SWWAF_METRICS_TOKEN`: switch on the ban endpoints and
the metrics. Give them as files to keep them from the app.
the metrics, at `servewaf`'s own address.
- `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or
`SWWAF_ALERT_NTFY_URL`: where alerts go.
- `SWWAF_INSTANCE_NAME`: the name in every log line, alert and metric, such as
`fsn1app1/gitea`.
- `SWWAF_INSTANCE_NAME`: the name in every line `servewaf` logs, and in every
alert and metric, such as `fsn1/servewaf`.
## Checking that it works
- `docker inspect --format '{{.State.Health.Status}}' <container>` says
`healthy` once `smallwebwaf` answers and the app accepts connections. A log
line with `"msg":"starting"` gives the version, the address and every setting
in effect, with secrets masked.
- A request to the site through traefik gets the app's answer, and its log line
has `"action":"forward"`. `https://<site>/_smallwebwaf/healthz` answers `ok`.
- To see a refusal without banning yourself, run the container with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. Its log
line has `"would_action":"banned"` and `"rule_ids":["env-file"]`. In `enforce`
mode the same request bans your address for seven days, and your next request
makes the ban permanent. Lift the ban by editing `bans.json` (see "Bans" in
[`SPEC.md`](SPEC.md)).
- With `SWWAF_METRICS_TOKEN` set,
`curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics`
answers with the metrics.
- `make example-app` builds the image and the example app on it, runs the app,
and checks it. It needs network access. It checks:
- the health check;
- a request forwarded to the app;
`healthy` for each container of `smallwebwaf`:
- for `rproxy`, once it answers and the app accepts connections, whether or
not `servewaf` answers;
- for `servewaf`, once it answers.
- A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer.
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
about the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. In `enforce` mode the same request bans your
address for seven days, and your next request makes the ban permanent. Lift
the ban by editing `bans.json` (see "Bans" in [`SPEC.md`](SPEC.md)).
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
`servewaf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
- `make example-app` builds the image and the example app on it, runs the app
with `rproxy` in its container and `servewaf` in another, and checks them. It
needs network access. It checks:
- the health checks;
- a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent;
- that only `servewaf`'s container changes the state directory's owner;
- with `servewaf` stopped, the app still answering through `rproxy`;
- with `servewaf` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume;
- a setting given as a file.
@@ -276,21 +360,26 @@ on the host.
- `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
`make build`.
- `script/run`: builds `bin/smallwebwaf` and runs it with the settings in your
environment, listening on port 8080 in front of an app at
`SWWAF_UPSTREAM_URL`. Its state files go in `bin/state` and its rule files are
those of `share/rules.d`, unless `SWWAF_STATE_DIR` and `SWWAF_RULES_DIR` say
otherwise. `make run`.
- `script/example-app`: builds the image and the example app on it, runs it with
a volume, and checks it as "Checking that it works" above describes. Then it
removes the containers, volumes and images. Needs network access.
`make example-app`.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it at
the defaults. It sends through it a sample of gitea's ordinary use and the
requests the Core Rule Set is known to refuse, and prints what `smallwebwaf`
did with each. Needs network access, and a docker daemon that pushes to a
registry on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md)
records the results. `make gitea-requests`.
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
host, with the settings in your environment. `make run`. Unless your settings
say otherwise:
- `rproxy` listens on port 8080, in front of an app at
`http://127.0.0.1:8081`;
- `servewaf` listens on port 8090;
- its state files go in `bin/state`;
- its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app on it, runs them
with a volume, and checks them as "Checking that it works" above describes.
Then it removes the containers, the network, the volumes and the images. Needs
network access. `make example-app`.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
`rproxy` and `servewaf`, three containers on one network. Every setting is at
its default, but for those that join the containers and the header that labels
each request. It sends through them a sample of gitea's ordinary use and the
requests the Core Rule Set is known to refuse, and prints what `servewaf` did
with each. Needs network access, and a docker daemon that pushes to a registry
on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the
results. `make gitea-requests`.
## Rationale
@@ -303,16 +392,16 @@ Small self-hosted sites receive a great deal of traffic nobody asked for:
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.
something small that can be put in front of its services 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". That is the useful thing 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.
of it want 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". That is
the useful thing 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` fills that gap with:
@@ -320,8 +409,8 @@ candidates one by one.
- lower limits for the networks and countries abuse comes from;
- bans that grow with repetition;
- a log that is easy to search and ship;
- one binary in the app's own container, with no database and no required
setting.
- a site that keeps answering while the part that decides is down;
- one binary, with no database and one required setting.
## Design
@@ -331,47 +420,36 @@ nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each
as its own user, and starts either again when it exits. The app cannot touch
the state files or the process of `smallwebwaf`.
- `smallwebwaf` listens on port 8080 and passes requests to the app on
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under
`/_smallwebwaf/`, on the same port.
- runit, a service supervisor, runs `smallwebwaf` and starts it again when it
exits. In an app's image built on the `smallwebwaf` image, it runs `rproxy`
and the app side by side, each as its own user.
- `rproxy` listens on port 8080 and passes requests to the app at
`SWRP_UPSTREAM_URL`. It answers the health check itself, and the refusals and
errors "`rproxy`" in [`SPEC.md`](SPEC.md) lists.
- `servewaf` listens on port 8080 of its own container. Nothing should route the
internet there. Its own endpoints, under `/_smallwebwaf/`, are for your admin
tools and your metrics collector.
- `rproxy` and `servewaf` use no token between them. Anything on their private
network can ask `servewaf` a question, or send it a report.
- The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust
`127.0.0.1` and `::1` for forwarded headers, besides the private address
ranges.
- Both processes start with the container's environment variables, so every
setting of this `smallwebwaf` process starts with `SWWAF_`, and the app can
read a token given as one. A token given as a file that only the `smallwebwaf`
user can read is out of its reach (see "Settings given as files" in
[`docs/settings.md`](docs/settings.md)).
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
them across a deploy.
- `smallwebwaf servewaf` runs the same checks for the sites that ask it about
each request, and passes nothing to an app (see "`servewaf`" in
[`SPEC.md`](SPEC.md)).
- `smallwebwaf rproxy` is the reverse proxy in front of one app that asks
`smallwebwaf servewaf` about each request. When `servewaf` cannot be connected
to, does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`,
`SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
answering. The settings of `rproxy` start with `SWRP_`, and it needs
`SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five
seconds after `SIGTERM`.
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
`rproxy`'s address for forwarded headers. With `rproxy` in the app's
container, that address is `127.0.0.1` or `::1`, which the private address
ranges leave out.
- The state files live in `/var/lib/smallwebwaf` in `servewaf`'s container. A
volume mounted there keeps them across a deploy.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health check, what
happens when a process stops, moving `smallwebwaf` to another port, and the
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and
what each does.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health checks, what
happens when a process stops, moving a port, and the forwarded headers.
"Architecture" in [`SPEC.md`](SPEC.md) lists the packages and what each does.
## TODO
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes:
- sharing bans between the instances of several apps;
- sending the log over RELP.
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending
the log over RELP.
## Documents