Files
smallwebwaf/SPEC.md
T
sneak d712bb1496
check / check (push) Waiting to run
SPEC: requirements and reasons only, reference material in docs/
docs/settings.md is now the one settings reference; the spec keeps the
conventions, the off list and each group's requirements and reasons, and
links to it for the per-setting entries. The endpoint list, the ban
notes, the state file contents, the rule file format and the alert
object likewise live in docs/ and are linked from the spec. The package
list from the README's Design section joins "Architecture". The nine
docs files are byte-identical to those on the README branch. Package
comments that named "Alert webhook schema", "Request log" and "Metrics
endpoint" for their field lists now name the docs files.

Model: fable-5-1
2026-10-22 21:08:26 +00:00

972 lines
54 KiB
Markdown

# smallwebwaf specification
`smallwebwaf` is a web application firewall and reverse proxy for one app. It
runs inside the app's container, between traefik and the app. This document says
what it must do and why, each requirement once. Everything in it is built,
except the last section, "Planned, not built". `README.md` says how to run it.
`EVALUATION.md` says why no existing tool was adopted. The reference documents
under `docs/`, which this document links to, give the settings, the formats and
the lists; every fact lives in one of these files and is linked from the others.
## Purpose
- It protects one self-hosted app on the open internet from scrapers, scanners
and other abusers, from the app's first request, with no setting at all. Every
setting has a default chosen for a service facing the internet in 2026. A
setting that needs an alert destination, an account key or a token stays off
until the operator supplies it.
- It limits each client's requests and bytes per minute, hour and day. It bounds
the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries
and netblocks, lower its limits for listed AS numbers and countries, consult
reputation sources, and send alerts.
- Everything is set by environment variables whose names start with `SWWAF_`,
since `smallwebwaf` shares its container, and so its environment, with the
app. The only other inputs are files: the rule files, the state files, a
lookup database, and a setting given as a file.
## Non-goals
- TLS, certificates and routing by host name. Those are traefik's job.
- More than one app per `smallwebwaf`. Each app's image carries its own.
- Browser challenges such as captchas or proof of work. If wanted, chain Anubis
between `smallwebwaf` and the app.
- Defence against floods that saturate the host's network link. That needs help
upstream of the host.
- A web console or a configuration file.
## Architecture
- One statically linked Go binary. It writes only to its state directory. It
ships in the image that is the base of the app's own image (see "Deployment").
- No database of any kind. Nothing is read from disk while serving a request.
- Packages:
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
- `internal/smallwebwaf`: the process. It reads the settings, the rule
files, the lookup database and the state files, listens, and serves until
`SIGTERM` or `SIGINT`. Then it 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
and runs the checks in `check`. It passes the request to the app with the
standard library's `httputil.ReverseProxy`, within the timeouts and size
limits, and answers its own endpoints in `answerAdmin`. Once the request
has ended, it counts bytes, refusals and anomalies, and writes the log
line.
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
refusals, and keeps each client's history.
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
decides how long a new ban lasts, and picks the ban to drop past
`SWWAF_MAX_BANS`.
- `internal/rules`: reads the rule files, and again as they change, and
tells which rules a request matches.
- `internal/waf`: the Core Rule Set with the six changes, as Coraza
directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup
database.
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
AbuseIPDB in the background, and keeps their answers.
- `internal/anomaly`: the anomaly counters and their alerts.
- `internal/alerts`: holds back repeats and alerts past the hourly limit,
and sends the rest to the webhook, Slack and ntfy, each from a queue of
its own.
- `internal/state`: reads the state files at start, takes in edits while
running, and writes the files when due and at the stop.
- `internal/requestlog` and `internal/remotelog`: the lines on stdout, and
sending them to the syslog server.
- `internal/metrics`: the metrics, in the Prometheus text format.
- `Dockerfile`: the lint and test phases, then the image.
`deploy/example-app` is an app built on the image, which
`script/example-app` checks.
- Libraries, checked against the owner's Go dependency defaults:
- the standard library for the proxy, HTTP clients, DNS, logging
(`log/slog`) and the state files (`encoding/json`, `os.Rename`);
- `github.com/corazawaf/coraza/v3` and
`github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries
the Core Rule Set 4.25.0. The changes `smallwebwaf` makes to it, and the
default `SWWAF_WAF_DISABLED_RULES`, are built for that version. They are
checked again before the version changes, since rule ids, and what each
rule matches, change between versions;
- `github.com/hashicorp/golang-lru/v2` for the bounded tables;
- `github.com/oschwald/maxminddb-golang/v2` to read the lookup database;
- `github.com/fsnotify/fsnotify` to notice edited files;
- `github.com/prometheus/client_golang` for the metrics;
- `github.com/maxmind/mmdbwriter`, in the tests, to write lookup databases;
- remote log sending written in this project, since the standard library's
`log/syslog` is frozen and writes only the older syslog format.
## What happens to a request
The steps run in this order. The first step that produces a final answer ends
the handling. The size and time limits apply to the whole exchange. Each step
names the section that holds its rules.
1. `GET /_smallwebwaf/healthz` is answered at once, before any check.
2. The client is identified.
- If the TCP peer is inside `SWWAF_TRUSTED_PROXIES`, `X-Forwarded-For` is
walked from the right, and the first address outside those netblocks is
the client. If every address is inside them, the leftmost is, so that a
visitor on a private network is known by its own address. With no header,
the peer is the client.
- If the peer is outside `SWWAF_TRUSTED_PROXIES`, it is the client, and the
header is ignored.
- An IPv6 client is its group: the netblock of `SWWAF_IPV6_GROUP_PREFIX`
around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12 and the
byte limits. It is still counted for the anomaly thresholds, and still needs
a token at step 13. A client in `SWWAF_DENY_NETS` is refused; a client in
both lists is let through.
4. Bans. A client under a ban is refused (see "Bans").
5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
and country"). The request goes on at once. It waits for the first GeoJS
answer, up to `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it:
- a country list;
- a biased threshold;
- `SWWAF_ADD_LOOKUP_HEADERS`.
A request that ends before the answer comes is logged without it.
6. The country lists (see "Country lists").
7. The reputation sources (see "Reputation"). A client on a fetched blocklist
gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
banned. A client listed by a DNS blocklist, or scored at least
`SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
With no cached verdict, a query is queued in the background and the request
goes on, so a first request is never delayed by a reputation query.
8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
times the percentage is refused and bans the client.
10. The trap paths (see "Attack detection"). A request for one is refused and
bans the client.
11. The rule files (see "Rule files"). Matching stops at the first rule that
refuses.
12. The Core Rule Set (see "Attack detection"). A match is refused with `403`
while `SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is
`detect`.
13. A request under `/_smallwebwaf/` is answered here (see "Admin endpoints").
14. The request is forwarded to `SWWAF_UPSTREAM_URL`, streaming, with
`X-Forwarded-For` added, and `X-Client-ASN` and `X-Client-Country` when
enabled.
15. After the response:
- The response bytes, the request bytes or both are added to the byte
counters, as `SWWAF_BYTES_COUNT` says. A client whose total passes a byte
limit times the percentage has broken that limit and is banned. The
response in progress is not cut off.
- The client's refusals are counted for the error burst (see "Attack
detection").
- The client's history and the anomaly counters are updated, and the alert
thresholds are evaluated.
In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and no ban is made. The size and time limits and
the token check still apply.
## Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client.
The earlier bucket is weighted by how much of it still overlaps the sliding
window. This costs a few integers per client per window and avoids the burst
at bucket boundaries that a single fixed bucket allows.
- Counters are read and updated in memory. They are written to `clients.json`
and loaded again at start (see "Persistent state"), so a restart does not hand
every client a fresh allowance. Buckets whose time has passed are discarded on
load.
- Memory is bounded. When the table of clients holds
`SWWAF_MAX_TRACKED_CLIENTS`, the least recently seen client is dropped first,
with its history. The bans, the GeoJS answers, the reputation verdicts and the
anomaly counters have bounds of their own; `docs/settings.md` gives the fixed
ones under "Fixed limits".
## Bans
Until a ban ends, every request from its netblock is refused with
`SWWAF_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to
`SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did
nothing. Requests refused under a ban are not counted against limits. They are
counted in the client's history and in the ban's notes.
An offence is a request `smallwebwaf` holds against the client. It is one of:
- a clear sign of attack;
- a broken limit;
- a refusal by a rule file, by the Core Rule Set or for a missing or wrong
token.
Offences are counted by kind in the client's history. The first two kinds lead
to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule whose action is `ban`,
or asks for a path in `SWWAF_TRAP_PATHS`. Examples are a probe for a `.env`
file or a `.git` directory, and a known scanner's user agent.
- The first one bans the netblock for `SWWAF_ATTACK_BAN_DURATION` (default
`7d`).
- Any further request from the netblock while that ban lasts shows it is
malicious: the ban becomes permanent.
- Once that ban has run out, the netblock is served like any other. Its next
clear sign of attack bans it permanently at once.
- A broken limit: a request over a rate limit, a response that takes the
client's byte total past a byte limit, or more than
`SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
- The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`). So
does one that comes more than `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default
`24h`) after the last such ban ended.
- Breaking a limit again within that window makes the new ban three times as
long as the last one: one hour, then 3, 9, 27 and 81 hours.
- A ban that would be longer than `SWWAF_MAX_BAN_DURATION` (default `7d`) is
permanent instead. At the defaults, that is the sixth ban in a row.
- Each such ban sets the client's counters for every limit back to zero.
Once it ends, only new requests can break a limit again. The client's
history keeps its totals.
A Core Rule Set match refuses only the request it matched, without a ban,
because the Core Rule Set has false positives. A `block` rule does the same.
Those refusals count toward the error burst, so a client that keeps setting them
off is banned under the second rule.
A client on the CrowdSec decision list is banned until the decision ends, with
the cause `crowdsec`. Only a client that sends a request is banned, so a long
list does not fill `bans.json`. Such a ban is never made permanent. It does not
make the netblock's next ban for a broken limit longer.
These refuse each request they cover, record nothing in `bans.json`, and count
no offence:
- `SWWAF_DENY_NETS`;
- a blocklist or reputation hit whose action is `deny`;
- the country lists.
Every ban carries notes with what an admin needs to decide whether to lift it,
drawn from what `smallwebwaf` already knows; nothing is looked up to fill them.
`docs/bans.md` lists the notes and says how a ban is made, kept or lifted, by
editing `bans.json` or through the ban endpoints. A ban lifted before it ends is
kept, marked as lifted, and does not count toward a longer ban. Deleting its
entry from `bans.json` forgets it entirely.
A permanent ban does not run out. It ends when an admin lifts it, or when
`smallwebwaf` drops it from a full table, as below. When `smallwebwaf` holds
`SWWAF_MAX_BANS` bans of its own, it drops the earliest ban of one netblock: the
netblock that has gone longest without a request. A past, active or permanent
ban is dropped alike. A scanner whose ban was dropped that way is refused and
banned again by its next probe. A ban an admin made, whose cause is `admin`, is
never dropped and does not count toward `SWWAF_MAX_BANS`, so such bans can never
fill the table. An admin who wants to keep a ban `smallwebwaf` made sets its
cause to `admin`.
## Settings
Conventions:
- Lists are comma separated. Netblocks are CIDR; a bare address means /32 or
/128. Durations use Go's syntax plus `d` for days (`90s`, `15m`, `24h`, `7d`).
Sizes take `K`, `M` and `G`, powers of 1024. Countries are two-letter ISO
codes in either case. A code that is not a country, such as `nk`, stops the
start with a message naming it.
- These settings take `off`, which switches them off:
- `SWWAF_LOOKUP_SOURCE` and `SWWAF_WAF_MODE`;
- the five timeouts under "Size and time limits";
- `SWWAF_REQUEST_MAX_BYTES` and `SWWAF_RESPONSE_MAX_BYTES`;
- the three rate limits and the three byte limits;
- `SWWAF_WAF_ANOMALY_THRESHOLD`, `SWWAF_WAF_BODY_LIMIT` and
`SWWAF_ERROR_BURST_THRESHOLD`;
- `SWWAF_ALERT_COOLDOWN` and `SWWAF_ALERT_MAX_PER_HOUR`;
- every anomaly threshold.
Every other setting refuses `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Any setting can be read from a file instead, for secrets and long lists:
`SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
`SWWAF_LOG_REMOTE_TLS_CA_FILE` already holds a file name, so it has no `_FILE`
form.
- Settings, files included, are read once at start. Changing one means
restarting the container. The files `smallwebwaf` watches while it runs are
its state files, its rule files and the lookup database.
- An invalid setting stops the process at start with a message naming the
variable. At start the settings in effect are logged, with secrets masked.
`docs/settings.md` lists every setting with its default and the values it takes,
in the groups below. What each group must do, and why:
### Core
- One listener, `SWWAF_LISTEN_ADDR`, for the requests `smallwebwaf` forwards to
`SWWAF_UPSTREAM_URL` and for its own endpoints (see "Ports").
- `SWWAF_INSTANCE_NAME` is in every log line, metric and alert, so that
instances can be told apart. The default, the container's host name, changes
at every redeploy; a name set by the operator survives it.
- `SWWAF_MODE=observe` logs and alerts on every decision while refusing nothing,
so that a configuration can be tried before it is enforced.
- `X-Forwarded-For` is believed only from `SWWAF_TRUSTED_PROXIES`, by default
the private address ranges, so a client that connects directly cannot claim
another address. Where other containers reach `smallwebwaf` directly, or
visitors on a private network reach traefik, they can name another address in
the header; the setting, narrowed to traefik's own network, closes that gap.
- `SWWAF_IPV6_GROUP_PREFIX`, `SWWAF_MAX_TRACKED_CLIENTS` and `SWWAF_MAX_BANS`
set the group size and the bounds under "Counting method" and "Bans".
### Tokens
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
`/_smallwebwaf/clients/<ip>`; `SWWAF_METRICS_TOKEN` switches on the metrics,
which would otherwise be open to anyone on the internet. The two are separate,
so a scraper that holds the metrics token cannot manage bans.
- A token is a long random value, since the endpoints can be reached from the
internet. One shorter than 32 characters stops the start. The app starts with
the same environment variables as `smallwebwaf`, so it can read a token set as
an environment variable. The file form keeps a token out of the app's reach
(see "Tokens" under "Deployment").
### State files
- The state files live in `SWWAF_STATE_DIR`, a directory of their own beside the
app's data, belonging to the `smallwebwaf` user.
- `bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
or made permanent, so a burst of changes becomes one write. Every file is
written every `SWWAF_STATE_COUNTER_INTERVAL`, and at an orderly stop.
### Logging
- The request log on stdout is always on and has no switch. The process's own
messages are JSON lines on stdout too, marked `"type":"process"`;
`SWWAF_LOG_LEVEL` filters only those.
- `SWWAF_LOG_REQUEST_HEADERS` names the request headers to record.
`Authorization`, `Cookie` and `Set-Cookie` values are never logged, only
whether they were present.
- `SWWAF_LOG_REMOTE_URL` sends every log line to a syslog server as well, over
UDP, TCP or TLS. Lines wait in a buffer of `SWWAF_LOG_REMOTE_BUFFER` lines;
when it is full, the oldest are dropped and counted.
### Metrics
- `SWWAF_METRICS_TOP_N` bounds the series by AS number and by country: that many
of the busiest each, the rest summed as `other`, so the label set stays
bounded.
### Static lists
- `SWWAF_ALLOW_NETS` bypasses everything (monitoring, the owner's own networks).
`SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte limits only; the
error burst, attack detection and bans still apply. `SWWAF_DENY_NETS` is
always refused.
- Many clients share one address (mobile carriers, offices, Tor), and so share
its limits and its bans. The defaults sit well above one person's use, and the
first two lists take known shared addresses out.
### Request rate limits, per client
Breaking one bans the client, so the defaults sit several times above what one
busy person produces. A browser loading a heavy page makes a few hundred
requests, a git clone a handful, and several people often share one address. A
machine that talks to the app all day, such as a gitea Actions runner, can still
pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
static assets or health checks.
### Byte limits, per client
A response's bytes are counted when it ends, so every default sits above the
largest response allowed, and no single download breaks one. `SWWAF_BYTES_COUNT`
says whether request bytes, response bytes or both are counted.
### Size and time limits
These apply per request, in both directions. The client-facing timeouts apply
between the client and `smallwebwaf`, the app-facing ones between `smallwebwaf`
and the app, since a slow client and a slow app are separate problems. There is
one size limit for request bodies and one for response bodies. Bodies pass
through unchanged, so a limit on each side would bound the same bytes, and the
lower one would always decide. Bodies stream straight through, so a request body
reaches the app while the client is still sending it.
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` 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` is longer by default than the 90 seconds after
which traefik, by default, closes an unused connection. So traefik closes
first, and never sends a request on a connection `smallwebwaf` is closing.
What the client gets when a limit is passed:
- Go's HTTP server reads a request's line and headers before `smallwebwaf` sees
the request. It answers `431` to headers over
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, and a client too slow to send them
gets an error answer, mostly `400`, or none. Either way the server closes the
connection, nothing reaches the app, and the request log gets no line.
- A request that announces a body larger than its limit is refused before
anything reaches the app.
- A limit passed after the headers have been read, but before the response has
started, is answered by `smallwebwaf`:
- `413` for a request body that is too large;
- `408` for a client too slow to send its body;
- `502` for a response that is too large;
- `504` for an app that is too slow.
- A request body streams through, so each side can hold up the other. A request
timeout that runs out while the body is still on its way answers `408` if
`smallwebwaf` was waiting for the client at that moment, and `504` if it was
waiting for the app.
- Once the response has started it can only be cut off, and the connection is
closed.
- A WebSocket connection leaves these limits behind once it is upgraded. It
stays open until either side closes it.
### Lookup of AS number and country
On by default through GeoJS, which needs no account or file, so that country
lookup works with no setup. A local database file, the free IPinfo Lite
database, is the alternative for a service that keeps its visitors' addresses on
its own host; the operator downloads and mounts it, and `smallwebwaf` never
fetches it and holds no account or token for it. `docs/lookup.md` says what each
source sends where, how long answers are kept, and when a request waits.
- Only one source is used. The start stops, naming both settings, when `file` is
set without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other
source. The start also stops when the source is `off` and a setting needs
lookups: a country list, a biased threshold, `SWWAF_ADD_LOOKUP_HEADERS` or an
anomaly threshold per AS number.
- GeoJS is told the address of every new visitor, whether or not a setting uses
the answer, since the request log, the metrics and the ban notes carry it.
Private, loopback and link-local addresses, which no source can place, are
never sent. Addresses are asked about in batches, one request at a time, and
answers are kept for days, which keeps the number of requests to GeoJS low.
Answers are kept apart from the table of clients, so that an answer outlasts a
client dropped from that table.
- A client the lookup cannot place has an unknown country and AS number. The
exclusive country list refuses it, and the biased thresholds give it
`SWWAF_UNKNOWN_LIMIT_PERCENT`. Nothing else treats it differently.
- `SWWAF_LOOKUP_TIMEOUT` bounds both how long a visitor's request waits for its
client's first answer, when a setting needs it, and how long a request to
GeoJS may take. A client whose answer has not come in time counts as unknown
until it comes, and its later requests do not wait.
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
is slow, down or refusing `smallwebwaf`, new clients count as unknown,
`smallwebwaf` keeps asking with backoff, and one `source_failure` alert is
sent per cooldown. A service that cannot accept this, or that every new
visitor's address goes to a third party, uses the file.
- A replaced database file is read again; one that cannot be read is logged,
sent as one `file_error` alert, and ignored, the file in use staying in use.
The directory that holds the file is mounted, not the file itself (see "Rule
files" for why).
### Country lists
Both are empty by default: the defaults judge a client by what it does, not by
where it comes from. Clients in `SWWAF_ALLOW_NETS` are not checked.
- `SWWAF_DENIED_COUNTRIES` refuses every request from a listed country.
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
listed country. Every request from any other country is refused. So is every
request from a client the lookup cannot place, such as internal monitoring;
those that should reach the app go in `SWWAF_ALLOW_NETS`. A list that let
unplaced clients through would let every new client in whenever GeoJS stops
answering. The cost: while GeoJS fails, this list refuses every new client. No
other failure of GeoJS, a reputation source or an alert destination comes this
close to taking the service down.
- Both may be set. `SWWAF_DENIED_COUNTRIES` then adds nothing, since the
exclusive list already refuses every other country. A code on both lists stops
the start with a message naming it.
- A refused request is answered with `SWWAF_BAN_RESPONSE`, as soon as the
client's address has been looked up, before its body is read. It skips the
reputation checks, the limits, the rule files and the Core Rule Set. It is
logged with the action `country_denied`, and counted in the metrics.
### Biased thresholds
The lists are empty by default, for the same reason as the country lists. The
request log and the metrics show which AS numbers and countries a service's
abuse comes from.
- Clients in a listed AS number or country get that percentage of every request
and byte limit (`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`), or
of the byte limits alone (`SWWAF_ASN_BYTES_PERCENT`,
`SWWAF_COUNTRY_BYTES_PERCENT`). `0` is a zero allowance: the client's first
request breaks a limit and bans it.
- `SWWAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
abuse-source list can be shared by every `smallwebwaf` in the fleet. It is
fetched and refreshed like the blocklists.
- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. It is 100 if
none applies. Clients given a lower percentage are banned after fewer
requests, first for a while and then, for repeated abuse, permanently.
- No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the
operator's choice, through the country lists.
### Attack detection
By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That is where automated attacks show:
injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. On a code forge, the example
deployment, bodies carry what people write and publish, such as issue text and
files saved in the web editor. The Core Rule Set takes the shell commands and
code in them for attacks. The default gives up refusing an attack carried in a
body; the rule files, the limits and the bans still apply to the client that
sends it, and `SWWAF_WAF_BODY_LIMIT` switches body inspection on.
`docs/core-rule-set.md` says what the Core Rule Set reads and how it scores a
request.
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
Set's own two tuning values, at its own defaults. A request whose score
reaches the threshold is a match. With the threshold `off`, nothing is a
match, and scores are still logged.
- `SWWAF_WAF_DISABLED_RULES` switches off rule ids when an app trips a false
positive. The default switches off six rules:
- 920340, 920420 and 920640 refuse a request for the type of its body, when
the type is missing or not on the Core Rule Set's short list;
- 920440 refuses a request for its file extension, such as `.sh` or `.sql`;
- 930130 and 930140 refuse a file or directory name in the path, such as
`.git/`, `.gitignore`, `Dockerfile`, `package.json` or an editor's
settings directory.
In front of a code forge these refuse git over HTTP, container image and
package uploads, and views of ordinary files in a repository. At the site
root, where no app serves such files, the default rule file bans the common
probes these rules caught (see "Rule files"). A list you give replaces the
default, so name these six ids in it to keep them off.
- `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. Form data
and multipart bodies are read up to the size, the rest streaming on without
being held in memory; JSON and XML bodies are read only when no larger than
the size, since those cannot be read in part. Any other body is not read: the
Core Rule Set would read it as form data, where binary content such as a git
push trips rules written for text. Body inspection suits apps whose forms
carry no code. `docs/gitea.md` records what it refuses in front of gitea.
- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for,
such as `/wp-login.php` in front of gitea. A request for one is a clear sign
of attack. This is the short form of a `path` rule with the `ban` action, for
deployments that mount no rule files.
- `SWWAF_ERROR_BURST_THRESHOLD` bounds the refusals per client per minute after
a rule file match, a trap path, a Core Rule Set match, or a missing or wrong
token. More than this breaks a limit (see "Bans"). A client trying one attack
or token after another is refused many times a minute; a person rarely more
than a few times. Answers the app gives are not counted, since they do not
tell a scanner from an ordinary client. In front of gitea, registry and git
clients get `404` and `401` by design, hundreds of times a minute.
- A Core Rule Set engine error on a request forwards the request, and is logged
and counted.
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them:
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
and `OPTIONS`. APIs, container image pushes and package uploads use them.
Other methods stay refused.
2. The request header `Expect` is allowed, since curl and git send it before
some large uploads. `Content-Encoding` is allowed on a request whose body the
Core Rule Set does not read, which by default is every request. git
compresses a fetch request over 1 KiB and says so in that header. On a body
the Core Rule Set does read, the header stays refused, since a compressed
body cannot be inspected. The other headers the Core Rule Set refuses, such
as `Proxy`, stay refused.
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
`http://127.0.0.1` on the user's own machine. The server never fetches that
address.
4. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260):
- `path`, `files`, `skip-to` and `sub_path`;
- `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`.
gitea puts file paths, branch and workflow names, and the page to return to
after signing in into them. Any name on those lists can be an ordinary file
or branch, such as `.gitignore` or `docker-build`. In these parameters,
anything only these three rules catch reaches the app, commands such as
`|cat /etc/passwd` included. Path traversal, SQL and script injection, and
PHP, Java and Node.js code are still refused there. Every other parameter
keeps all three rules. Parameter names are matched without regard to case,
as Coraza matches them. An app that uses one of these parameters as a file
on the server, or passes it to a shell, must check the value itself. A rule
file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts the page to return to after signing in into
`redirect_to`. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the
whole site. gitea shows the message with any script removed and returns
only to its own pages. Every other cookie is read in full. An app that
reads a cookie of either name must check it itself.
- `Referer` is not checked for a Unix command given without arguments
(932340) or for Java starting a process (944110). `Referer` names the page
a request comes from, on everything that page loads and on every link
followed from it. The Core Rule Set takes some page addresses for attacks,
such as a search for `env`, or OpenJDK's `Runtime.java`, whose path holds
both `runtime` and `java.`. All of those requests would be refused.
`Referer` keeps the other rules. An app that passes `Referer` to a shell
must check it itself.
6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
### Ban response and scope
`SWWAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise
when debugging; `429` and `close` are the alternatives. Behind traefik, `close`
does not leave the client unanswered: traefik answers `502`, as it does whenever
its backend drops a connection. `SWWAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban
to the surrounding netblock. The four ban lengths are under "Bans".
### Reputation
No source is on by default. A source acts on a list kept elsewhere; the defaults
judge a client only by what it does here. AbuseIPDB and CrowdSec also need an
account or an engine of the operator's own. The list best suited to be on by
default, Spamhaus DROP, must not be fetched more than once an hour, and Spamhaus
may block an address that does. Each `smallwebwaf` fetches its own copy, so
several apps on one host, sharing one address, would often fetch less than an
hour apart. `docs/reputation.md` says how each source is asked and what is done
with the answer.
- Blocklists (`SWWAF_BLOCKLIST_URLS`) are text files of addresses and netblocks,
such as the Spamhaus DROP list. Spamhaus's terms for it, on its DROP page:
- automated downloads must be at least one hour apart, and once a day is
more than enough in most cases;
- a product that uses it must credit The Spamhaus Project and keep the
list's date and copyright lines with the data.
So `SWWAF_BLOCKLIST_REFRESH` is never less than `1h`, and the last good copy
is kept whole, comment lines included, on failure and across restarts.
- `SWWAF_BLOCKLIST_ACTION` is `deny` by default, which refuses every request
from a listed address and suits lists of networks that send nothing
legitimate, such as DROP. `limit:<percent>` gives listed clients that
percentage of every limit, and suits a list of addresses shared with ordinary
visitors, such as Tor exits. `log` only notes the hit.
- DNS blocklists (`SWWAF_DNSBL_ZONES`) meant for mail list residential ranges
and would block ordinary visitors, so they should not be used. Spamhaus zones
need their keyed query service, given as a zone name containing the key.
`SWWAF_DNSBL_RESOLVER` names a resolver, since public resolvers are refused by
several list operators.
- AbuseIPDB is asked only about clients that have already committed one offence,
so the daily budget (`SWWAF_ABUSEIPDB_DAILY_BUDGET`, under the free tier's
1000 checks a day) is spent on suspects.
- A CrowdSec engine on the host (`SWWAF_CROWDSEC_LAPI_URL`) has its decision
list pulled on a schedule. It is kept like a fetched blocklist, and a client
on it is banned (see "Bans"). This is how a fleet-wide blocklist can arrive
without `smallwebwaf` depending on CrowdSec.
- `SWWAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is `limit:25`
by default. Such verdicts are less certain than a blocklist, so a listed
client gets a quarter of every limit rather than a refusal.
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, with a fixed
bound on their number. A source that is down or over quota gives no verdict.
The service continues, with one `source_failure` alert per cooldown.
### Alerting
- Alerts go to a webhook as JSON (`docs/alerts.md` gives the object), and to
Slack and ntfy as formatted messages. `SWWAF_ALERT_EVENTS` chooses the event
types sent.
- `SWWAF_ALERT_COOLDOWN`: the same event is not repeated within this time for
the same netblock, source or file. For an anomaly, it is the same thing
counted that must match: one client, netblock, AS number or named netblock, or
the whole service. A count of suppressed repeats is included in the next one.
- `SWWAF_ALERT_MAX_PER_HOUR`: beyond this, alerts are rolled into one summary
per hour, so a wide attack cannot flood the channel.
- A destination that is down is retried with backoff from a bounded queue, the
oldest alert dropped first, and the drops counted in the metrics.
### Anomaly thresholds
These only alert; nothing is blocked. None is set by default, and a threshold
left unset sends no alert. An alert needs a destination only the operator can
supply, and what counts as unusual depends on each service's normal traffic,
which the metrics show. They are what reveals a widely distributed scraper,
thousands of addresses at low rates each, which the per-client limits do not
see; a low `SWWAF_ASN_LIMIT_PERCENT` for its AS number is the response.
- Requests and bytes are counted per minute and per hour, for each client, each
surrounding netblock, each AS number, the whole service, and each named
netblock in `SWWAF_WATCH_NETS`.
- Anomaly counting includes clients in `SWWAF_ALLOW_NETS` and
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, since an exempt client misbehaving is worth
knowing about. At most 20,000 counters are kept, in memory and in
`alerts.json`.
## Rule files
`smallwebwaf` reads every `*.rules` file in `SWWAF_RULES_DIR` and checks each
request against them, unless the client is in `SWWAF_ALLOW_NETS`.
`docs/rules.md` gives the format, the targets and actions, the errors, and the
default file.
- The files are plain text meant to be edited by hand, so that a new scanning
pattern seen in the request log becomes a rule in one line. `smallwebwaf`
watches the directory: a file edited, added or removed there takes effect
while it runs.
- A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in
exchange matching time is linear in the input, so no rule can be made to stall
the proxy.
- `path` and `uri` are matched both as received and once percent-decoded, so an
encoded probe cannot slip past. Request bodies are not available to rule
files; reading them is the Core Rule Set's job.
- `block` refuses the request without a ban; `ban` makes the request a clear
sign of attack (see "Bans"). `ban` is for requests no real visitor sends,
anchored at the site root. A file of the same name deeper in a site can be
ordinary content, such as a file in a gitea repository. A visitor banned by
mistake is let back in by lifting the ban.
- At start, a file that does not parse, a regex that does not compile, or a
duplicate id stops the process with a message naming the file and line. So
does a `SWWAF_RULES_DIR` that does not exist. While running, the same faults
leave the rules as they were, and the log and one `file_error` alert name the
file and line. An empty directory is not an error.
- The image ships a default file, kept short and limited to patterns that are
wrong for every app, with its path rules anchored at the site root. Anything
app-specific belongs in a file the app's Dockerfile adds or the deployer
mounts. Rules meant to be edited while `smallwebwaf` runs belong in a mounted
directory, not a mounted file. Many editors save a file by replacing it, and a
container keeps seeing the old one when the file alone is mounted. The lookup
database is mounted the same way, for the same reason.
## Persistent state
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of all
of it, so an orderly stop loses nothing. The one exception is log lines still
waiting to be sent to `SWWAF_LOG_REMOTE_URL`, which stdout has already carried.
The files are meant for people as well: an admin can read or edit them at any
time, and the running `smallwebwaf` takes the edit in. `docs/state-files.md`
lists the files, what each holds, their sizes, and how an edit is taken in.
- Each file holds one kind of state, as JSON with:
- a top-level `version` number;
- entries sorted by client address;
- times in RFC 3339 UTC;
- durations and sizes as plain numbers, with the unit in the field name.
- `bans.json` is indented, so that a person can open it in an editor, find an
address, and remove or add an entry. `clients.json` and `lookups.json` put
each entry on one line, which halves their size and lets `grep` show
everything about one client.
- Clients that were never banned are kept too, so every client's history
survives a restart.
- Each file is rewritten whole, so each has a bound given by its setting.
Writing a file of these sizes takes well under a second, in the background, so
rewriting whole files needs nothing cleverer.
- A write serialises from a snapshot taken under the lock, so requests are not
held up. It goes to a temporary file in the same directory, which is synced
and renamed over the real name. A crash at any point leaves either the old
complete file or the new complete file, never a partial one. Before writing a
file, `smallwebwaf` takes in any edit made to it on disk, so an admin's edit
is never overwritten.
- At start, a file that does not parse, or has an unknown `version`, stops the
process with a message naming the file and position. Starting with empty state
would silently forgive every repeat offender. Since writes are atomic, a
broken file can only come from a hand edit, which the editor should hear
about. A missing file means empty state, and is normal on first run.
- While running, an edit that parses replaces what `smallwebwaf` held for that
file. An edit that does not parse never stops the running `smallwebwaf`. It
keeps the state it has and sets the edited file aside as `<name>.bad`. It
writes the file again from memory, and names the file and the position of the
error in the log and in one `file_error` alert.
- A write that fails leaves the state correct in memory. It is logged, counted
in the metrics and sent as one `file_error` alert per cooldown, and retried at
the next write.
- A hard kill loses the changes since the last write. Losing the volume loses
ban history, not service.
## Request log
Every request gets one JSON object on one line on stdout, refused ones included,
apart from those Go's HTTP server ends before `smallwebwaf` sees them (see "Size
and time limits"). `docs/request-log.md` lists the fields and describes the
syslog sending. Bodies are never logged. Query strings are logged as received;
an app that carries secrets in query strings needs that fixed in the app. The
process's own messages share the stream as JSON lines with `"type":"process"`;
request lines carry `"type":"request"`.
When `SWWAF_LOG_REMOTE_URL` is set, the same lines are also sent there as RFC
5424 syslog records. So a deployment can stop depending on docker's log handling
while `docker logs` keeps working. Sending happens on its own goroutine from a
bounded buffer: an unreachable or slow server never delays a request and never
stops stdout. UDP gives no delivery signal at all and is offered only for
compatibility.
## Metrics endpoint
`GET /_smallwebwaf/metrics` answers in the Prometheus text format, for a request
carrying `SWWAF_METRICS_TOKEN`. `docs/metrics.md` lists the metrics. No metric
carries a client IP address as a label. Per-address questions are answered by
the request log and `GET /_smallwebwaf/clients/<ip>`.
## Admin endpoints
A request whose path starts with `/_smallwebwaf/` is for `smallwebwaf` itself:
it answers it and never passes it to the app. The prefix carries the tool's
name, so it takes no path an app uses. Admins and scrapers reach these endpoints
through traefik, like any other request. `docs/admin-endpoints.md` lists the
endpoints and what each answers.
- `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a
token, while `smallwebwaf` is running. It does not ask the app; the
container's health check does. It is answered before any check, so a health
checker never needs an exemption and is never refused, for example by
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`.
- Every other endpoint needs its token, sent as `Authorization: Bearer <token>`
(see "Tokens" under "Settings"). While a token is unset, the endpoints that
need it answer `404`, as does any other path under the prefix.
- Apart from the health check, these requests go through every check that any
other request goes through. They are answered at the point where another
request would be forwarded to the app: a banned or refused client stays
refused, and each request counts toward the client's limits. A missing or
wrong token is answered `401`, in `SWWAF_MODE=observe` too, and counts toward
the error burst. A client guessing tokens is therefore banned once it passes
`SWWAF_ERROR_BURST_THRESHOLD` guesses in a minute. A client in
`SWWAF_ALLOW_NETS` skips the checks but still needs the token.
## Deployment
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
`FROM` the `smallwebwaf` image. Each app remains one container. An app is
hardened by changing its base image. It can add packages from nixpkgs.
The image holds:
- Ubuntu 26.04 LTS, with `ca-certificates`, `nix-bin` and `runit` from Ubuntu;
- nixpkgs, fixed at one release;
- `runsvinit` as the entrypoint;
- the `smallwebwaf` binary, a user of its own (uid and gid 65532), and its runit
service;
- its state and rules directories;
- `EXPOSE 8080` and the health check below.
Every input to the image is pinned by digest or hash, so that an app built on it
gets the same packages each time. `docs/image.md` says how each part is pinned,
and why.
Every `run` script, the app's included, is a bash script. It starts with
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
function, called on its last line, as the owner's code style guide asks. Ubuntu
ships bash.
Beyond its `FROM` line, the app's Dockerfile adds:
- its binary;
- any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.<name>`;
- its runit service: a directory under `/etc/service` named after the app, never
`smallwebwaf`. Its `run` script waits one second (`sleep 1`) and then starts
the app with `chpst`, listening where `SWWAF_UPSTREAM_URL` points (see
"Ports"). The app runs as a user of its own, which the Dockerfile creates with
`useradd`. That user is neither root nor `smallwebwaf`, so that the app cannot
touch the state files or the process of `smallwebwaf`.
It sets no `ENTRYPOINT` or `USER` of its own. The container must start
`runsvinit`, as root, so that it can start each service as its own user.
"Getting started" in `README.md` shows such a Dockerfile and `run` script.
### The two processes
- `runsvinit`, `runsvdir` and each service's `runsv` run as root. Each `run`
script starts as root and hands over to its program's user with `chpst`. Both
services start with the container's environment variables and write to the
container's output, which `docker logs` shows.
- When `smallwebwaf` or the app exits, runit starts it again a second later. The
other process keeps running, and the container does not stop. While
`smallwebwaf` is down, traefik answers `502`. While the app is down,
`smallwebwaf` answers `502`, which is not a client offence.
- Only four things stop `smallwebwaf`, all at start:
- invalid configuration;
- a lookup database that is missing or unreadable;
- a rule file or state file that does not parse;
- an unwritable `SWWAF_STATE_DIR`.
- A `smallwebwaf` that stops at start is started again every second, logging its
message each time, until the cause is fixed; the health check fails meanwhile.
Once running, a failing GeoJS, reputation source or alert destination, or a
broken edit, never takes the protected service down.
- `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop both
services at once, each with `SIGTERM`, and exits once they have stopped.
`smallwebwaf` stops taking requests, gives those in progress five seconds to
finish, and writes its state files. An app that exits at once on `SIGTERM`
ends those requests: the client gets `502`, or a response cut short. To let
them finish, the app must keep answering for five seconds after `SIGTERM`.
- The container's root filesystem stays writable: runit writes each service's
status into its directory under `/etc/service`.
### The health check
The image's `HEALTHCHECK` runs `smallwebwaf healthcheck`. It passes while
`smallwebwaf` answers `GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in
`SWWAF_LISTEN_ADDR`, and the app accepts connections at `SWWAF_UPSTREAM_URL`. So
the container shows as healthy only while both processes are up. traefik sends a
container no requests until it shows as healthy. So the check runs every second
from the container's start until it first passes, for up to a minute, and every
30 seconds after that. An app with a health check of its own can replace the
image's `HEALTHCHECK` with one that checks both.
### Ports
`smallwebwaf` listens on no port but the one in `SWWAF_LISTEN_ADDR`, 8080 by
default, which traefik routes to. Its own endpoints are on that listener, and it
forwards every other request to the app at `SWWAF_UPSTREAM_URL`,
`127.0.0.1:8081` by default. Another port may be set: the health check follows
the setting, traefik's port label
(`traefik.http.services.<name>.loadbalancer.server.port`) must name it, and the
app must leave it free. The address part of `SWWAF_LISTEN_ADDR` stays empty
(`:9000`, never `127.0.0.1:9000`), so that traefik reaches `smallwebwaf` on the
container's address and the health check on `127.0.0.1`. The app listens on that
loopback address only. An app that listens on every address can be reached
around `smallwebwaf` by anything that reaches the container.
### State
`smallwebwaf` keeps its state files in `SWWAF_STATE_DIR`, beside the app's own
data directories. Without a volume there, the files survive a restart of the
container and are lost when a deploy replaces it. A volume mounted at
`/var/lib/smallwebwaf`, named or a host directory, keeps them across deploys.
The `run` script makes it and every file in it belong to the `smallwebwaf` user,
so a host directory mounted there needs no change of owner. The volume is
separate from the app's, holds a few tens of MiB at most with the defaults, and
needs no backup beyond the host's own. The image declares no volume, since every
app image built on it would inherit it.
### Tokens
A token given as a file (`SWWAF_ADMIN_TOKEN_FILE`, `SWWAF_METRICS_TOKEN_FILE`)
is out of the app's reach only while the `smallwebwaf` user alone can read the
file. The operator makes the file on the host, owned by uid 65532, the
`smallwebwaf` user, with mode `0400`, and mounts the directory that holds it
into the container read-only. The container sees the same owner and mode.
### Forwarded headers
The app's TCP peer is `smallwebwaf` on `127.0.0.1`, and the `X-Forwarded-For`
the app receives ends with traefik's address, which `smallwebwaf` adds after the
visitor's. The app must therefore trust `127.0.0.1` and `::1` for forwarded
headers, besides the private address ranges traefik is on. The usual default for
a trusted-proxy setting, the private ranges alone, does not include loopback: an
app left at it ignores the header and sees every visitor as `127.0.0.1`. A list
given replaces that default, so the app's own trusted-proxy setting names them
all: `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`.
### upaas
An app deployed by upaas takes this shape with no change to upaas. upaas builds
the app's Dockerfile and runs the one container as it runs any app. The traefik
labels route to port 8080, and any `SWWAF_` settings go with the app's
environment variables. The volume for `/var/lib/smallwebwaf` and the directory
of any token file go beside the app's own volumes. gitea is built on the image
like any other app; `docs/gitea.md` records what the defaults do in front of it,
and `script/gitea-requests` checks those notes.
## Planned, not built
Everything above is built. These two points are specified as later work, and
nothing in the code does them yet:
- Planned: sharing bans between the instances of several apps, by running one
CrowdSec engine per host that every instance reports its bans to. It is
reconsidered once real ban volumes are known. Until then each instance keeps
its own bans, and can read a CrowdSec engine's decisions as described under
"Reputation".
- Planned: sending the log over RELP, the reliable syslog transport. It is
revisited against the available Go packages. Until then the remote log goes
over UDP, TCP or TLS as described under "Request log".