SPEC.md is the only place that says how smallwebwaf behaves and why; docs/ holds lists linking to it; the README is short and links to both. Passed independent review in round 8. Model: fable-5-1 (writing); opus-5-5 (review, merge)
17 KiB
smallwebwaf
smallwebwaf is an MIT-licensed web application firewall, written in Go by
@sneak, 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 says how smallwebwaf behaves and why. The
reference documents under 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
/.envor/.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. Abanrule of your own, or an entry inSWWAF_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). 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); - sends alerts to a webhook, Slack or ntfy (see
docs/alerts.md); - serves Prometheus metrics (see
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).
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.
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
runscript that starts the app listening on127.0.0.1:8081.
# 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:
#!/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:
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).
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 gives each step in full, under the same numbers.
- The health check,
GET /_smallwebwaf/healthz, is answered at once. - The client is identified.
- The static lists,
SWWAF_ALLOW_NETSandSWWAF_DENY_NETS. - The bans.
- The lookup of the client's AS number and country.
- The country lists.
- The reputation sources.
- The client's limit percentage.
- The rate limits.
- The trap paths.
- The rule files.
- The Core Rule Set.
smallwebwaf's own endpoints, under/_smallwebwaf/.- The request is passed to the app.
- 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). 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).
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 lists every setting, its default and the
values it takes. The ones most deployments touch:
SWWAF_MODE=observe:smallwebwafmakes 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 whatenforcemode 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.phpin 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 (seedocs/settings.md).SWWAF_REQUEST_MAX_BYTES,SWWAF_CLIENT_REQUEST_TIMEOUTandSWWAF_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),fileoroff, as above.SWWAF_ADMIN_TOKENandSWWAF_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_URLorSWWAF_ALERT_NTFY_URL: where alerts go.SWWAF_INSTANCE_NAME: the name in every log line, alert and metric, such asfsn1app1/gitea.
Checking that it works
docker inspect --format '{{.State.Health.Status}}' <container>sayshealthyoncesmallwebwafanswers 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/healthzanswersok. - To see a refusal without banning yourself, run the container with
SWWAF_MODE=observeand ask for/.env. The request reaches the app. Its log line has"would_action":"banned"and"rule_ids":["env-file"]. Inenforcemode the same request bans your address for seven days, and your next request makes the ban permanent. Lift the ban by editingbans.json(see "Bans" inSPEC.md). - With
SWWAF_METRICS_TOKENset,curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metricsanswers with the metrics. make example-appbuilds 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 stopanddocker 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
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, withmake bootstrap:make,gitandcurl;- Go, for
gofmt; - node, yarn and prettier.
script/setup: readies a fresh clone:script/bootstrap, thenscript/install-precommit.make setup.script/projectname: printssmallwebwaf, which the other scripts tag their images with.script/test: checks thatgo.modandgo.sumare tidy, then runs the tests, as thetestphase of theDockerfile.make test.script/tidy: writesgo.modandgo.sumasgo mod tidywrites them, in thetidystage of theDockerfile.make tidy.script/lint: runs golangci-lint, as thelintphase of theDockerfile.make lint.script/fmt: formats the Go code withgofmtand the Markdown with prettier.make fmt.script/fmt-check: checks the formatting and changes nothing.make fmt-check.script/check: runsscript/test,script/lintandscript/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; runsscript/check.script/install-precommit: installs that hook.make hooks.script/build: buildsbin/smallwebwafwith the Go on your host.make build.script/run: buildsbin/smallwebwafand runs it with the settings in your environment, listening on port 8080 in front of an app atSWWAF_UPSTREAM_URL. Its state files go inbin/stateand its rule files are those ofshare/rules.d, unlessSWWAF_STATE_DIRandSWWAF_RULES_DIRsay 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 whatsmallwebwafdid with each. Needs network access, and a docker daemon that pushes to a registry onlocalhostover plain HTTP.docs/gitea.mdrecords the results.make gitea-requests.
Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore
robots.txtand crawl every commit of every repository on a public git server; - scanners walking through lists of WordPress and
.envpaths; - 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 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 lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs
smallwebwafand 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 ofsmallwebwaf. smallwebwaflistens on port 8080 and passes requests to the app on127.0.0.1:8081. The app listens on127.0.0.1only, so nothing reaches it except throughsmallwebwaf.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 on127.0.0.1. So the app must trust127.0.0.1and::1for forwarded headers, besides the private address ranges. - Both processes start with the container's environment variables, so every
setting of
smallwebwafstarts withSWWAF_, and the app can read a token given as one. A token given as a file that only thesmallwebwafuser can read is out of its reach (see "Settings given as files" indocs/settings.md). - The state files live in
/var/lib/smallwebwaf. A volume mounted there keeps them across a deploy. docker stopstops both processes at once.smallwebwafgives the requests in progress five seconds to finish, so the app must keep answering for five seconds afterSIGTERM.
"Deployment" in 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 lists the packages and
what each does.
TODO
- Planned, as "Planned, not built" in
SPEC.mddescribes:- sharing bans between the instances of several apps;
- sending the log over RELP.
Documents
SPEC.md: howsmallwebwafbehaves, and why.EVALUATION.md: what already exists, what each tool covers and misses, and why none was adopted.REPO_POLICIES.md: the policies this repository follows.docs/settings.md: every setting, its default and the values it takes.docs/rules.md: the rule file format and the default file.docs/admin-endpoints.md: the endpoints under/_smallwebwaf/, with examples.docs/request-log.md: the request log's fields.docs/alerts.md: the alert events and what each destination gets.docs/metrics.md: the metrics.docs/state-files.md: the state files and what each holds.docs/image.md: what the image holds and how it is pinned.docs/gitea.md:smallwebwafin front of gitea.
License
MIT. See LICENSE.