check / check (push) Successful in 4m11s
A request over a rate limit is refused with SWWAF_BAN_RESPONSE and bans the client's netblock: an hour at first, three times the last ban when broken again within a day of its end, permanent past seven days. The ban ledger in internal/bans is checked after the static lists and before the lookup, and the requests it refuses are not counted. A ban resets the client's counters and carries notes holding the request that broke the limit, as SPEC.md now says. At most SWWAF_MAX_BANS are held. SWWAF_BAN_RESPONSE also answers SWWAF_DENY_NETS and the country lists. Judgement call: the six ban settings cannot be off. Judgement call: a permanent ban's ban_expires is "permanent". Model: opus-5-5
608 lines
35 KiB
Markdown
608 lines
35 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 three parts of
|
|
milestone 3: the static lists and the bans that broken rate limits lead to,
|
|
which come next in the build order, 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, refuses a
|
|
client that comes from a country you refuse or from a network you refuse, lets
|
|
the networks you choose through, and writes a JSON log line for every request.
|
|
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`.
|
|
|
|
## 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 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, and only in
|
|
memory: a restart starts every client afresh.
|
|
- 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, how many requests 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 and their notes are
|
|
kept in memory only, so a restart lifts every ban, and nothing shows them yet:
|
|
`bans.json`, which shows them and lets you lift a ban, comes with the state
|
|
files (https://git.eeqj.de/sneak/smallwebwaf/issues/17).
|
|
- 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.
|
|
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
|
|
check and without asking the app, for the image's health check.
|
|
- 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_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_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.
|
|
|
|
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` and the ban settings cannot be off.
|
|
|
|
Several limits are fixed rather than settings. At most 20,000 clients are kept
|
|
for the rate limits, 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 refused because a
|
|
ban covers its client, 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.
|
|
- `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,
|
|
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`.
|
|
|
|
## 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
|
|
come in milestone 3 or later (see the build order in [`SPEC.md`](SPEC.md));
|
|
until then the rate counters, the bans and the GeoJS answers are kept in
|
|
memory only, and a restart loses them.
|
|
- 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. The state files come in milestone 3 or later;
|
|
until then it writes nothing to disk and needs no volume.
|
|
- `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 refused because a ban covers its
|
|
netblock, 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 in memory for seven days, and many addresses are asked about in one
|
|
request; writing the answers to disk, so that they survive a restart, comes in
|
|
milestone 3 or later (see the build order in [`SPEC.md`](SPEC.md)). 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, listens, serves
|
|
requests until `SIGTERM` or `SIGINT`, and stops. 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.
|
|
- `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`: counts each client's requests and tells when one takes
|
|
it over a rate limit.
|
|
- `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. 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;
|
|
`make run` runs it.
|
|
- `script/example-app`: builds the image and, on it, the example app in
|
|
`deploy/example-app`, runs it, and checks that the health check passes, that a
|
|
request reaches the app through `smallwebwaf`, and that `sv stop` and
|
|
`docker stop` stop it in order; then removes the container 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, after the bans that broken rate limits lead to and up
|
|
to the metrics endpoint, and 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)
|