check / check (push) Waiting to run
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
972 lines
54 KiB
Markdown
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".
|