check / check (push) Waiting to run
- Engine error handling dropped as not built; unknown country and AS number each on its own; observe mode makes no ban of its own. - README no longer restates the request steps, the refusal answers or the design; its step list uses the spec's numbers. - Repeats, misreadable sentences, late verbs and long sentences reworded as the review asks. Model: fable-5-1
384 lines
17 KiB
Markdown
384 lines
17 KiB
Markdown
# 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.
|
|
|
|
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.
|
|
|
|
It needs no setting. Every 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
|
|
|
|
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
|
client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits
|
|
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a
|
|
limit is refused and banned. The first ban lasts an hour. Each repeat within a
|
|
day makes the next ban three times as long. A ban that would last more than
|
|
seven days is permanent.
|
|
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
|
default rule file bans such a client for seven days at its first probe. Its
|
|
next request during the ban makes the ban permanent. The default rule file
|
|
does not cover probes for other apps, such as a WordPress login. A `ban` rule
|
|
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
|
|
- Attacks carried in a request's URL or headers, such as SQL injection and path
|
|
traversal. The OWASP Core Rule Set is a published set of rules that recognise
|
|
common web attacks. Coraza, a Go engine, runs those rules (see "Attack
|
|
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is
|
|
refused.
|
|
- Abuse from networks and countries you do not want. You can:
|
|
- refuse whole netblocks and countries;
|
|
- give the clients of chosen networks and countries a fraction of every
|
|
limit, so that they are banned sooner;
|
|
- refuse or limit clients on a blocklist or a DNS blocklist, or with a high
|
|
AbuseIPDB score;
|
|
- ban clients named by your own CrowdSec engine.
|
|
- Slow and oversized requests. Every request and every answer has a time limit
|
|
and a size limit.
|
|
|
|
For the operator, it:
|
|
|
|
- writes one JSON log line for every request, 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));
|
|
- serves Prometheus metrics (see [`docs/metrics.md`](docs/metrics.md));
|
|
- keeps its bans, counters and history in JSON files you can read and edit, so
|
|
that a restart loses nothing and a ban can be lifted with an editor (see
|
|
[`docs/state-files.md`](docs/state-files.md)).
|
|
|
|
Not built, and not planned:
|
|
|
|
- TLS and routing for several apps. Both stay with traefik, the reverse proxy in
|
|
front of the container.
|
|
- Browser challenges such as captchas.
|
|
- A web console.
|
|
- Defence against floods large enough to fill the host's network link.
|
|
|
|
## Getting started
|
|
|
|
Build the image from a clone. `make docker` runs the tests and the linter, then
|
|
builds the image, tagged `smallwebwaf`. It builds for amd64, on an amd64 host
|
|
only: the `Dockerfile` checks Ubuntu's package lists against the hashes of the
|
|
amd64 archive.
|
|
|
|
```sh
|
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
|
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:
|
|
|
|
- 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`.
|
|
|
|
```dockerfile
|
|
# The smallwebwaf image, pinned by digest.
|
|
FROM <registry>/smallwebwaf@sha256:<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
|
|
```
|
|
|
|
`app.run` sits beside the `Dockerfile`. `--listen` and `--trusted-proxies` stand
|
|
for the app's own options:
|
|
|
|
```bash
|
|
#!/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 "$@"
|
|
```
|
|
|
|
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)).
|
|
|
|
`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.
|
|
|
|
## 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
|
|
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.
|
|
|
|
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
|
2. The client is identified.
|
|
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.
|
|
6. The country lists.
|
|
7. The reputation sources.
|
|
8. The client's limit percentage.
|
|
9. The rate limits.
|
|
10. The trap paths.
|
|
11. The rule files.
|
|
12. The Core Rule Set.
|
|
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`.
|
|
14. The request is passed to the app.
|
|
15. After the answer: the byte limits, the error burst and 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)).
|
|
|
|
## 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.
|
|
[`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_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.
|
|
- `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
|
|
`/wp-login.php,/xmlrpc.php` in front of a code forge. A request for one bans
|
|
the client.
|
|
- `SWWAF_WAF_DISABLED_RULES`: Core Rule Set rule ids to switch off when a rule
|
|
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.
|
|
- `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.
|
|
- `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`.
|
|
|
|
## 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;
|
|
- a ban for a broken limit, and a ban for a probe made permanent;
|
|
- orderly stops with `sv stop` and `docker stop`;
|
|
- bans surviving a new container on the same volume;
|
|
- a setting given as a file.
|
|
|
|
## 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.
|
|
The tests and the linter run in Docker, as phases of the `Dockerfile`, and never
|
|
on the host.
|
|
|
|
- `script/bootstrap`: installs on the host what the other scripts need, with
|
|
`make bootstrap`:
|
|
- `make`, `git` and `curl`;
|
|
- Go, for `gofmt`;
|
|
- node, yarn and prettier.
|
|
- `script/setup`: readies a fresh clone: `script/bootstrap`, then
|
|
`script/install-precommit`. `make setup`.
|
|
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
|
|
images with.
|
|
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
|
|
tests, as the `test` phase of the `Dockerfile`. `make test`.
|
|
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
|
|
the `tidy` stage of the `Dockerfile`. `make tidy`.
|
|
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
|
`make lint`.
|
|
- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
|
|
`make fmt`.
|
|
- `script/fmt-check`: checks the formatting and changes nothing.
|
|
`make fmt-check`.
|
|
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
|
|
`make check`.
|
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
|
first. `make docker`.
|
|
- `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`.
|
|
- `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`.
|
|
|
|
## Rationale
|
|
|
|
Small self-hosted sites 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;
|
|
- scanners walking through lists of WordPress and `.env` paths;
|
|
- 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". 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:
|
|
|
|
- per-client limits that ban;
|
|
- 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.
|
|
|
|
## Design
|
|
|
|
The `smallwebwaf` image is built on Ubuntu 26.04 LTS, pinned by digest, with
|
|
nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
|
|
`nix-env -iA nixpkgs.<name>` and gets the same package each time it is built.
|
|
[`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.
|
|
- 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 `smallwebwaf` 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.
|
|
- `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`.
|
|
|
|
"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.
|
|
|
|
## 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.
|
|
|
|
## Documents
|
|
|
|
- [`SPEC.md`](SPEC.md): how `smallwebwaf` behaves, and why.
|
|
- [`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.
|
|
- [`docs/settings.md`](docs/settings.md): every setting, its default and the
|
|
values it takes.
|
|
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
|
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
|
`/_smallwebwaf/`, with examples.
|
|
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
|
gets.
|
|
- [`docs/metrics.md`](docs/metrics.md): the metrics.
|
|
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
|
holds.
|
|
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
|
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea.
|
|
|
|
## License
|
|
|
|
MIT. See [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|