check / check (push) Waiting to run
Process log lines carry instance, as request lines do; the instance name is read before the other settings, so the line saying a setting is invalid carries it too. Every metric, Go's and the process's included, carries the label instance, set once on the registry. README.md says so, and that Prometheus keeps it as exported_instance unless the scrape sets honor_labels. An instance name that is not valid UTF-8 stops the start, as the metrics library panics on such a label. Tests that read metrics expect the label; one helper replaces the alert tests' loops that wait for them. Judgement call: the label is named instance, as in the log lines and alerts, although Prometheus gives each target a label of that name. Model: opus-5-5
1415 lines
83 KiB
Markdown
1415 lines
83 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 nine parts of
|
|
milestone 3: the static lists, the bans that broken rate limits lead to, the ban
|
|
ledger with the bans you make, keep and lift, the JSON state files with your
|
|
edits taken in while it runs and the paths the rate limits do not count, which
|
|
come next in the build order, `observe` mode and the rest of the request log's
|
|
fields, which come a little later, and the metrics endpoint and the header size
|
|
and the idle time as settings, which come last in it. So are the four parts of
|
|
the stage after it: the rule files, with the bans for a clear sign of attack,
|
|
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
|
Slack and ntfy, and remote log sending. `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, checks each request against the rule files and bans a client whose
|
|
request is a clear sign of attack, keeps its bans, each client's counters and
|
|
history, and GeoJS's answers in JSON files across restarts, takes in your edits
|
|
of those files, such as a ban you make, keep or lift, and of the rule files
|
|
while it runs, writes a JSON log line for every request, sends its log lines to
|
|
a syslog server too if you name one, sends an alert to a webhook, to Slack and
|
|
to ntfy, each if you name one, for each ban it makes or makes permanent, for
|
|
GeoJS failing and for a rule file or state file with an error, serves Prometheus
|
|
metrics to a scraper that holds the metrics token, lets an admin who holds the
|
|
admin token list, add and lift bans and ask what it knows of a client, 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, and the default rule file of
|
|
`share/rules.d` unless `SWWAF_RULES_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. It
|
|
also gets the request's id in `X-Request-ID`, the same id as in the request's
|
|
log line (see `request_id` in "Request log" below).
|
|
- 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, the country
|
|
lists and the rule files 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, for a broken limit, for a clear sign of attack and by an admin. At
|
|
most `SWWAF_MAX_BANS` bans `smallwebwaf` made are kept, past, active and
|
|
permanent; past that, the earliest such ban of the netblock that has gone
|
|
longest without a request is dropped first. The bans whose cause is `admin`,
|
|
those you make or keep, are kept besides, and never dropped. `bans.json` shows
|
|
the bans and their notes, a restart lifts none, and you make, keep or lift a
|
|
ban by editing it (see "State files" below).
|
|
- Checks each request against the rules of the rule files (see "Rule files"
|
|
below) after the rate limits, and before its body is read. A `log` rule that
|
|
matches is noted in the log line; a `block` rule refuses the request with
|
|
`403`, and bans no one; a `ban` rule refuses it with `SWWAF_BAN_RESPONSE` and
|
|
bans the client's netblock for a clear sign of attack. Matching stops at the
|
|
first rule that refuses. A client in `SWWAF_ALLOW_NETS` is not checked.
|
|
- Bans a client for a clear sign of attack, as "Bans" in [`SPEC.md`](SPEC.md)
|
|
describes: the first such ban lasts `SWWAF_ATTACK_BAN_DURATION`, seven days by
|
|
default, and any request from the netblock while it lasts makes it permanent.
|
|
Once it has run out, the netblock is served like any other, but its next clear
|
|
sign of attack bans it permanently at once. Such a ban covers the same
|
|
netblock as a ban for a broken rate limit, does not set the client's counters
|
|
back to zero, and does not make the netblock's next ban for a broken limit
|
|
longer. Its notes give the id and the target of the rule that matched in place
|
|
of the limit.
|
|
- 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, the rate limits and the rule
|
|
files, 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, the rule files 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, a rate limit or a rule 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, with three differences:
|
|
neither a broken rate limit nor a `ban` rule makes a ban; a broken rate limit
|
|
does not set the client's counters back to zero, so each request over the
|
|
limit is logged as one that would be refused; and a request under a ban does
|
|
not make it permanent. A ban it would have made, or made permanent, raises the
|
|
alert `enforce` mode would have raised, marked as what would have happened
|
|
(see "Alerts" below). 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 one of `smallwebwaf`'s own endpoints
|
|
without its 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 request under
|
|
`/_smallwebwaf/` that is not for one of its endpoints. 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.
|
|
- Lets an admin list, add and lift bans, and ask what it knows of a client,
|
|
through the endpoints `SWWAF_ADMIN_TOKEN` opens, which go through the checks
|
|
as the metrics do (see "Admin endpoints" below).
|
|
- Writes a line in the request log for each request (see "Request log" below).
|
|
- Sends every line it writes on stdout to a syslog server as well, while
|
|
`SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server"
|
|
below).
|
|
- Sends an alert for each ban it makes or makes permanent, for GeoJS failing,
|
|
and for a rule file or state file with an error, holding back repeats and,
|
|
past an hourly limit, rolling the rest into one summary, to each destination
|
|
you name: as a JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as
|
|
a message to the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names,
|
|
and as a message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts"
|
|
below).
|
|
|
|
## Settings
|
|
|
|
Each setting is an environment variable, or a file one names (see "Settings
|
|
given as files" below), 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_INSTANCE_NAME` (default: the host's name, which docker sets to the
|
|
first 12 characters of the container's id unless the deployment names one):
|
|
the name every log line and alert gives as `instance`, and every metric
|
|
carries as its label `instance` (see "Metrics" below). Set it, for example to
|
|
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
|
|
container, and that tells instances apart when several log to one place. A
|
|
name that is not valid UTF-8, such as one saved in Latin-1, stops the start.
|
|
- `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, the rate limits and the rule files, 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 as sent, the path the app receives, before any query
|
|
string and not percent-decoded, starts with a prefix, character for character.
|
|
`/assets/` matches `/assets/app.js` and `/assets/`, but not `/assets`,
|
|
`/Assets/app.js`, `/%61ssets/app.js`, `/static/assets/app.js`,
|
|
`/static/../assets/app.js` or `/assets%2Fapp.js`. A character the client sends
|
|
percent-encoded, such as a space, is written percent-encoded in a prefix, as
|
|
in `/my%20files/`, 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, matches a `ban` rule, 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. A `block` rule always answers `403`.
|
|
- `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, other than one for a clear sign of attack,
|
|
bans for three times as long as that ban.
|
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban for a broken rate limit that
|
|
would be longer is permanent instead.
|
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
|
attack.
|
|
- `SWWAF_MAX_BANS` (default `5000`): the most bans `smallwebwaf` made that are
|
|
kept, past, active and permanent. The bans you make or keep are kept besides.
|
|
- `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_LOG_REQUEST_HEADERS` (default
|
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
|
request headers whose values the request log gives, in either case.
|
|
`Authorization`, `Cookie` and `Set-Cookie` are never logged, even when listed
|
|
(see "Request log" below). An entry naming `Host` or `Transfer-Encoding` stops
|
|
the start, since Go's HTTP server takes both out of the request; the request's
|
|
host is the field `host`.
|
|
- `SWWAF_ADMIN_TOKEN` (default unset): the token an admin sends for the ban
|
|
endpoints and `/_smallwebwaf/clients/<ip>` (see "Admin endpoints" below), a
|
|
long random value. While it is unset they are off; one shorter than 32
|
|
characters stops the start. The settings logged at start show `********` in
|
|
its place. Given as a file, with `SWWAF_ADMIN_TOKEN_FILE`, it can be kept out
|
|
of the app's reach (see "Settings given as files" below).
|
|
- `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. Given as a file, it can be kept out of the app's
|
|
reach (see "Settings given as files" below).
|
|
- `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`.
|
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
|
rule files. A directory that does not exist stops the start.
|
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file, and checks
|
|
no request against one.
|
|
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that every line on
|
|
stdout is also sent to, as `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`
|
|
with a host and a port, such as `syslog+tls://logs.example:6514`. Unset or
|
|
empty, nothing is sent.
|
|
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates,
|
|
which the certificate of a `syslog+tls` server must chain to instead of the
|
|
host's own. A file that cannot be read or holds no certificate stops the
|
|
start.
|
|
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
|
wait to be sent.
|
|
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility the lines
|
|
are sent with: `kern`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`,
|
|
`news`, `uucp`, `cron`, `authpriv`, `ftp`, or `local0` to `local7`.
|
|
- `SWWAF_LOG_REMOTE_APP_NAME` (default `SWWAF_INSTANCE_NAME`): the app name the
|
|
lines are sent with, 1 to 48 printable ASCII characters without a space. While
|
|
`SWWAF_LOG_REMOTE_URL` is set, an `SWWAF_INSTANCE_NAME` that is not such a
|
|
name stops the start too, unless this setting gives one that is.
|
|
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook each alert is posted
|
|
to, an `http` or `https` URL without a user or a fragment, such as
|
|
`https://alerts.example/smallwebwaf` (see "Alerts" below). Unset or empty, no
|
|
alert is sent to a webhook. Since many webhooks carry their secret in the path
|
|
or the query, the settings logged at start show `********` in place of them,
|
|
and a value that stops the start is not shown.
|
|
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each alert,
|
|
such as one that authenticates it, as a list of a name, `:` and a value, such
|
|
as `Authorization:Bearer 0123456789abcdef`. A value cannot hold a comma. The
|
|
settings logged at start show `********` in place of each value.
|
|
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
|
each alert is posted to as a message, such as
|
|
`https://hooks.slack.com/services/T0123/B4567/abcdef`. Unset or empty, no
|
|
alert is sent to Slack. It is checked and logged as `SWWAF_ALERT_WEBHOOK_URL`
|
|
is.
|
|
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic each alert is published
|
|
to, as the topic's full URL, such as `https://ntfy.sh/my-alerts`. Unset or
|
|
empty, no alert is sent to ntfy. It is checked and logged as
|
|
`SWWAF_ALERT_WEBHOOK_URL` is, since anyone who knows a topic on a server open
|
|
to all can read it.
|
|
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent to ntfy
|
|
with each alert as `Authorization: Bearer <token>`, for a topic that needs
|
|
one. The settings logged at start show `********` in its place. A control
|
|
character in it, such as the carriage return of a file saved with Windows line
|
|
ends, stops the start, and while `SWWAF_ALERT_NTFY_URL` is set, so does one in
|
|
`SWWAF_INSTANCE_NAME`, which ntfy is sent in the title.
|
|
- `SWWAF_ALERT_EVENTS` (default
|
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
|
the events alerts are sent for. `waf_block`, `anomaly` and `reputation_hit`
|
|
come with the features that raise them; nothing raises them yet.
|
|
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long a repeat of an alert is held
|
|
back (see "Alerts" below).
|
|
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour;
|
|
the rest of the hour's alerts are rolled into one summary.
|
|
|
|
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, a rate limit, `SWWAF_ALERT_COOLDOWN` or
|
|
`SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, the ban
|
|
settings, the state settings, `SWWAF_METRICS_TOP_N` and
|
|
`SWWAF_LOG_REMOTE_BUFFER` 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.
|
|
|
|
### Settings given as files
|
|
|
|
Any setting may instead be given as a file that holds its value: the variable
|
|
named like the setting with `_FILE` added, such as `SWWAF_METRICS_TOKEN_FILE`,
|
|
names the file. `smallwebwaf` reads the file once, at start, and its health
|
|
check reads only the files of `SWWAF_LISTEN_ADDR` and `SWWAF_UPSTREAM_URL`, each
|
|
time it runs. The file's contents are the value, less one newline at their end
|
|
so that a file written with `echo` or an editor works, and are checked as the
|
|
setting's own value would be. Setting both the setting and its `_FILE` form, or
|
|
naming a file that cannot be read, stops the start with a message naming the
|
|
variable. The settings logged at start name the file, and show a token given in
|
|
one as `********`, as they show one given directly.
|
|
`SWWAF_LOG_REMOTE_TLS_CA_FILE`, whose value names a file already, has no `_FILE`
|
|
form.
|
|
|
|
The app starts with the same environment variables as `smallwebwaf`, so it can
|
|
read a token given as one. A token given as a file is out of the app's reach
|
|
only while the `smallwebwaf` user alone can read the file: make it on the host,
|
|
owned by uid 65532, the `smallwebwaf` user, with mode `0400`, and mount the
|
|
directory that holds it into the container read-only; the container sees the
|
|
same owner and mode. For example, on the host:
|
|
|
|
```sh
|
|
mkdir -p /srv/app/tokens
|
|
openssl rand -hex 32 > /srv/app/tokens/metrics
|
|
chown 65532:65532 /srv/app/tokens/metrics
|
|
chmod 0400 /srv/app/tokens/metrics
|
|
```
|
|
|
|
and for the container, `-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro` and
|
|
`-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics`.
|
|
|
|
## 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","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
|
```
|
|
|
|
A field that does not apply to a request is left out of its line, apart from
|
|
`type`, the fields from `time` to `user_agent`, `request_id`, `peer_ip`,
|
|
`client_group`, `country`, `action` and `duration_total`, which every line has.
|
|
|
|
- `time` is when the request arrived, in UTC. `instance` is
|
|
`SWWAF_INSTANCE_NAME`. `scheme` is the `X-Forwarded-Proto` a trusted proxy
|
|
sent, and otherwise `http`. `path` and `query` are as the client sent them.
|
|
- `request_id` is the `X-Request-ID` a trusted proxy sent, or a new random one
|
|
of 26 letters and digits when it sent none, or when the peer is not a trusted
|
|
proxy. A request passed to the app takes it there in `X-Request-ID`.
|
|
- `peer_ip` is the TCP peer, normally traefik. `forwarded_for` is the
|
|
`X-Forwarded-For` header as received, several lines of it joined with `, `.
|
|
`client_group` is the client as the rate limits count it: its IPv4 address as
|
|
a /32, or the /64 of its IPv6 address.
|
|
- `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.
|
|
- `content_type` is the request's `Content-Type`, and `content_length` the
|
|
length the request announced for its body, which is left out for none or zero.
|
|
- `request_headers` are the request's headers that `SWWAF_LOG_REQUEST_HEADERS`
|
|
names, by name in lower case, several lines of one joined with `, `.
|
|
`Authorization`, `Cookie` and `Set-Cookie` are never among them, whatever the
|
|
setting says: `has_authorization` and `has_cookie` are there instead, and
|
|
true, when the request has an `Authorization` or a `Cookie` header.
|
|
- `websocket` is there, and true, when the app switched the connection to
|
|
another protocol, as it does for a WebSocket.
|
|
- `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.
|
|
- `response_content_type`, `cache_control` and `location` are the
|
|
`Content-Type`, `Cache-Control` and `Location` headers of the answer: the
|
|
app's, as passed on, or those of `smallwebwaf`'s own 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 or because it matched a `ban` rule, which bans
|
|
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,
|
|
`rule_blocked` for one a `block` rule refused, `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, a rate limit or a rule would have
|
|
refused in `enforce` mode, and names the action that refusal would have had:
|
|
`denied`, `banned`, `country_denied`, `rate_limited` or `rule_blocked`.
|
|
`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.
|
|
- `counts` gives the client's requests in the minute, the hour and the day as
|
|
the rate limits count them, this request included: in each window, those in
|
|
the bucket under way and a share of those in the bucket before, so a count can
|
|
have a fraction. For a request that broke a limit, they are the counts that
|
|
broke it. It is left out for a request the rate limits do not count: the
|
|
health check, one from a client in `SWWAF_ALLOW_NETS` or
|
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
|
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a ban
|
|
or the country lists refuse, or would refuse in `observe` mode. The byte
|
|
totals come with the byte limits.
|
|
- `rule_ids` is there for a request that matched rules of the rule files, and
|
|
lists their ids in the order they matched, up to the one that refused it.
|
|
- `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.
|
|
- The timings are in milliseconds, to the microsecond. `duration_total` runs
|
|
from when the request's headers had been read to when its line is written, and
|
|
`duration_checks` over the same start to when the checks were done; the health
|
|
check runs none, and its line has no `duration_checks`.
|
|
`duration_upstream_connect`, `duration_upstream_first_byte` and
|
|
`duration_upstream_total` are there for a request passed to the app, and run
|
|
from when it was handed to the app: until there was a connection to it, new or
|
|
kept open from an earlier request, until the first byte of its answer arrived,
|
|
and until the end. The first two are left out when that never happened, as for
|
|
an app that cannot be reached.
|
|
|
|
No body is logged, and no header but those above. `smallwebwaf`'s own messages
|
|
(start, the settings, stop, errors) share the stream as JSON lines marked
|
|
`"type":"process"`, each with `instance` as a request's line has it.
|
|
|
|
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`.
|
|
|
|
### Sending the log to a syslog server
|
|
|
|
While `SWWAF_LOG_REMOTE_URL` is set, every line `smallwebwaf` writes on stdout,
|
|
request lines and its own, is also sent to that syslog server, as the message of
|
|
an RFC 5424 record: one record to a datagram over UDP, and over TCP and TLS each
|
|
record after its length in bytes and a space. A record gives the facility
|
|
`SWWAF_LOG_REMOTE_FACILITY` names, the severity informational, the time the line
|
|
was written, in the same form as a request line's `time`, the host's name, and
|
|
the app name `SWWAF_LOG_REMOTE_APP_NAME` gives. stdout is unchanged.
|
|
|
|
The lines wait in a buffer of `SWWAF_LOG_REMOTE_BUFFER` lines and are sent from
|
|
there, so a server that is slow or cannot be reached never holds up a request or
|
|
stdout. When the buffer is full, its oldest line is dropped to make room. A line
|
|
whose sending fails is dropped too, and the connection closed. That failure,
|
|
like a failed attempt to connect, is logged and followed by the next attempt to
|
|
connect a second later, twice as long after each further failure up to a minute,
|
|
and a second again after a connection that stayed up for a minute before it
|
|
failed. A line too long for one UDP datagram is dropped alone, with no wait and
|
|
nothing logged. UDP gives no sign of what arrives, and over TCP and TLS a line
|
|
sent on a connection the server has just closed can be lost before a failure
|
|
shows; such a loss is not counted.
|
|
|
|
As `smallwebwaf` stops, it sends the lines still waiting, on the connection open
|
|
or a new one, for at most two seconds, and gives up the rest; stdout has carried
|
|
them.
|
|
|
|
## Alerts
|
|
|
|
`smallwebwaf` sends each alert to each destination you name: to
|
|
`SWWAF_ALERT_WEBHOOK_URL` it posts the alert as one JSON object, with
|
|
`Content-Type: application/json` and the headers `SWWAF_ALERT_WEBHOOK_HEADERS`
|
|
gives, as "Alert webhook schema" in [`SPEC.md`](SPEC.md) describes, and to
|
|
`SWWAF_ALERT_SLACK_WEBHOOK_URL` and `SWWAF_ALERT_NTFY_URL` a message made from
|
|
it, as below. An alert is for one of these events, and is sent when
|
|
`SWWAF_ALERT_EVENTS` names its event:
|
|
|
|
- `ban`: a ban `smallwebwaf` makes, for a broken rate limit or a clear sign of
|
|
attack.
|
|
- `permanent_ban`: a permanent ban it makes, or a ban for a clear sign of attack
|
|
that a request made permanent.
|
|
- `source_failure`: GeoJS failing or refusing `smallwebwaf`.
|
|
- `file_error`: a rule file edited while it runs that has an error, an edit of a
|
|
state file set aside as `<name>.bad`, or a state file it could not write while
|
|
running.
|
|
|
|
The bans you make, in `bans.json` or through the ban endpoints, raise no alert.
|
|
In `observe` mode, a request that would have made a ban, or made one permanent,
|
|
raises the alert `enforce` mode would have raised, for the ban as it would have
|
|
been, with `mode`, `observe`, in its `detail`: no ban was made, or made
|
|
permanent. A request that would have made a ban whose alert would be held back,
|
|
by the cooldown or past `SWWAF_ALERT_MAX_PER_HOUR`, raises none, and is not
|
|
counted. This is the alert for a ban for a broken rate limit, shown indented; it
|
|
is sent on one line:
|
|
|
|
```json
|
|
{
|
|
"instance": "fsn1app1/gitea",
|
|
"time": "2026-10-06T12:00:00.123461Z",
|
|
"event": "ban",
|
|
"client": "203.0.113.9",
|
|
"netblock": "203.0.113.9/32",
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"reason": "requests per minute over the limit of 1000",
|
|
"detail": {
|
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
|
"cause": "limit",
|
|
"notes": {
|
|
"country": "",
|
|
"limit": 1000,
|
|
"window": "minute",
|
|
"count": 1001,
|
|
"request": {
|
|
"time": "2026-10-06T12:00:00.123456789Z",
|
|
"method": "GET",
|
|
"host": "app.example",
|
|
"path": "/owner/repo/commits/branch/main?page=812",
|
|
"status": 403,
|
|
"user_agent": "scraper/1.0"
|
|
},
|
|
"requests": 5210,
|
|
"refused": 0,
|
|
"earlier_bans": {
|
|
"limit": 0,
|
|
"attack": 0,
|
|
"admin": 0
|
|
}
|
|
}
|
|
},
|
|
"suppressed_repeats": 0
|
|
}
|
|
```
|
|
|
|
- `instance` is `SWWAF_INSTANCE_NAME`, and `time` when the alert was raised, in
|
|
UTC.
|
|
- `client` is the address of the client whose request raised the alert, and
|
|
`netblock` the netblock of the ban; both are empty for `source_failure` and
|
|
`file_error`. `asn` and `as_name` are empty until AS numbers are looked up,
|
|
and `country` is, for a ban, the client's country as the ban's notes give it.
|
|
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`.
|
|
- `detail` is what is particular to the event: for a ban, its `cause`, when it
|
|
ends as `ban_expires`, in the form the request log gives it, and its `notes`,
|
|
as `bans.json` gives them; for `source_failure`, the `source`, `geojs`, the
|
|
`error`, and when GeoJS is asked again, `asking_again_in`; for `file_error`,
|
|
the `file`, which for an edit set aside is the file it was renamed to, and the
|
|
`error`, which for a file that does not parse names where in it the error is.
|
|
- `suppressed_repeats` is how many repeats the cooldown held back before this
|
|
alert.
|
|
|
|
Slack and ntfy are each sent the alert as a message: a title, the instance and
|
|
the event, such as `fsn1app1/gitea: ban`, and a text, the `reason`, then a line
|
|
for each of the `client`, the `netblock` and the `country`, the `file`, the
|
|
`source`, the `error` and the `mode` the `detail` gives, and the
|
|
`suppressed_repeats`, leaving out those that are empty or 0. Slack is posted, as
|
|
JSON, the title in bold and the text below it, with `&`, `<` and `>` escaped, so
|
|
that nothing in them is read as a link or a mention. ntfy is posted the text,
|
|
with the title as `Title`, `SWWAF_ALERT_NTFY_TOKEN`, while it is set, as
|
|
`Authorization: Bearer <token>`, and the priority and the tag, which ntfy shows
|
|
as an emoji, of the alert's event, as `Priority` and `Tags`:
|
|
|
|
| Event | Priority | Tag |
|
|
| ------------------------------ | --------- | -------------------------- |
|
|
| `ban` | `default` | `no_entry` |
|
|
| `permanent_ban` | `high` | `no_entry` |
|
|
| `waf_block` | `default` | `shield` |
|
|
| `anomaly` | `high` | `chart_with_upwards_trend` |
|
|
| `reputation_hit` | `low` | `label` |
|
|
| `source_failure`, `file_error` | `high` | `warning` |
|
|
| `summary` | `default` | `bar_chart` |
|
|
|
|
For the alert above, ntfy is sent these headers and this text:
|
|
|
|
```text
|
|
Title: fsn1app1/gitea: ban
|
|
Priority: default
|
|
Tags: no_entry
|
|
|
|
requests per minute over the limit of 1000
|
|
client: 203.0.113.9
|
|
netblock: 203.0.113.9/32
|
|
```
|
|
|
|
and Slack this JSON object, shown indented; it is sent on one line:
|
|
|
|
```json
|
|
{
|
|
"text": "*fsn1app1/gitea: ban*\nrequests per minute over the limit of 1000\nclient: 203.0.113.9\nnetblock: 203.0.113.9/32"
|
|
}
|
|
```
|
|
|
|
An alert for the same event as the last one sent, on the same netblock, or for a
|
|
`file_error` about the same file, or for a `source_failure` about the same
|
|
source, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it is held back
|
|
and counted, and the next alert sent for them gives that count as
|
|
`suppressed_repeats`.
|
|
|
|
Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour of the clock, in UTC, the
|
|
hour's other alerts are held back and counted by event. Once the hour has ended,
|
|
one alert sums them up: its `event` is `summary`, its `reason` says how many
|
|
were held back, and its `detail` gives the `hour` as when it started, the
|
|
`count`, and the count for each event, as `events`. An alert held back this way
|
|
starts no cooldown, and the repeats held back before it are given by the next
|
|
alert sent for the same event and netblock, file or source.
|
|
|
|
Each destination has a queue of its own, of at most 1000 alerts, from which they
|
|
are sent to it one at a time, the oldest first, so a destination that is slow or
|
|
down holds up neither the others nor any request. A destination takes an alert
|
|
by answering with a 2xx status, and refuses it with a 4xx status other than
|
|
`408` and `429`: a refused alert is logged, counted as dropped, and given up, so
|
|
that the next is sent. Any other answer, a redirect included, a connection that
|
|
fails, or no answer within 10 seconds is a failure: it is logged, naming the
|
|
destination's setting and not its URL, and the alert is sent again a second
|
|
later, twice as long after each further failure in a row, up to a minute. With
|
|
1000 alerts waiting for a destination, the oldest is dropped to make room for a
|
|
new one. The cooldowns, the hour under way and the alerts still waiting for each
|
|
destination are kept in `alerts.json` (see "State files" below), so that after a
|
|
restart the alerts waiting are sent, and the cooldowns go on.
|
|
|
|
## State files
|
|
|
|
`smallwebwaf` keeps its state in memory and a copy of it in four 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, but for the alerts waiting, with times in UTC.
|
|
|
|
- `bans.json`: every ban with its notes, indented to be read. A permanent ban's
|
|
`expires` is `null`. A ban's `cause` is `limit` for a broken rate limit or
|
|
`attack` for a clear sign of attack, for a ban `smallwebwaf` made, and `admin`
|
|
for one you made or keep. Its `reason` is a short text: for a ban
|
|
`smallwebwaf` made, the limit broken, such as
|
|
`requests per minute over the limit of 1000`, or the rule that matched, such
|
|
as `matched the rule env-file`; for yours, what you wrote. Its `lifted` is
|
|
when you lifted it, and is left out until you do.
|
|
- `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.
|
|
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
|
|
read: under `cooldowns`, for each event and netblock, or event and `file` or
|
|
`source`, or event alone, when the last alert was sent, `sent`, and the
|
|
repeats held back since, `suppressed_repeats`; under `hour`, the hour under
|
|
way, from its `start`, the alerts `sent` in it and those `held_back` for its
|
|
summary, by event; and under `waiting`, for each destination you name,
|
|
`webhook`, `slack` or `ntfy`, the alerts still waiting to be sent to it, the
|
|
oldest first, each as the webhook is sent it. As an hour ends, the cooldowns
|
|
that have run out with no repeat held back are dropped. As the file is read,
|
|
the alerts waiting for a destination you no longer name are dropped. A file
|
|
whose `waiting` is a list, as it was before alerts went to Slack and ntfy too,
|
|
stops the start: put the list under `"webhook"`, or remove the file.
|
|
|
|
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
|
|
through `DELETE /_smallwebwaf/bans/<client>`, or made permanent, with every such
|
|
change 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, raised as a `file_error` alert while
|
|
`smallwebwaf` runs, 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`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
|
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
|
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. The
|
|
AS number and AS name come with their lookup.
|
|
|
|
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
|
a state file as soon as you save it: what the file then holds replaces what
|
|
`smallwebwaf` held for it, as if read at start. It tells its own writes from
|
|
yours by comparing the file with what it last read or wrote, and before it
|
|
writes a file it takes in any edit made since, so your edit is not overwritten;
|
|
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
|
lost when you save over it. An edit that would stop the start, because it does
|
|
not parse, has another `version`, leaves out a field an entry needs, gives a ban
|
|
another `cause` or names another destination, does not stop the running
|
|
`smallwebwaf`: it keeps what it holds, and at the file's next write renames your
|
|
file to `<name>.bad`, such as `bans.json.bad`, writes the file again from
|
|
memory, logs the file and where the error is, and raises a `file_error` alert
|
|
for it. It waits for that write because an editor's file can be read before the
|
|
editor has finished writing it. Mend the `.bad` file and move it back. A file
|
|
you remove is written again at its next write.
|
|
|
|
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
|
|
and its `expires`, `null` for a ban that never ends; its `reason` and its
|
|
`notes` may be left out, and so may its `cause`, which is then `admin`, and is
|
|
written so at the file's next write. A ban whose `cause` is `admin` is never
|
|
dropped and does not count toward `SWWAF_MAX_BANS`. A ban whose `cause` is
|
|
`attack` becomes permanent at the first request it refuses; one whose `cause` is
|
|
`admin` does not. This `bans.json` bans `203.0.113.0/24` for good:
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"bans": [
|
|
{
|
|
"netblock": "203.0.113.0/24",
|
|
"start": "2026-10-06T12:00:00Z",
|
|
"expires": null,
|
|
"reason": "probes for logins"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
To keep a ban `smallwebwaf` made, so that it is never dropped, set its `cause`
|
|
to `admin`: `"cause": "admin"`.
|
|
|
|
To lift a ban, add `lifted` to its entry, with the time you lift it, such as
|
|
`"lifted": "2026-10-06T13:00:00Z"`. From when the edit is taken in, the ban
|
|
refuses nothing, whatever time `lifted` gives, and does not make the netblock's
|
|
next ban longer; it is kept in `bans.json` with its notes, as any other ban is.
|
|
To forget a ban altogether, delete its entry: it then refuses nothing either,
|
|
and does not make the netblock's next ban longer.
|
|
|
|
The ban endpoints add and lift bans without an edit of the file (see "Admin
|
|
endpoints" below).
|
|
|
|
## Rule files
|
|
|
|
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR`,
|
|
`/etc/smallwebwaf/rules.d` by default, in the order of their names, and checks
|
|
each request against their rules in that order, as "Rule files" in
|
|
[`SPEC.md`](SPEC.md) describes. A file whose name starts with `.`, such as an
|
|
editor's lock file `.#50-app.rules`, is not a rule file, as a shell's `*.rules`
|
|
would not match it. A rule is a line of four fields separated by spaces or tabs:
|
|
an id, a target, an action and a regex, which runs to the end of the line.
|
|
Spaces and tabs at the end of a line are not part of its regex, so a line with
|
|
only those after its action has no regex, and is not a rule. Blank lines and
|
|
lines that start with `#` are ignored.
|
|
|
|
```
|
|
# id target action regex
|
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|
```
|
|
|
|
- The id is letters, digits, `-` and `_`, and no two rules share one. The
|
|
request log, the metrics and a ban's notes name the rule by it.
|
|
- The target is what the regex is matched against: `path` or `query`, as the
|
|
client sent it, before any decoding; `uri`, the path and the query together,
|
|
both as sent and once percent-decoded, so that an encoded probe does not slip
|
|
past; `method`; `host`; `user_agent`; `referer`; or `header:<Name>`, any one
|
|
request header but `Host` and `Transfer-Encoding`, which Go's HTTP server
|
|
takes out of every request; the request's host is the target `host`. A header
|
|
sent more than once is matched with its values joined by `, `, and one not
|
|
sent as empty text. No body is read.
|
|
- The action is `log`, `block` or `ban` (see "What it does so far" above). Keep
|
|
`ban` for requests no real visitor sends, and anchor a path at the site root
|
|
with `^/`: a file of the same name deeper in a site can be ordinary content,
|
|
such as a file in a repository on a code forge.
|
|
- The regex is in Go's syntax, RE2, which has no backreferences or lookaround,
|
|
and takes time linear in the text it reads. It matches anywhere in the target
|
|
unless anchored with `^` and `$`; `(?i)` at its front makes it ignore case.
|
|
|
|
A line that is not a rule, a `header:` name with a character no header name can
|
|
have, such as `header:User-Agent:`, a rule for the `Host` or the
|
|
`Transfer-Encoding` header, a regex that does not compile or an id used twice
|
|
stops the start with a message naming the file and the line, and so does a
|
|
`SWWAF_RULES_DIR` that does not exist. An empty directory is no error, and the
|
|
log says that it holds no rules. While it runs, `smallwebwaf` watches the
|
|
directory, and reads the rule files again once the directory has had no change
|
|
for 2 seconds after one is edited, added or removed, so that a file saved in
|
|
place, appended to or copied in with `scp` is read only once whole, unless its
|
|
writing stops for longer. It also reads them 2 seconds after it starts watching,
|
|
so that an edit saved while it started is not missed. If they then hold one of
|
|
those errors, the rules stay as they were, the earlier version of the edited
|
|
file included, the log and a `file_error` alert name the file and the line, and
|
|
the files are read again after the next change.
|
|
|
|
The image ships one rule file, `share/rules.d/00-default.rules` here: rules that
|
|
ban probes no real visitor sends, for secrets, version control directories,
|
|
backups, logs and web shells at the site root, and the user agents of common
|
|
scanners; one that blocks `../` twice in a row in the path or the query; and one
|
|
that only notes a request without a user agent. An app's Dockerfile adds rules
|
|
of its own in a file beside it, named to sort after it, such as this
|
|
`50-gitea.rules` for an app that serves no WordPress:
|
|
|
|
```
|
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
|
```
|
|
|
|
```dockerfile
|
|
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
|
```
|
|
|
|
A directory mounted over `/etc/smallwebwaf/rules.d` replaces the default file,
|
|
and single files mounted into it add to it. Docker does not show a single
|
|
mounted file being replaced, which is how many editors save, so rules to be
|
|
edited while `smallwebwaf` runs belong in a mounted directory, with a copy of
|
|
`00-default.rules` if its rules are to stay. To run without rules, mount an
|
|
empty directory or set `SWWAF_RULES_ENABLED=false`.
|
|
|
|
## 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.
|
|
|
|
Every metric below, Go's and the process's included, carries the label
|
|
`instance`, `SWWAF_INSTANCE_NAME`, as a constant label set once on the registry
|
|
the metrics are kept in, rather than as a label each metric declares. Prometheus
|
|
gives each series it scrapes an `instance` label of its own, the address it
|
|
scraped, and keeps this one as `exported_instance` unless the scrape sets
|
|
`honor_labels: true`.
|
|
|
|
- `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`, `limit`, `attack` or `admin`, the
|
|
last for the bans you add through `POST /_smallwebwaf/bans`, and those whose
|
|
`cause` is `admin` that you add to `bans.json` while `smallwebwaf` runs;
|
|
`smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`, neither of which
|
|
counts a lifted ban.
|
|
- `smallwebwaf_rule_matches_total`: the requests that matched each rule, by
|
|
`rule_id` and `action`, the rule's own; and `smallwebwaf_rules_loaded`: the
|
|
rules read from the rule files.
|
|
- `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`; and, by `file` too,
|
|
`smallwebwaf_state_file_edits_taken_in_total`: your edits taken in, and
|
|
`smallwebwaf_state_file_edits_set_aside_total`: those renamed to `<name>.bad`
|
|
because they would stop the start.
|
|
- While `SWWAF_LOG_REMOTE_URL` is set,
|
|
`smallwebwaf_remote_log_lines_sent_total`: the lines sent to it;
|
|
`smallwebwaf_remote_log_lines_dropped_total`: those dropped, from a full
|
|
buffer or because their sending failed; and
|
|
`smallwebwaf_remote_log_buffer_depth`: those waiting in the buffer.
|
|
- For each destination you name, by `destination`, `webhook`, `slack` or `ntfy`:
|
|
`smallwebwaf_alerts_sent_total`: the alerts it took;
|
|
`smallwebwaf_alerts_failed_total`: the requests to it that failed;
|
|
`smallwebwaf_alerts_suppressed_total`: the alerts held back, as repeats or for
|
|
an hour's summary, which are the same for every destination; and
|
|
`smallwebwaf_alerts_dropped_total`: those dropped from its full queue, or
|
|
given up as it refused them.
|
|
- 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
|
|
Core Rule Set, come with them.
|
|
|
|
## Admin endpoints
|
|
|
|
While `SWWAF_ADMIN_TOKEN` is set, `smallwebwaf` answers these requests itself,
|
|
on the app's own address and through traefik like any other request, for a
|
|
request that carries the token as `Authorization: Bearer <token>`:
|
|
|
|
- `GET /_smallwebwaf/bans`: every ban held, past, active and permanent.
|
|
- `POST /_smallwebwaf/bans`: bans a netblock, as adding an entry to `bans.json`
|
|
does. The body is a JSON object of `netblock`, `duration` and, if you like,
|
|
`reason`. `netblock` is a netblock such as `203.0.113.0/24`, or a client's
|
|
address, which bans the netblock a ban on that client covers: its IPv4
|
|
address, or the netblock around it that `SWWAF_BAN_SCOPE_V4_PREFIX` sets, or
|
|
its IPv6 /64. `duration` is a duration such as `1h` or `7d`, or `permanent`.
|
|
The ban starts at once, its `cause` is `admin`, and it is made even while
|
|
another ban on the netblock lasts. A body that is not such an object, has
|
|
another field, has anything but whitespace after the object, or is longer than
|
|
4 KiB is answered `400`, saying what is wrong, and so is an IPv4-mapped
|
|
netblock, such as `::ffff:203.0.113.0/120`, or a value with a zone, such as
|
|
`fe80::1%eth0`.
|
|
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban on a netblock
|
|
that `<client>`, an address, is in, as adding `lifted` to its entry in
|
|
`bans.json` does, and answers `404` when no ban on it is active.
|
|
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client at
|
|
the address `<ip>`: under `client`, the client as `clients.json` holds it,
|
|
with its counters and its history, which holds its country as last looked up
|
|
and its offences, or `null` when the table of clients does not hold it; and
|
|
under `bans`, every ban on a netblock the address is in, with its notes.
|
|
|
|
The ban endpoints answer with the bans listed, made or lifted, under `bans`,
|
|
each as an entry of `bans.json` (see "State files" above), and a ban they make
|
|
or lift is written to `bans.json` `SWWAF_STATE_WRITE_DELAY` later. Refusals, and
|
|
the answers to requests that cannot be read, are plain text.
|
|
|
|
A request without the token, or with another, such as the metrics token, is
|
|
answered `401`, in `observe` mode too. While the token is unset, each of these
|
|
answers `404`, as does any request under `/_smallwebwaf/` that is not for one of
|
|
its endpoints. Like the metrics, these requests go through every check any other
|
|
request goes through, and are answered where another would be passed to the app:
|
|
a banned client stays refused, so an admin whose own address is banned lifts
|
|
that ban by editing `bans.json`, and each request counts toward the client's
|
|
rate limits. A client in `SWWAF_ALLOW_NETS` skips the checks, and still needs
|
|
the token.
|
|
|
|
With the token in `$TOKEN`, for an app at `https://app.example`:
|
|
|
|
```sh
|
|
# Every ban.
|
|
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans
|
|
|
|
# Ban 203.0.113.0/24 for seven days.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
|
https://app.example/_smallwebwaf/bans
|
|
|
|
# Lift the bans on 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
|
https://app.example/_smallwebwaf/bans/203.0.113.9
|
|
|
|
# What smallwebwaf knows of 203.0.113.9.
|
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
https://app.example/_smallwebwaf/clients/203.0.113.9
|
|
```
|
|
|
|
## 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, the GeoJS answers and the alerts are built, with an
|
|
edit taken in while running (see "State files" above); the others come with
|
|
their features.
|
|
- 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, the rule files 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, for a
|
|
`block` or `ban` rule, the latter banning 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, when a ban for a clear sign of attack becomes permanent,
|
|
and which ban `smallwebwaf` made is dropped when `SWWAF_MAX_BANS` are held.
|
|
- `internal/rules`: reads the rule files at start and again as they change, and
|
|
tells which of their rules a request matches.
|
|
- `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, takes in an admin's edit of
|
|
one while running, 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.
|
|
- `internal/remotelog`: sends the lines on stdout to `SWWAF_LOG_REMOTE_URL`,
|
|
each as a syslog record, from a buffer of its own. It is written with the
|
|
standard library alone, whose `log/syslog` writes only the older syslog
|
|
format.
|
|
- `internal/alerts`: takes the alerts the other parts raise, holds back repeats
|
|
and those past the hourly limit, and sends the others to the webhook, Slack
|
|
and ntfy, each from a queue of its own.
|
|
- `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` and
|
|
`share/rules.d/00-default.rules` as its default rule file.
|
|
- `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 and the GeoJS answers to 100,000, dropping the least
|
|
recently seen, and the banned netblocks in the order they were last seen, from
|
|
which the ledger picks the ban to drop past `SWWAF_MAX_BANS`, and
|
|
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
|
|
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule
|
|
file is saved. 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, and the rule
|
|
files of `share/rules.d` unless `SWWAF_RULES_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 a probe
|
|
for `/.env` bans another client, whose next request makes the ban permanent,
|
|
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 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)
|