check / check (push) Successful in 4m56s
SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES (default 32K) and SWWAF_CLIENT_IDLE_TIMEOUT (default 120s) replace the two values the proxy fixed. The idle time is read like the other durations, and can be off. Go's server reads 4K past the header limit it is given before it refuses, so it is still given the setting less 4K. The header size must be more than 4K and cannot be off; any other value stops the start with a message that does not offer off. SPEC.md and README.md say so. README.md lists both settings, no longer calls them fixed, and names them as built. Model: opus-5-5
565 lines
32 KiB
Markdown
565 lines
32 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 two parts of
|
|
milestone 3: the static lists, 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, refuses a client that
|
|
sends too many requests, 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 `429`
|
|
before anything reaches the app, and so is each request after it until the
|
|
client is back under every limit. A client is one IPv4 address, or one IPv6
|
|
/64, since one abuser usually holds a whole /64. Refused requests count too,
|
|
so a client that keeps sending too fast stays refused until it slows down.
|
|
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.
|
|
- Refuses a request from a country you refuse with `403`, 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 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 `403` 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 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 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.
|
|
|
|
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; only
|
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` 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, and empty when it is not
|
|
known: 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,
|
|
and when GeoJS cannot place the client or has not answered in time.
|
|
- `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`, `country_denied` for one
|
|
refused for its client's country, `rate_limited` for one refused for a rate
|
|
limit, `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 refused for a rate limit, and names the
|
|
window whose limit it went over: `minute`, `hour` or `day`, the shortest if it
|
|
went over several.
|
|
- `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 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 outside `SWWAF_ALLOW_NETS` and
|
|
`SWWAF_DENY_NETS` is sent to GeoJS, 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
|
|
the country lists, for a rate limit, and for an announced body over the size
|
|
limit.
|
|
- `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 and the GeoJS answers to 100,000, 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, from the bans that broken request limits lead to
|
|
through 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)
|