check / check (push) Waiting to run
The spec names every fault that stops the start, runs step 15 for every request but the health check, and states each of the fourteen repeated requirements once. docs/alerts.md says which alerts have an empty client and netblock and what a summary's detail holds; docs/settings.md lists the settings that need a lookup and points at step 3 for the two static lists. The README and the spec now travel in one branch, so the README never names a spec section that does not exist yet. Model: fable-5-1
1171 lines
66 KiB
Markdown
1171 lines
66 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/` give the settings, the formats and the lists. This document links
|
|
to them.
|
|
|
|
## Purpose
|
|
|
|
- It protects one self-hosted app on the open internet from scrapers, scanners
|
|
and other abusers. It does so 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 when the table
|
|
of bans is full.
|
|
- `internal/rules`: reads the rule files at start and again when they
|
|
change, and tells which rules a request matches.
|
|
- `internal/waf`: the Core Rule Set, with the six changes listed under
|
|
"Attack detection", 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 checks; step 15 runs for every request but the health check. 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, so a
|
|
health checker never needs an exemption and is never refused.
|
|
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 and the error burst at step 15. It 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`.
|
|
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.
|
|
`X-Forwarded-For` is added, and `X-Request-ID` carries the request's id,
|
|
generated when traefik did not supply one. With `SWWAF_ADD_LOOKUP_HEADERS`,
|
|
`X-Client-ASN` and `X-Client-Country` are added for the app's own logs; a
|
|
header whose value is unknown is left out. Whatever that setting says, any
|
|
such header the client sent is removed.
|
|
15. After the response, or after the refusal:
|
|
- For a request forwarded to the app, the response bytes, the request bytes
|
|
or both are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a
|
|
WebSocket, what it carried each way counts once it closes. 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 refusals the error burst counts (see "Attack detection") are counted.
|
|
- 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 are bounded by `SWWAF_MAX_BANS` (see "Bans"). The
|
|
GeoJS answers, the reputation verdicts and scores, the DNS queries under way
|
|
and the anomaly counters have fixed bounds, listed under "Fixed limits" in
|
|
`docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used
|
|
longest ago goes first, and the verdict or score fetched longest ago. Past
|
|
that bound, a zone is asked at the client's next request.
|
|
|
|
## 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`), with the cause `attack`.
|
|
- Any further request from the netblock while that ban lasts shows it is
|
|
malicious: the ban becomes permanent. No other cause of ban becomes
|
|
permanent this way.
|
|
- 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`), with
|
|
the cause `limit`. 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, with `403` and
|
|
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. A ban lifted while the
|
|
decision is in force is made again at the client's next request. To lift it for
|
|
good, delete the decision in CrowdSec, then lift the ban once the list has been
|
|
fetched again.
|
|
|
|
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. No lookup is made for the notes
|
|
alone. `docs/state-files.md` lists the notes with the rest of a ban's entry.
|
|
|
|
An admin adds, keeps or lifts a ban by editing `bans.json` (see "Persistent
|
|
state") or through the ban endpoints (see "Admin endpoints"). A ban an admin
|
|
makes has the cause `admin`; an admin who wants to keep a ban `smallwebwaf` made
|
|
sets its cause to `admin`. A ban lifted before it ends is kept, marked as
|
|
lifted, and does not count toward a longer ban; it refuses nothing. Deleting its
|
|
entry from `bans.json` forgets it entirely. An admin whose own address is banned
|
|
lifts the ban by editing `bans.json`, since the endpoints refuse a banned
|
|
client's request like any other.
|
|
|
|
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 whose cause is `admin` is never dropped
|
|
and does not count toward `SWWAF_MAX_BANS`, so such bans can never fill the
|
|
table.
|
|
|
|
## Settings
|
|
|
|
`docs/settings.md` lists every setting with its default and the values it takes,
|
|
in the groups below. It also gives the form each kind of value takes.
|
|
Conventions:
|
|
|
|
- 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.
|
|
|
|
No other setting can be switched off with `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`. The
|
|
file's contents are the value, less one newline at the end, and are checked as
|
|
the value itself would be. Setting both forms stops the start, and so does
|
|
naming a file that cannot be read. `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 one log line, marked
|
|
`"type":"process"` with `"msg":"invalid setting"`. The line names the variable
|
|
and what is wrong with the value; "The two processes" says what happens then.
|
|
At start the settings in effect are logged on one line; tokens, keys and the
|
|
paths of webhooks, which often carry a secret, are shown as `********`.
|
|
|
|
### Core
|
|
|
|
- `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` lets a configuration be tried before it is enforced (see
|
|
"What happens to a request").
|
|
- `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. Narrowing the setting to traefik's own network closes that gap.
|
|
- `SWWAF_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6
|
|
client starts afresh; existing bans keep refusing their netblocks until they
|
|
end. `SWWAF_MAX_TRACKED_CLIENTS` and `SWWAF_MAX_BANS` set the bounds under
|
|
"Counting method" and "Bans".
|
|
|
|
### Tokens
|
|
|
|
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
|
|
`/_smallwebwaf/clients/<ip>`. The metrics are off until `SWWAF_METRICS_TOKEN`
|
|
is set, since without a token they would 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
|
|
|
|
- `SWWAF_STATE_DIR` names the directory of the state files (see "State" under
|
|
"Deployment").
|
|
- `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 (see "Request log").
|
|
`SWWAF_LOG_LEVEL` filters only the process's own messages, and no message is
|
|
at `debug`. `warn` and `error` hold back the line of settings at start.
|
|
- `SWWAF_LOG_REQUEST_HEADERS` names the request headers to record.
|
|
`Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if
|
|
listed, only whether they were present.
|
|
- `SWWAF_LOG_REMOTE_URL` names a syslog server (see "Request log").
|
|
|
|
### Metrics
|
|
|
|
- `SWWAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries get
|
|
a series of their own. The rest are summed as `other`, so the label set stays
|
|
bounded.
|
|
|
|
### Static lists
|
|
|
|
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or 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.
|
|
- Many clients share one address (mobile carriers, offices, Tor), and so share
|
|
its limits and its bans. `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS`
|
|
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, and a git clone a handful. A machine that talks to the app all day 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; their bytes are not counted either. A prefix is
|
|
matched exactly as sent, without the query, and is not percent-decoded:
|
|
`/assets/` matches `/assets/app.js`, and does not match `/Assets/app.js`,
|
|
`/%61ssets/app.js` or `/static/assets/app.js`. There are no wildcards. A path
|
|
holding `..`, a backslash or an encoded slash (`%2F`) is never exempt, since the
|
|
app may read it as a path outside the prefix. `SWWAF_WAF_EXEMPT_PATHS` is
|
|
matched the same way.
|
|
|
|
### Byte limits, per client
|
|
|
|
A response's bytes are counted when it ends (see step 15). So every default sits
|
|
above the largest request and answer together, and no single download breaks a
|
|
limit.
|
|
|
|
### 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, except the part the Core
|
|
Rule Set reads while `SWWAF_WAF_BODY_LIMIT` is set.
|
|
|
|
- `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, or an app that cannot be reached;
|
|
- `504` for an app that is too slow.
|
|
- A request body streams through, so each side can hold up the other. A request
|
|
timeout can run out while the body is on its way. The answer is then `408` if
|
|
`smallwebwaf` was waiting for the client, 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 is the alternative for a
|
|
service that keeps its visitors' addresses on its own host.
|
|
|
|
- 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 that needs
|
|
a lookup is set (`docs/settings.md` lists them).
|
|
- 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.
|
|
These addresses are never sent:
|
|
- one in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`;
|
|
- one under a ban;
|
|
- a private, loopback or link-local address, which no source can place.
|
|
- For an IPv6 visitor, GeoJS is asked about the first address of its group.
|
|
Addresses are asked about together, up to 200 in one request
|
|
(`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a time, which
|
|
keeps the number of requests to GeoJS low. Each answer's `country_code`, `asn`
|
|
and `organization_name` are read. An answer without a `country_code`, or with
|
|
the `asn` `64512`, counts as unknown.
|
|
- Each answer is kept for seven days, in memory and in `lookups.json`. Answers
|
|
are kept apart from the table of clients, so that an answer outlasts a client
|
|
dropped from that table. The answer reaches the client's history and ban notes
|
|
when it comes.
|
|
- `SWWAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
|
|
first answer, when a setting needs it (see step 5). It also bounds each
|
|
request to GeoJS. A client whose answer has not come in time counts as unknown
|
|
until it comes, and its later requests do not wait. Its address is asked about
|
|
again in the background.
|
|
- A client the lookup cannot place has an unknown country and AS number, which
|
|
the country lists and the biased thresholds treat as described there. Nothing
|
|
else treats it differently.
|
|
- 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. GeoJS
|
|
is then left alone for a second, and twice as long after each further failure,
|
|
up to five minutes. Each failure raises a `source_failure` alert (see
|
|
"Alerting"). A service that cannot accept this uses the file.
|
|
- The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`,
|
|
`as_name` and `country_code` fields are read. The operator downloads it with a
|
|
free IPinfo account and mounts it read-only; `smallwebwaf` never fetches it
|
|
and holds no account or token for it. IPinfo updates it daily. Its licence is
|
|
Creative Commons Attribution-ShareAlike 4.0, and IPinfo asks for a link to it
|
|
on the site that uses it, such as "IP address data is powered by IPinfo".
|
|
- The file is read whole at start; one that is missing or unreadable stops the
|
|
start. It is read again once it has gone 2 seconds without a change after
|
|
being replaced. A replacement that cannot be read is logged, sent as one
|
|
`file_error` alert and ignored. The database already loaded stays in use. A
|
|
client whose address the file does not hold counts as unknown.
|
|
|
|
### Country lists
|
|
|
|
Both are empty by default: the defaults judge a client by what it does, not by
|
|
where it comes from.
|
|
|
|
- `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 or
|
|
a visitor on a private address; 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 is logged
|
|
with the action `country_denied`, and counted in the metrics.
|
|
|
|
### Biased thresholds
|
|
|
|
The biased thresholds lower the limits of clients in listed AS numbers and
|
|
countries. 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.
|
|
|
|
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
|
|
Their clients get that percentage of every request and byte limit
|
|
(`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A lowered limit is
|
|
rounded down. `0` is a zero allowance: the client's first request breaks a
|
|
limit and bans it.
|
|
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` set the byte
|
|
limits' percentage alone, when it should differ from the request limits'. For
|
|
an AS number they list, they replace its percentage from
|
|
`SWWAF_ASN_LIMIT_PERCENT` and `SWWAF_ASN_LIMIT_PERCENT_URL`; for a country,
|
|
the one from `SWWAF_COUNTRY_LIMIT_PERCENT`.
|
|
- `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 kept like a blocklist (see "Reputation"). Where it and
|
|
`SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
|
|
- 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>`. A client the
|
|
lookup cannot place gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of an AS
|
|
number's or a country's. The percentage 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. The log line of each counted request,
|
|
and the notes of each ban, say which setting lowered the limit.
|
|
- 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.
|
|
|
|
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`. An engine error
|
|
on a request forwards the request, and is logged and counted.
|
|
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
|
|
Set's own two tuning values, at its own defaults. Each level up runs more
|
|
rules, which find more attacks and take more ordinary requests for attacks.
|
|
Each rule that matches adds its score to the request's, and a rule rated
|
|
critical adds 5. A request whose score reaches the threshold is a match, so by
|
|
default one critical rule is enough. With the threshold `off`, nothing is a
|
|
match, and scores are still logged. A request with more than 1000 query
|
|
parameters or body fields adds 5 to its score, since Coraza reads only the
|
|
first 1000. For a match, in `block` and `detect` mode alike, the log line
|
|
gives the rule ids and the score, and a `waf_block` alert is sent.
|
|
- `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, and the rest streams 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. A body the Core Rule Set cannot parse adds
|
|
5 to the score, and so does a multipart body cut by the limit in the middle of
|
|
a part's header. The client has until `SWWAF_CLIENT_REQUEST_TIMEOUT` runs out
|
|
to send the part that is read. 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. This is the short form of a `path`
|
|
rule with the `ban` action, for deployments that mount no rule files. A trap
|
|
path is matched whole, without the query, against the path as sent and once
|
|
percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
|
|
`/%77p-login.php`, and does not match `/wp-login.php/` or
|
|
`/blog/wp-login.php`.
|
|
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
|
|
kinds, and of no other:
|
|
- a rule file match;
|
|
- a trap path;
|
|
- a Core Rule Set match;
|
|
- 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.
|
|
|
|
`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. It reads these values as names in a repository
|
|
or in its own records, or as a page of its own site, never as a command. 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. 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.
|
|
|
|
The parameter and cookie names in these changes are matched without regard to
|
|
case, as Coraza matches them.
|
|
|
|
### Ban lengths, 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, has terms, below, that forbid frequent fetching, and
|
|
Spamhaus may block an address that breaks them. Each `smallwebwaf` fetches its
|
|
own copy, so several apps on one host, sharing one address, would often fetch
|
|
too close together.
|
|
|
|
- Blocklists (`SWWAF_BLOCKLIST_URLS`) are text files of addresses and netblocks,
|
|
one to a line, as the Spamhaus DROP list is written. Anything after a `;` or
|
|
`#` on a line is ignored. An IPv4-mapped address such as
|
|
`::ffff:192.0.2.0/120` is read as the IPv4 netblock it stands for. Spamhaus's
|
|
terms for DROP, 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.
|
|
|
|
- Each list is fetched again `SWWAF_BLOCKLIST_REFRESH` after its last try, one
|
|
list at a time. `reputation.json` keeps when each was last tried, so a restart
|
|
does not fetch a list again before its time. A fetch fails when:
|
|
- the server answers other than `200`;
|
|
- the fetch does not finish within a minute;
|
|
- the list is longer than 16 MiB;
|
|
- a line is not an address or netblock, or, for the AS number file, not
|
|
`AS<number>:<percent>`.
|
|
|
|
The log, the alerts, the ban notes and the metrics name each list by its
|
|
URL, so a list's URL must not hold a secret.
|
|
|
|
- `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 request and byte limit, and suits a list of addresses
|
|
shared with ordinary visitors, such as Tor exits. `log` only notes the hit in
|
|
the log line and raises an alert.
|
|
- A CrowdSec engine on the host (`SWWAF_CROWDSEC_LAPI_URL`) has its decision
|
|
list fetched every minute, with `SWWAF_CROWDSEC_LAPI_KEY` in the `X-Api-Key`
|
|
header. The list is kept like a fetched blocklist, and a client on it is
|
|
banned (see "Bans"). Only decisions of the type `ban` on an address or a
|
|
netblock are used; any other, such as a captcha or a country, is left out. A
|
|
decision lasts for the `duration` it had left when the engine answered. This
|
|
is how a fleet-wide blocklist can arrive without `smallwebwaf` depending on
|
|
CrowdSec. A fetch fails when:
|
|
- the engine answers other than `200`, a redirect included;
|
|
- the fetch does not finish within a minute;
|
|
- the answer is longer than 16 MiB;
|
|
- the answer is not a list of decisions.
|
|
- Each DNS blocklist (`SWWAF_DNSBL_ZONES`) is asked about a client's own address
|
|
in the background, the first time the client is seen. No request waits for the
|
|
answer. The name asked about is the one RFC 5782 gives: the numbers of an IPv4
|
|
address reversed, or the 32 hex digits of an IPv6 address reversed. So
|
|
`192.0.2.99` is asked about as `99.2.0.192.dnsbl.dronebl.org`. An answer in
|
|
`127.0.0.0/8` lists the client, but one in `127.255.255.0/24` is Spamhaus
|
|
refusing the query. A name that does not exist does not list the client. These
|
|
are failures, after which the zone is not asked again for a minute:
|
|
- an answer in `127.255.255.0/24`;
|
|
- an answer outside `127.0.0.0/8`;
|
|
- a resolver error;
|
|
- no answer within `SWWAF_REPUTATION_TIMEOUT`.
|
|
|
|
DNS blocklists made for mail servers 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 that contains the key.
|
|
`smallwebwaf` shows the key as `********` wherever it names the zone. Only
|
|
`reputation.json` keeps the key. `SWWAF_DNSBL_RESOLVER` names a resolver,
|
|
since public resolvers are refused by several list operators.
|
|
|
|
- AbuseIPDB (`SWWAF_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the
|
|
abuse confidence score of a client, from 0 to 100. Only a client that has
|
|
already committed one offence is checked, so the daily budget
|
|
(`SWWAF_ABUSEIPDB_DAILY_BUDGET`, under the free tier's 1000 checks a day) is
|
|
spent on suspects. The check runs in the background at the client's next
|
|
request that no ban refuses, so no request waits; the score applies to the
|
|
client's later requests. For an IPv6 client, AbuseIPDB is asked about the
|
|
address of the request that set off the check, and the score is used for the
|
|
whole group. A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE` is a hit. The
|
|
budget is counted in UTC days and kept in `reputation.json`, so a restart does
|
|
not make it whole again. The check that spends the last of it is logged and
|
|
alerted, and no client is checked until the day ends. The key is sent in the
|
|
`Key` header and nowhere else, and is never logged. A check fails, after which
|
|
no client is checked for a minute, when:
|
|
- AbuseIPDB answers other than `200`;
|
|
- the answer gives no score;
|
|
- no answer comes within `SWWAF_REPUTATION_TIMEOUT`.
|
|
- `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 request and byte limit rather than a refusal.
|
|
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and
|
|
in `reputation.json`. 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, and to Slack and ntfy as formatted messages.
|
|
`docs/alerts.md` lists the events, the JSON object and the messages.
|
|
`SWWAF_ALERT_EVENTS` chooses which of the seven event types are sent; the
|
|
hourly `summary` is sent whatever it says. The bans an admin makes raise no
|
|
alert. In `observe` mode, a request that would have made a ban still raises
|
|
the ban's alert, with `mode` set to `observe` in its `detail`.
|
|
- A repeat is an alert with the same event, and the same netblock, source, file
|
|
or anomaly scope, as one sent less than `SWWAF_ALERT_COOLDOWN` before. An
|
|
anomaly scope is one of the things counted under "Anomaly thresholds". A
|
|
repeat is held back and counted, and the next such alert sent gives the count.
|
|
- Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and
|
|
counted by event, so a wide attack cannot flood the channel. Once the hour
|
|
ends, one alert with the event `summary` gives those counts. It also counts
|
|
the repeats that a cooldown held back and that no later alert reported.
|
|
- Each destination has a bounded queue (see "Fixed limits" in
|
|
`docs/settings.md`), sent one at a time, so a slow destination holds up
|
|
nothing. When a queue is full, its oldest alert is dropped and counted in the
|
|
metrics. A `2xx` answer takes an alert. A `4xx` other than `408` and `429`
|
|
refuses it, and it is dropped. Anything else is a failure: the alert is sent
|
|
again a second later, and twice as long after each further failure, up to a
|
|
minute. `alerts.json` keeps the queues and cooldowns across a restart.
|
|
|
|
### 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 reveal 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 of:
|
|
- a client;
|
|
- the surrounding netblock;
|
|
- an AS number;
|
|
- the whole service;
|
|
- a named netblock in `SWWAF_WATCH_NETS`.
|
|
- Every request but the health check is counted, whatever was done with it,
|
|
clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` included,
|
|
since an exempt client misbehaving is worth knowing about. The 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. `docs/rules.md` gives the format, the targets and actions,
|
|
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. It reads the files again once the directory has had no change
|
|
for 2 seconds, so that a file still being written is read whole. A file whose
|
|
name starts with `.`, such as an editor's lock file, is not a rule file.
|
|
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
|
|
rules in line order.
|
|
- 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. A header sent more than once is matched with
|
|
its values joined by `, `; a header not sent is empty text. `Host` and
|
|
`Transfer-Encoding` cannot be targets, since Go's HTTP server takes them out
|
|
of every request; a rule naming one stops the start like any bad line. Request
|
|
bodies are not available to rule files; reading them is the Core Rule Set's
|
|
job.
|
|
- `log` only notes the match in the request log. `block` refuses the request;
|
|
`ban` makes the request a clear sign of attack (see "Bans"), and is for
|
|
requests no real visitor sends. A visitor banned by mistake is let back in by
|
|
lifting the ban.
|
|
- At start, these stop the process with a message naming the file and line:
|
|
- a line that does not parse;
|
|
- a regex that does not compile;
|
|
- a duplicate id.
|
|
|
|
So does a `SWWAF_RULES_DIR` that does not exist or cannot be read. While
|
|
running, the same faults leave the rules as they were, and the log and one
|
|
`file_error` alert name the file and line. Once the file is fixed, it is
|
|
read again. An empty directory is not an error: the log says that no rules
|
|
were loaded.
|
|
|
|
- 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. In front
|
|
of gitea, `/.env` is a probe, while
|
|
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
|
|
any visitor may open. `ban` rules of your own follow the same rule. Anything
|
|
app-specific, such as a rule banning WordPress probes in front of gitea,
|
|
belongs in a file the app's Dockerfile adds or the deployer mounts. A
|
|
directory mounted over `SWWAF_RULES_DIR` replaces the default file; single
|
|
files mounted into it add to it.
|
|
- 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. To run without rule
|
|
files, mount an empty directory or set `SWWAF_RULES_ENABLED=false`.
|
|
|
|
## 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 remote log is the exception; see
|
|
"Request log"). 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, and their sizes.
|
|
|
|
- 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`, `reputation.json` and `alerts.json` are indented, so that a
|
|
person can open `bans.json` 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 has a bound, so it can be rewritten whole. Writing one 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, and the directory is synced. 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, the files are read back. Active bans go into an in-memory lookup by
|
|
netblock, everything else into maps. Expired cache entries are discarded. A
|
|
missing file means empty state, and is normal on first run. These stop the
|
|
process with a message naming the file and position:
|
|
- a file that does not parse, or has an unknown `version`;
|
|
- an entry without a field it needs;
|
|
- a ban whose cause is not one of the four;
|
|
- a ban whose `expires` is not after its `start`.
|
|
|
|
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.
|
|
|
|
- While running, `smallwebwaf` notices a file that is edited, replaced or added.
|
|
It tells its own writes from an admin's by comparing the file with what it
|
|
last read or wrote. An edit that parses is taken in at once: what the file
|
|
says replaces what `smallwebwaf` held for that file. An edit to `bans.json`
|
|
makes, lifts or forgets bans as "Bans" describes. A change `smallwebwaf` made
|
|
after the admin opened the file, such as a new ban, is lost when the admin
|
|
saves over it. Most editors warn when a file changed on disk while it was
|
|
open. A file that is removed is written again at its next write.
|
|
- 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`, so the edit
|
|
is kept. 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. The admin
|
|
mends the `.bad` file and moves it back.
|
|
- 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: ban changes from the last
|
|
`SWWAF_STATE_WRITE_DELAY`, everything else from the last
|
|
`SWWAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not
|
|
service.
|
|
|
|
## Request log
|
|
|
|
Every request `smallwebwaf` sees gets one JSON object on one line on stdout,
|
|
refused ones included. `docs/request-log.md` lists the fields. Bodies are never
|
|
logged, and no header but those listed there. 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 (start, the settings, stop, errors) 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.
|
|
|
|
- Over UDP each record is one datagram; a line too long for one datagram is
|
|
dropped alone. Over TCP and TLS each record follows its length in bytes and a
|
|
space. UDP gives no delivery signal at all and is offered only for
|
|
compatibility.
|
|
- The record carries the facility from `SWWAF_LOG_REMOTE_FACILITY`, the severity
|
|
informational, the time, the host's name and the app name from
|
|
`SWWAF_LOG_REMOTE_APP_NAME`.
|
|
- Sending happens on its own goroutine from a buffer of
|
|
`SWWAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never holds
|
|
up a request and never stops stdout. When the buffer is full, the oldest line
|
|
is dropped and counted in the metrics. A failed connection is tried again a
|
|
second later, and twice as long after each further failure, up to a minute.
|
|
- As `smallwebwaf` stops, it spends at most two seconds sending the lines still
|
|
waiting, and drops the rest; stdout has carried them.
|
|
|
|
## 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 the metrics collector reach
|
|
these endpoints through traefik, like any other request.
|
|
`docs/admin-endpoints.md` lists the endpoints, what each takes 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.
|
|
- 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` (see "Attack detection" for what guessing
|
|
costs).
|
|
- A ban made through the endpoints starts at once.
|
|
|
|
## Deployment
|
|
|
|
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
|
|
`FROM` the `smallwebwaf` image. Each app is one container. An app is hardened by
|
|
changing its base image. It can add packages from nixpkgs. `docs/image.md` lists
|
|
what the image holds and how each part is pinned.
|
|
|
|
- The image is Ubuntu's newest long-term support release, pinned by digest, and
|
|
moves to the next LTS release when that ships. Its packages come from Ubuntu's
|
|
snapshot service, the archive as it was at the moment the Dockerfile names,
|
|
rather than the live archive, whose packages change with every update. The
|
|
snapshot is never older than the pinned Ubuntu image, since older packages can
|
|
need older versions of packages the image holds, and it moves forward with the
|
|
image's digest. The Dockerfile names the hash of each of the snapshot's
|
|
`InRelease` files, which cover every package list and so every package, and
|
|
the build checks them before installing. The live archive's `InRelease` files,
|
|
which `apt-get update` also fetches and the install does not use, are left out
|
|
of the check. The hashes are the amd64 archive's, so the image is built for
|
|
amd64 only.
|
|
- The Ubuntu image has no CA certificates. Without them Nix cannot download
|
|
packages and `smallwebwaf` cannot reach GeoJS, so `ca-certificates` is
|
|
installed by name. The snapshot itself is fetched over HTTPS with the CA
|
|
certificates of the Go image that builds `smallwebwaf`, itself pinned by
|
|
digest.
|
|
- nixpkgs comes from a release file on `releases.nixos.org`, named with its
|
|
hash, not from a GitHub archive, whose bytes can change. Root uses Nix
|
|
directly; no Nix daemon runs, and since `nix-bin` creates no build users,
|
|
`build-users-group =` in `/etc/nix/nix.conf` lets root build without them.
|
|
Root's Nix profile is last on the `PATH`, so that no package hides the image's
|
|
own commands; busybox's `sv`, for one, looks for services elsewhere. An app
|
|
built on the image gets the same packages each time. Security fixes from
|
|
Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app takes
|
|
by changing the digest in its `FROM` line. The image moves to each new nixpkgs
|
|
release, every six months.
|
|
- runit supervises the two processes, with `runsvinit` as the entrypoint, as the
|
|
owner's code style guide asks for service containers. No package carries
|
|
`runsvinit`, so the image builds it from its archived source, unchanged since
|
|
2015, at a fixed commit hash, with a `go.mod` the build writes. `runsvinit`
|
|
starts runit's `runsvdir`, which runs the `run` script of each directory under
|
|
`/etc/service`. Ubuntu's `sv` looks for services there too.
|
|
- The image's `run` script for `smallwebwaf` makes `SWWAF_STATE_DIR` and every
|
|
file in it belong to the `smallwebwaf` user (uid and gid 65532). Then it
|
|
starts `smallwebwaf` as that user with runit's `chpst`.
|
|
|
|
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. Each waits one second (`sleep 1`) before starting its program, so a
|
|
process that stops at start is started again every second, not in a tight loop.
|
|
|
|
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 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 runs its `run` script again. 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 faults found at start stop `smallwebwaf`:
|
|
- 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 `SWWAF_RULES_DIR` that does not exist or cannot be read;
|
|
- a `SWWAF_LISTEN_ADDR` it cannot listen on.
|
|
- A `smallwebwaf` that stops at start is therefore 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`, a directory of its own
|
|
beside the app's data directories, belonging to the `smallwebwaf` user. 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. A host directory mounted there needs no
|
|
change of owner, and files an earlier owner left in it can be read and replaced.
|
|
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.
|
|
"Settings given as files" in `docs/settings.md` shows the commands.
|
|
|
|
### 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
|
|
|
|
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".
|