check / check (push) Waiting to run
A request is neither counted nor refused by the request rate limits when its path, percent-decoded, starts with one of the comma-separated prefixes in SWWAF_RATE_LIMIT_EXEMPT_PATHS. A request whose decoded path contains .. or a backslash, or whose path as sent holds an encoded slash, is never exempt, since an app may act on it as a path outside every prefix, such as /assets/..%2Flogin as /login. The static lists, bans and the country lists still apply. The setting is empty by default, and a prefix that does not start with / stops the start. README.md documents it. Model: opus-5-5
773 lines
45 KiB
Markdown
773 lines
45 KiB
Markdown
# smallwebwaf
|
|
|
|
`smallwebwaf` is a simple, fast, logging web application firewall, MIT-licensed
|
|
and 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 two milestones are built
|
|
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
|
|
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are seven parts of
|
|
milestone 3: the static lists, the bans that broken rate limits lead to, the
|
|
JSON state files and the paths the rate limits do not count, which come next in
|
|
the build order, `observe` mode, which comes a little later, and the metrics
|
|
endpoint and the header size and the idle time as settings, which come last in
|
|
it. `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,
|
|
bans a client that sends too many requests, not counting those for the paths you
|
|
choose, refuses a client that comes from a country you refuse or from a network
|
|
you refuse, lets the networks you choose through, keeps its bans, each client's
|
|
counters and history, and GeoJS's answers in JSON files across restarts, writes
|
|
a JSON log line for every request, serves Prometheus metrics to a scraper that
|
|
holds the metrics token, and in `observe` mode passes on the requests it would
|
|
refuse, logging what it would have done with them. It comes as the image the
|
|
app's own image is built on. The rest of the design comes 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
|
|
|
|
Build the `smallwebwaf` image from a clone:
|
|
|
|
```sh
|
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
|
cd smallwebwaf
|
|
make docker
|
|
```
|
|
|
|
`make docker` runs the tests and the linter, then builds the image, tagged
|
|
`smallwebwaf`, for amd64 and only on an amd64 host: the hashes the `Dockerfile`
|
|
checks Ubuntu's package lists against are those of Ubuntu's amd64 archive. Push
|
|
it to a registry your hosts pull from, and build each app's image on it, pinned
|
|
by digest, as "How it works, in short" below shows. `make example-app` builds a
|
|
small app on the image, the one in `deploy/example-app`, and checks that it
|
|
works.
|
|
|
|
To work on the code, `make build` builds the binary alone, with Go installed,
|
|
and `make run` builds and runs it, listening on port 8080 in front of an app at
|
|
`SWWAF_UPSTREAM_URL`, by default `http://127.0.0.1:8081`, with its state files
|
|
in `bin/state` unless `SWWAF_STATE_DIR` is set.
|
|
|
|
## 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 timeouts and the 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
|
|
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
|
bans the client. A request whose path starts with one of
|
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`, as that setting below describes, is neither
|
|
counted nor refused by the rate limits; the static lists, bans and the country
|
|
lists still apply to it. A client is one IPv4 address, or one IPv6 /64, since
|
|
one abuser usually holds a whole /64. 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, with
|
|
their history, and a restart gives no client a fresh allowance (see "State
|
|
files" below).
|
|
- Bans a client that breaks a rate limit, as "Bans" in [`SPEC.md`](SPEC.md)
|
|
describes: the first ban lasts an hour, and a limit broken again within a day
|
|
of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and
|
|
81 hours; a ban that would last longer than seven days is permanent instead. A
|
|
ban covers the client's netblock: its IPv4 address, or the netblock around it
|
|
that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or its IPv6 /64. While it lasts, every
|
|
request from the netblock is refused with `SWWAF_BAN_RESPONSE` after the
|
|
static lists and before the country lists, so the client is not looked up, and
|
|
is not counted for the rate limits. A ban sets the client's counters back to
|
|
zero. Each ban carries notes for deciding whether to lift it: the limit, its
|
|
window and the requests counted in it, the request that broke it, the client's
|
|
country when it was looked up, the netblock's requests since it was first
|
|
seen, how many of them the ban has refused, and how many bans the netblock had
|
|
before. At most `SWWAF_MAX_BANS` bans are kept, past, active and permanent;
|
|
past that, the earliest ban of the netblock that has gone longest without a
|
|
request is dropped first. `bans.json` shows the bans and their notes, and a
|
|
restart lifts none (see "State files" below); lifting a ban by editing it
|
|
comes with https://git.eeqj.de/sneak/smallwebwaf/issues/68.
|
|
- Refuses a request from a country you refuse with `SWWAF_BAN_RESPONSE`, as soon
|
|
as the client's country is known and before its body is read; such a request
|
|
is not counted for the rate limits. While one of the country lists below is
|
|
set, each client's country is looked up through GeoJS (see "Country and AS
|
|
number lookup" below); with neither set, no visitor's address leaves the host.
|
|
A client on a private, loopback or link-local address has no country and is
|
|
never looked up: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless it is
|
|
in `SWWAF_ALLOW_NETS`, and `SWWAF_DENIED_COUNTRIES` does not refuse it.
|
|
- Checks the client's own address against the static lists, the three netblock
|
|
settings below, before anything else, its country included. A client in
|
|
`SWWAF_ALLOW_NETS` skips bans, the country lists and the rate limits, and is
|
|
not looked up; the timeouts and size limits still apply. A client in
|
|
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
|
|
read, and the request is not counted for the rate limits; an address in
|
|
`SWWAF_ALLOW_NETS` too is let through. A client in
|
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
|
|
limits; the country lists and bans still apply to it.
|
|
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
|
|
that `SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would refuse:
|
|
it passes them to the app, and their log lines name what `enforce` mode would
|
|
have done (see `would_action` in "Request log" below). The checks run, and
|
|
requests are counted, as in `enforce` mode, but a broken rate limit makes no
|
|
ban and does not set the client's counters back to zero, so each request over
|
|
the limit is logged as one that would be refused. The bans in `bans.json` are
|
|
kept, and refuse requests again when `smallwebwaf` next runs in `enforce`
|
|
mode, as long as they last. The timeouts and size limits still apply, since
|
|
they protect `smallwebwaf` and the app themselves, and a request for the
|
|
metrics without the token is still answered `401`. It is for trying a
|
|
configuration before enforcing it.
|
|
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
|
check and without asking the app, for the image's health check.
|
|
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
|
|
a request that carries `SWWAF_METRICS_TOKEN` as
|
|
`Authorization: Bearer <token>`, and with `401` for one that does not. While
|
|
the token is unset the metrics answer `404`, as does any other request under
|
|
`/_smallwebwaf/`. Unlike the health check, such a request goes through every
|
|
check any other request goes through, and is answered where another would be
|
|
passed to the app: a banned client stays refused, and each counts toward the
|
|
client's rate limits. None of them reaches the app.
|
|
- 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_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
|
|
requests `smallwebwaf` would refuse and log what it would have done (see "What
|
|
it does so far" above).
|
|
- `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_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
|
line and headers a client may send. Over it, the answer is `431` and nothing
|
|
reaches the app. It must be more than `4K`, and cannot be `off`: Go's HTTP
|
|
server always has such a limit, and reads 4 KiB past the one it is given
|
|
before it refuses.
|
|
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
|
may wait for its next request before `smallwebwaf` closes it. The default 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.
|
|
- `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_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
|
|
country lists and the rate limits, such as your monitoring or your own
|
|
networks.
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
|
|
rate limits do not apply to, such as a machine that talks to the app all day.
|
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
|
- `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.
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes whose requests
|
|
the rate limits neither count nor refuse, such as `/assets/` for static
|
|
assets; each starts with `/`. A request whose path, percent-decoded, contains
|
|
`..` anywhere or a backslash, or whose path as sent holds an encoded slash
|
|
(`%2F` or `%2f`), is never exempt, since the app may act on it as a path
|
|
outside every prefix: `/assets/..%2Flogin` as `/login`. Any other request is
|
|
exempt when its path, percent-decoded and before any query string, starts with
|
|
a prefix, character for character. `/assets/` matches `/assets/app.js` and
|
|
`/assets/`, but not `/assets`, `/Assets/app.js`, `/static/assets/app.js`,
|
|
`/static/../assets/app.js` or `/assets%2Fapp.js`. A prefix is written without
|
|
percent-encoding, and there are no wildcards: `*` is a character like any
|
|
other.
|
|
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
|
for example `cn,ru,kp`.
|
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
|
countries whose clients get through, for example `us,de`. A client whose
|
|
country cannot be found is refused too, so that new clients are not let in
|
|
whenever GeoJS stops answering.
|
|
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
|
|
that is banned, breaks a rate limit, is in `SWWAF_DENY_NETS` or comes from a
|
|
refused country: `403`, `429`, or `close` to close the connection without an
|
|
answer. Behind traefik, `close` does not leave the client unanswered: traefik
|
|
answers `502`, as it does whenever its backend drops a connection.
|
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
|
|
limit.
|
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): a rate limit broken again
|
|
within this time after a ban ended bans for three times as long as that ban.
|
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is
|
|
permanent instead.
|
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans kept, past, active and
|
|
permanent.
|
|
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): the length of the netblock around
|
|
an IPv4 client that a ban covers, such as `24` to ban the surrounding /24. An
|
|
IPv6 ban covers the client's /64.
|
|
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
|
files, an absolute path. A directory `smallwebwaf` cannot write stops the
|
|
start.
|
|
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long after a ban is made
|
|
`bans.json` is written, with every ban made in between.
|
|
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
|
written.
|
|
- `SWWAF_METRICS_TOKEN` (default unset): the token a scraper sends for the
|
|
metrics, a long random value. While it is unset the metrics are off; one
|
|
shorter than 32 characters stops the start. The settings logged at start show
|
|
`********` in its place.
|
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many countries get series of their
|
|
own in the metrics by country; the others are counted as `other`.
|
|
|
|
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. Countries are the two-letter codes
|
|
ISO 3166-1 assigns today, and `xk` for Kosovo, in either case (`de` and `DE` are
|
|
the same); any other code, such as `nk` (North Korea is `kp`) or the withdrawn
|
|
`su`, stops the start, and so does a code on both country lists. `off` switches
|
|
a timeout, a size limit or a rate limit off;
|
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, the ban settings, the state settings
|
|
and `SWWAF_METRICS_TOP_N` cannot be off.
|
|
|
|
Several limits are fixed rather than settings. At most 20,000 clients are kept,
|
|
with their counters and history, and an IPv6 client is counted by its /64. A new
|
|
client waits at most a second for its country, and at most 100,000 answers from
|
|
GeoJS are kept, for 7 days each.
|
|
|
|
## 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","country":"DE","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.
|
|
- `country` is the client's country as GeoJS places it. It is empty with neither
|
|
country list set, for a client in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`, for
|
|
a client on a private, loopback or link-local address, when GeoJS cannot place
|
|
the client or has not answered in time, and for a request whose client a ban
|
|
covers, even when the client's country is known.
|
|
- `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, `denied` for one
|
|
refused because its client is in `SWWAF_DENY_NETS`, `banned` for one refused
|
|
because a ban covers its client, `country_denied` for one refused for its
|
|
client's country, `rate_limited` for one that broke a rate limit and banned
|
|
its client, `too_large` for a request or response over its size limit,
|
|
`timed_out` for one that ran out of time, `upstream_error` when the app could
|
|
not be reached or its answer broke off, and `admin` for one `smallwebwaf`
|
|
answered at its own endpoint.
|
|
- `would_action` is there in `observe` mode for a request that
|
|
`SWWAF_DENY_NETS`, a ban, the country lists or a rate limit would have refused
|
|
in `enforce` mode, and names the action that refusal would have had: `denied`,
|
|
`banned`, `country_denied` or `rate_limited`. `action` then names what was
|
|
done: `forward` for a request passed to the app, and another action, such as
|
|
`too_large`, for one a size or time limit refused.
|
|
- `limit_hit` is there for a request that broke a rate limit, and names the
|
|
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
|
went over several. `offence` is then `limit`.
|
|
- `ban_expires` is there for a request that made a ban or was refused under one,
|
|
or in `observe` mode would have been refused under one, and gives when the ban
|
|
ends, in the same form as `time`, or `permanent`.
|
|
- `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 `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
|
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`.
|
|
|
|
## State files
|
|
|
|
`smallwebwaf` keeps its state in memory and a copy of it in three JSON files in
|
|
`SWWAF_STATE_DIR`, `/var/lib/smallwebwaf` by default, as "Persistent state" in
|
|
[`SPEC.md`](SPEC.md) describes. Each has a top-level `version`, 1, and lists its
|
|
entries by client address, with times in UTC.
|
|
|
|
- `bans.json`: every ban with its notes, indented to be read; a permanent ban's
|
|
`expires` is `null`.
|
|
- `clients.json`: each client's two buckets in the minute, the hour and the day,
|
|
and its history: when it was first and last seen, its country as last looked
|
|
up and when, its requests, how many were forwarded and how many refused (one
|
|
`smallwebwaf` answered at its own endpoints is neither, unless it was refused
|
|
with `401` for a missing or wrong token), the body bytes in each direction,
|
|
its responses by status class and its offences by kind. Each client is on a
|
|
line of its own, so `grep` shows everything about one.
|
|
- `lookups.json`: GeoJS's answers, one to a line, with when GeoJS gave each and
|
|
when it was last used.
|
|
|
|
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, with every
|
|
ban made in between, and every file every `SWWAF_STATE_COUNTER_INTERVAL` and
|
|
when `smallwebwaf` stops. Each write goes to a temporary file in the same
|
|
directory, which then replaces the file, so a crash leaves the old file or the
|
|
new one, whole. A write that fails is logged, and tried again at the next write.
|
|
A hard kill loses what changed since the last write.
|
|
|
|
At start the files are read back: each client keeps its counts, so a restart
|
|
gives it no fresh allowance, and each ban keeps refusing every client in its
|
|
netblock until it ends, even after `SWWAF_BAN_SCOPE_V4_PREFIX` has changed. A
|
|
netblock whose address has bits past its length, such as `203.0.113.9/24`, is
|
|
read as the netblock it is in, `203.0.113.0/24`. Buckets and answers whose time
|
|
has passed are dropped. A missing file is empty state, as on a first start. A
|
|
file that does not parse, or has another `version`, stops the start with a
|
|
message naming the file, and the line and column where Go's JSON decoder gives
|
|
them; so does a state directory `smallwebwaf` cannot write. So does an entry
|
|
without a field it needs, named with the entry's place in the file: a ban's
|
|
`netblock`, `start` or `expires`, which is `null` for a permanent ban; a
|
|
client's `client`, or the `start` of a window in which it has requests; an
|
|
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
|
`answered`. An edit made while `smallwebwaf` runs is overwritten by its next
|
|
write: taking it in comes with https://git.eeqj.de/sneak/smallwebwaf/issues/68.
|
|
The AS number and AS name come with their lookup.
|
|
|
|
## Metrics
|
|
|
|
`GET /_smallwebwaf/metrics` answers with the metrics in the Prometheus text
|
|
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
|
|
other request. No metric carries a client's address.
|
|
|
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
|
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
|
|
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
|
|
`action`, as the request log names it.
|
|
- `smallwebwaf_request_duration_seconds`: how long requests took, and
|
|
`smallwebwaf_upstream_duration_seconds`: how long those passed to the app took
|
|
from then on, as histograms; `smallwebwaf_requests_in_flight`: the requests
|
|
under way.
|
|
- `smallwebwaf_rate_limit_hits_total` by `window`,
|
|
`smallwebwaf_size_and_time_limit_hits_total` by `limit`, the setting whose
|
|
limit was passed, `smallwebwaf_offences_total` by `kind`, and
|
|
`smallwebwaf_bans_made_total` by `cause`; `smallwebwaf_active_bans` and
|
|
`smallwebwaf_permanent_bans`.
|
|
- `smallwebwaf_country_requests_total`,
|
|
`smallwebwaf_country_request_bytes_total`,
|
|
`smallwebwaf_country_response_bytes_total`, and
|
|
`smallwebwaf_country_list_refusals_total`, the requests the country lists
|
|
refused, by `country`, for the requests whose client's country is known. The
|
|
`SWWAF_METRICS_TOP_N` countries with the most requests since the start have
|
|
series of their own, and the others are counted as `other`. A country that
|
|
drops out of them loses its series, and its later requests count as `other`;
|
|
one that comes into them gets a series that counts from then on.
|
|
- `smallwebwaf_geojs_requests_total`: the requests to GeoJS;
|
|
`smallwebwaf_geojs_failures_total`: those that failed, an answer that leaves
|
|
out an address asked about included; and `smallwebwaf_geojs_unanswered_total`:
|
|
the requests whose client counted as coming from an unknown country because
|
|
GeoJS had not answered about it in time.
|
|
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
|
|
- `smallwebwaf_state_file_writes_total`,
|
|
`smallwebwaf_state_file_write_failures_total`,
|
|
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
|
`smallwebwaf_state_file_size_bytes`, by `file`.
|
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
|
|
|
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
|
log") are not counted. The metrics of the features still to come, such as the
|
|
rule files, come with them.
|
|
|
|
## 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. The files
|
|
for the bans, the clients and the GeoJS answers are built (see "State files"
|
|
above); the others come with their features, and taking in an edit while
|
|
running comes with https://git.eeqj.de/sneak/smallwebwaf/issues/68.
|
|
- 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
|
|
|
|
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`, after Ubuntu's own commands. 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. At each start the `run` script of `smallwebwaf`
|
|
gives that directory and every file in it to the `smallwebwaf` user, so a host
|
|
directory mounted there needs no change of owner.
|
|
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
|
|
requests and gives those in progress five seconds to finish.
|
|
|
|
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
|
|
|
|
So far `smallwebwaf` looks up only the country, only through GeoJS, and only
|
|
while `SWWAF_DENIED_COUNTRIES` or `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set:
|
|
then the address of every new visitor is sent to GeoJS, except a visitor in
|
|
`SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS` and one whose netblock a ban covers, and
|
|
with neither set, none is. An IPv6 visitor is asked about by the first address
|
|
of its /64. A new visitor waits at most a second for its answer, and without one
|
|
counts as coming from an unknown country until the answer arrives. The addresses
|
|
waiting are asked about together, up to 200 in one request, one request at a
|
|
time; at most 10,000 visitors wait, and one more counts as coming from an
|
|
unknown country until there is room. While GeoJS fails, visitors with a kept
|
|
answer are unaffected and new ones count as coming from an unknown country.
|
|
GeoJS is then left alone for a second, twice as long after each further failure
|
|
up to five minutes, and asked again by the next request that needs it.
|
|
|
|
In the full design, `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 for seven days, in memory and in `lookups.json`, so that it survives a
|
|
restart, and many addresses are asked about in one request. 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`). `SWWAF_LOOKUP_SOURCE` comes in milestone 3 or later (see
|
|
the build order in [`SPEC.md`](SPEC.md)); until then GeoJS is asked only while a
|
|
country list is set. You download the database 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`, and `SWWAF_DENIED_COUNTRIES` does not refuse it. 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 and the state
|
|
files, listens, serves requests until `SIGTERM` or `SIGINT`, and stops,
|
|
writing the state files. Run as `smallwebwaf healthcheck`, it is the image's
|
|
health check instead.
|
|
- `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 `SWWAF_DENY_NETS`, for
|
|
a ban, for the country lists, for a rate limit, which bans the client, and for
|
|
an announced body over the size limit; in `observe` mode, only for the size
|
|
limit, with what it would have refused for noted in the log line. A request
|
|
under `/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
|
instead of reaching the app.
|
|
- `internal/metrics`: the metrics, counted as the other parts tell it what
|
|
happened, and served in the Prometheus text format.
|
|
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
|
|
long a new ban lasts, and which ban is dropped when `SWWAF_MAX_BANS` are held.
|
|
- `internal/lookup`: looks up each client's country through GeoJS, and keeps the
|
|
answers.
|
|
- `internal/ratelimit`: the table of clients: counts each client's requests,
|
|
tells when one takes it over a rate limit, and keeps each client's history.
|
|
- `internal/state`: reads the state files at start, and writes them when they
|
|
are due and at the stop.
|
|
- `internal/requestlog`: the lines on stdout: the request log line and the
|
|
process's own messages.
|
|
- `Dockerfile`: the lint and test phases, then the image, whose last stage
|
|
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
|
|
`share/smallwebwaf.run` as runit's `run` script for `smallwebwaf`.
|
|
- `deploy/example-app`: an app built on the image, which `script/example-app`
|
|
checks.
|
|
|
|
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
|
|
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
|
|
netblocks to `SWWAF_MAX_BANS`, dropping the least recently seen, and
|
|
`github.com/prometheus/client_golang` keeps the metrics and serves them. The
|
|
country codes are the list in `internal/config/config.go`.
|
|
|
|
## 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`, `curl`, 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, with
|
|
its state files in `bin/state` unless `SWWAF_STATE_DIR` is set; `make run`
|
|
runs it.
|
|
- `script/example-app`: builds the image and, on it, the example app in
|
|
`deploy/example-app`, runs it with a volume for the state files, and checks
|
|
that the health check passes, that a request reaches the app through
|
|
`smallwebwaf`, that a second request in a minute bans the client, that
|
|
`sv stop` and `docker stop` stop it in order, and that a new container on the
|
|
same volume still refuses the banned client; then removes the containers, the
|
|
volume and both images. It needs network access, for nixpkgs' binary cache,
|
|
and `script/check` does not run it; `make example-app` does.
|
|
|
|
## TODO
|
|
|
|
- The rest of milestone 3: taking in an admin's edits to the state files
|
|
(https://git.eeqj.de/sneak/smallwebwaf/issues/68) and the rest of the request
|
|
log's fields; then 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.
|
|
|
|
## License
|
|
|
|
MIT. See [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|