check / check (push) Waiting to run
Every requirement is stated once, where it belongs, and linked from elsewhere. The notes on gitea move to docs/gitea.md, which script/gitea-requests points at. The list of settings that take off is exact, the non-goal that contradicted the planned item is gone, and the sentences the review named are split or reordered. The package comments in internal/waf and internal/anomaly point at the renamed sections. Model: fable-5-1
1395 lines
74 KiB
Markdown
1395 lines
74 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. `docs/gitea.md` records
|
|
how it behaves in front of gitea.
|
|
|
|
## 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, and lower its limits for listed AS numbers and countries. It
|
|
consults reputation sources. It sends 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 ships in an image built on Ubuntu 26.04
|
|
LTS with nixpkgs installed. That image is the base of the app's own image, so
|
|
`smallwebwaf` and the app run in one container (see "Deployment"). runit
|
|
starts `smallwebwaf` as a non-root user of its own. `smallwebwaf` writes only
|
|
to its state directory.
|
|
- One listener, on port 8080, which traefik routes to. It forwards requests to
|
|
the app on `127.0.0.1:8081`. It answers its own endpoints under
|
|
`/_smallwebwaf/` (see "Admin endpoints"). Those never reach the app.
|
|
- No database of any kind. Nothing is read from disk while serving a request.
|
|
- The proxy is the standard library's `httputil.ReverseProxy`. The other parts
|
|
of the process are each specified below.
|
|
- 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 the Core Rule
|
|
Set, and the default `SWWAF_WAF_DISABLED_RULES`, are built for that
|
|
version. They are checked again before the version changes;
|
|
- `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;
|
|
- 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. The first address outside those netblocks is the
|
|
client. If every address is inside them, the leftmost is the client, 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 every check below. It
|
|
is still counted for the anomaly thresholds. A client in `SWWAF_DENY_NETS` is
|
|
refused.
|
|
4. The ban ledger. 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`.
|
|
6. The country lists (see "Country lists"). A refused request is not an offence.
|
|
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. 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 `SWWAF_TRAP_PATHS`). 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"). In `block` mode a match is
|
|
refused with `403`. In `detect` mode it is only logged and alerted.
|
|
13. A request under `/_smallwebwaf/` is answered here and goes no further.
|
|
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
|
|
`SWWAF_ERROR_BURST_THRESHOLD`).
|
|
- 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. It 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 the anomaly counters have fixed
|
|
bounds, given where each is described.
|
|
|
|
## Bans
|
|
|
|
A ban refuses every request from a netblock with `SWWAF_BAN_RESPONSE` until it
|
|
ends. 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.
|
|
|
|
Some refusals make no ban at all. `SWWAF_DENY_NETS`, a blocklist or reputation
|
|
hit whose action is `deny`, and the country lists refuse each request they cover
|
|
and record nothing in `bans.json`.
|
|
|
|
Every ban carries notes with what an admin needs to decide whether to lift it
|
|
(see "Persistent state"). An admin can add or lift a ban at any time by editing
|
|
`bans.json`, or through the ban endpoints when `SWWAF_ADMIN_TOKEN` is set. 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 the
|
|
table of bans is full. When `smallwebwaf` holds `SWWAF_MAX_BANS` bans of its
|
|
own, it drops the one that has gone longest without a request, whether past,
|
|
active or permanent. 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:
|
|
- 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.
|
|
|
|
The settings, by group:
|
|
|
|
### Core
|
|
|
|
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app.
|
|
- `SWWAF_LISTEN_ADDR` (default `:8080`): the one listener, for the requests
|
|
`smallwebwaf` forwards and for its own endpoints. The address part stays empty
|
|
(see "Ports").
|
|
- `SWWAF_INSTANCE_NAME` (default: the container's host name, which docker sets
|
|
to the first 12 characters of the container's id): included in every log line,
|
|
metric and alert. Set it to a name that survives a redeploy and tells
|
|
instances apart, such as `fsn1app1/gitea`.
|
|
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to log and alert on
|
|
every decision while refusing nothing.
|
|
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`,
|
|
the private address ranges): the netblocks whose `X-Forwarded-For` is
|
|
believed, normally where traefik reaches `smallwebwaf` from. A client that
|
|
connects directly cannot claim another address. A list given replaces the
|
|
default; set but empty, it trusts nothing.
|
|
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`), from 32 to 128.
|
|
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the clients held in memory and
|
|
in `clients.json`.
|
|
- `SWWAF_MAX_BANS` (default `5000`): the bans `smallwebwaf` made that are held,
|
|
past, active and permanent (see "Bans").
|
|
|
|
### Tokens
|
|
|
|
- `SWWAF_ADMIN_TOKEN`: the bearer token for the ban endpoints and
|
|
`/_smallwebwaf/clients/<ip>`. Unset by default, which switches them off; bans
|
|
are then managed by editing `bans.json`.
|
|
- `SWWAF_METRICS_TOKEN`: the bearer token a scraper sends for
|
|
`/_smallwebwaf/metrics`. Unset by default, which switches the metrics off,
|
|
since they would otherwise be open to anyone on the internet. It is separate
|
|
from `SWWAF_ADMIN_TOKEN`, so a scraper that holds it 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 given
|
|
as one. The file form keeps a token out of the app's reach (see "Tokens" under
|
|
"Deployment").
|
|
|
|
### State files
|
|
|
|
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the JSON state files, in a
|
|
directory of their own beside the app's data, belonging to the `smallwebwaf`
|
|
user.
|
|
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): `bans.json` is written this long
|
|
after a ban is made, lifted or made permanent, so a burst of changes becomes
|
|
one write.
|
|
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
|
written.
|
|
|
|
### Logging
|
|
|
|
- The request log on stdout is always on and has no switch.
|
|
- `SWWAF_LOG_LEVEL` (default `info`): for the process's own messages, which are
|
|
JSON lines on stdout too, marked `"type":"process"`. It does not filter the
|
|
request log.
|
|
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
|
request headers to record. `Authorization`, `Cookie` and `Set-Cookie` values
|
|
are never logged, only whether they were present.
|
|
- `SWWAF_LOG_REMOTE_URL`: when set, every log line is also sent there, as
|
|
`syslog+udp://host:514`, `syslog+tcp://host:514` or `syslog+tls://host:6514`.
|
|
- `SWWAF_LOG_REMOTE_TLS_CA_FILE`: an optional CA certificate file for the `+tls`
|
|
form.
|
|
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the lines held in memory while
|
|
the server is unreachable. When full, the oldest are dropped and counted.
|
|
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`) and `SWWAF_LOG_REMOTE_APP_NAME`
|
|
(default `SWWAF_INSTANCE_NAME`): the syslog header fields.
|
|
|
|
### Metrics
|
|
|
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and countries get
|
|
series of their own. The rest are summed as `other`.
|
|
|
|
### Static lists
|
|
|
|
- `SWWAF_ALLOW_NETS`: bypass everything (monitoring, the owner's own networks).
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: bypass the request and byte limits only. The
|
|
error burst, attack detection and bans still apply.
|
|
- `SWWAF_DENY_NETS`: always refused.
|
|
|
|
### 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_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
|
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`).
|
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS`: path prefixes not counted, 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_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
|
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`).
|
|
- `SWWAF_BYTES_COUNT` (default `both`): `response`, `request` or `both`.
|
|
|
|
### 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, since
|
|
`smallwebwaf` passes bodies through unchanged. Bodies stream straight through,
|
|
so a request body reaches the app while the client is still sending it.
|
|
|
|
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
|
|
send its request line and headers, and then, from the end of the headers, its
|
|
body.
|
|
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
|
line and headers. It must be more than `4K`. Go's HTTP server always has such
|
|
a limit, and reads 4 KiB past the one it is given before it refuses.
|
|
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
|
may wait for its next request before `smallwebwaf` closes it. It is longer
|
|
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.
|
|
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long one response may
|
|
take to reach the client, from the end of the request to the last byte.
|
|
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
|
|
app and sending it the whole request may take.
|
|
- `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take
|
|
to send one whole response, from the end of the request to the last byte.
|
|
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
|
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
|
|
|
|
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`. A client too slow to send them gets
|
|
either no answer or an error answer, mostly `400`. 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.
|
|
- When a limit is passed after the headers have been read, but before the
|
|
response has started, `smallwebwaf` answers itself:
|
|
- `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. It answers `504` if
|
|
it was waiting for the app to take what it had.
|
|
- 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. The file is the alternative for a service that keeps
|
|
its visitors' addresses on its own host.
|
|
|
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. One source
|
|
at a time: `file` without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH`
|
|
with any other source, stops the start with a message naming both. So does a
|
|
setting that needs lookups while the source is `off`: the country lists, the
|
|
biased thresholds, `SWWAF_ADD_LOOKUP_HEADERS`, and the anomaly thresholds per
|
|
AS number.
|
|
- `SWWAF_LOOKUP_DB_PATH`: the file for `file`: the IPinfo Lite database in its
|
|
`.mmdb` form (`ipinfo_lite.mmdb`), whose `asn`, `as_name` and `country_code`
|
|
fields are read. The operator downloads it with a free IPinfo account, mounts
|
|
it read-only, and refreshes it; IPinfo updates it daily. `smallwebwaf` never
|
|
fetches it and holds no account or token for it. A file that is missing or
|
|
unreadable at start stops the start. `smallwebwaf` notices when the file is
|
|
replaced and reads it again. A replacement it cannot read is ignored, logged
|
|
and sent as one `file_error` alert, and the file it has stays in use. The
|
|
directory that holds the file is mounted, not the file itself (see "Rule
|
|
files" for why).
|
|
- `geojs` asks the free GeoJS web service about up to 200 addresses in one
|
|
request (`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a
|
|
time, and reads each answer's `country_code`, `asn` and `organization_name`.
|
|
An answer without a `country_code`, or with the `asn` `64512`, counts as
|
|
unknown. 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.
|
|
- 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.
|
|
- Each GeoJS answer is kept for 7 days, in memory and in `lookups.json`, apart
|
|
from the table of clients. Up to 100,000 answers are kept, about 15 MiB; past
|
|
that, the answer used longest ago goes first.
|
|
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a 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 uses the file.
|
|
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): pass `X-Client-ASN` and
|
|
`X-Client-Country` to the app.
|
|
|
|
### 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`: for example `cn,ru,kp,ir,ua,by`. Every request from
|
|
a listed country is refused.
|
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`: for example `us,de`. Only clients
|
|
placed in a listed country get through. Every request from any other country
|
|
is refused. So is every request from a client the lookup cannot place, such as
|
|
a visitor on the local network, another container or 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. That is the nearest a failing helper comes to taking the service
|
|
down: GeoJS failing while this list is set refuses every new client.
|
|
- 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. It is a
|
|
refusal, not a ban.
|
|
|
|
### 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.
|
|
|
|
- `SWWAF_ASN_LIMIT_PERCENT`: for example `AS14061:50,AS16276:50,AS45102:25`.
|
|
Clients in a listed AS number get that percentage of every request and byte
|
|
limit, so the rules in "Bans" ban them after fewer requests than others. `0`
|
|
is a zero allowance: the client's first request breaks a limit and bans it.
|
|
- `SWWAF_COUNTRY_LIMIT_PERCENT`: the same form with country codes, for example
|
|
`CN:25,RU:50`.
|
|
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT`: optional
|
|
overrides applied to the byte limits only, when the byte percentage should
|
|
differ from the request percentage.
|
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): for clients the lookup cannot
|
|
place.
|
|
- `SWWAF_ASN_LIMIT_PERCENT_URL`: optional URL of 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: issue and comment text,
|
|
wiki pages, files saved in the web editor, package descriptions. When these hold
|
|
shell commands or code, the Core Rule Set takes 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.
|
|
|
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of rule
|
|
files (see "Rule files").
|
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` skips rule files entirely.
|
|
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` (log and alert only), or
|
|
`block`.
|
|
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`) and `SWWAF_WAF_ANOMALY_THRESHOLD`
|
|
(default `5`): the Core Rule Set's own two tuning values, at its own defaults.
|
|
- `SWWAF_WAF_DISABLED_RULES` (default
|
|
`920340,920420,920440,920640,930130,930140`): rule ids to switch off 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 given replaces the
|
|
default, so include them in it.
|
|
|
|
- `SWWAF_WAF_EXEMPT_PATHS`: path prefixes not inspected.
|
|
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off` reads no request body. A size,
|
|
such as `128K`, switches body inspection on. The Core Rule Set then reads form
|
|
data and multipart bodies up to that size, streaming the rest of a longer one
|
|
on without holding it in memory. It reads JSON and XML bodies 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. Responses are never inspected. Body
|
|
inspection suits apps whose forms carry no code. In front of gitea it refuses
|
|
the requests below, until their rule ids, named in the request log, are added
|
|
to `SWWAF_WAF_DISABLED_RULES`:
|
|
- issue and comment text, wiki pages and files saved in the web editor that
|
|
hold shell commands or code (932125, 932235, 932250 and others);
|
|
- package descriptions that show code;
|
|
- PyPI uploads (922130);
|
|
- attachments named like `debug.log` or `config.yml` (932180).
|
|
- `SWWAF_TRAP_PATHS`: paths the app never serves and only scanners ask for, for
|
|
example `/wp-login.php,/xmlrpc.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` (default `30`): 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 are answered `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:
|
|
|
|
- `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.
|
|
- 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.
|
|
- 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.
|
|
- 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.
|
|
|
|
- The Core Rule Set reads the request without the `gitea_flash` and
|
|
`redirect_to` cookies. It does not check `Referer` for a Unix command given
|
|
without arguments (932340) or for Java starting a process (944110). 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. `Referer` names the page a request comes from, on everything
|
|
that page loads. 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.`. gitea shows the message with any script removed and
|
|
returns only to its own pages. Every other cookie is read in full, and
|
|
`Referer` keeps the other rules.
|
|
- 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 fourth and fifth changes leave an app on its own in those places (see "Risks
|
|
the design has to handle").
|
|
|
|
### Ban lengths and response
|
|
|
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
|
attack.
|
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
|
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): breaking a limit again within
|
|
this time after a ban for a broken limit ended makes the next ban three times
|
|
as long.
|
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): a ban that would be longer is
|
|
permanent instead.
|
|
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429`, or `close` to drop the
|
|
connection without an answer. `403` makes a ban easy to recognise when
|
|
debugging. 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` (default `32`): widen to for example `24` to ban
|
|
the surrounding netblock.
|
|
|
|
### Reputation
|
|
|
|
No source is on by default, for the reason the country lists and the biased
|
|
thresholds are empty. 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. Besides, the list best suited to be on by
|
|
default, Spamhaus DROP, must not be downloaded automatically more than once an
|
|
hour. Spamhaus may block an address that downloads it too often. Each
|
|
`smallwebwaf` fetches its own copy. On a host that runs several apps with it,
|
|
which share one address, downloads would often come less than an hour apart.
|
|
|
|
- `SWWAF_BLOCKLIST_URLS`: text files of addresses and netblocks, one per line;
|
|
anything after a `;` or `#` on a line is ignored. For example the Spamhaus
|
|
DROP list, `https://www.spamhaus.org/drop/drop.txt`. 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.
|
|
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`, never less than `1h`): how often
|
|
each list is fetched again. The last good copy is kept whole, comment lines
|
|
included, on failure and across restarts.
|
|
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
|
`deny` refuses every request from a listed address, which suits lists of
|
|
networks that send nothing legitimate, such as DROP. `limit:<percent>` gives
|
|
listed clients that percentage of every limit, so they are banned after fewer
|
|
requests. It suits a list of addresses shared with ordinary visitors, such as
|
|
Tor exits.
|
|
- `SWWAF_DNSBL_ZONES`: for example `dnsbl.dronebl.org`. Zones meant for mail
|
|
(lists of residential ranges) will block ordinary visitors and should not be
|
|
used. Spamhaus zones need their keyed query service, given as a zone name
|
|
containing the key.
|
|
- `SWWAF_DNSBL_RESOLVER`: an optional resolver address, since public resolvers
|
|
are refused by several list operators.
|
|
- `SWWAF_ABUSEIPDB_KEY`, `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`) and
|
|
`SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`; the free tier allows 1000
|
|
checks a day). Only clients that have already committed one offence are
|
|
queried, so the budget is spent on suspects.
|
|
- `SWWAF_CROWDSEC_LAPI_URL` and `SWWAF_CROWDSEC_LAPI_KEY`: optional. If a
|
|
CrowdSec engine exists on the host, its decision list is pulled on a schedule
|
|
and treated as a blocklist (see "Bans" for the ban it makes). This is how a
|
|
fleet-wide blocklist can arrive without `smallwebwaf` depending on CrowdSec.
|
|
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
|
`log`, for DNS blocklist and AbuseIPDB hits. Such verdicts are less certain
|
|
than a blocklist, so by default a listed client gets a quarter of every limit
|
|
and is banned after a quarter of the requests.
|
|
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`) and `SWWAF_REPUTATION_TIMEOUT`
|
|
(default `2s`).
|
|
- A source that is down or over quota gives no verdict. The service continues,
|
|
with one `source_failure` alert per cooldown.
|
|
|
|
### Alerting
|
|
|
|
- `SWWAF_ALERT_WEBHOOK_URL`: a JSON POST; schema under "Alert webhook schema".
|
|
`SWWAF_ALERT_WEBHOOK_HEADERS`: optional `Name:value` pairs for authentication.
|
|
- `SWWAF_ALERT_SLACK_WEBHOOK_URL`: a Slack incoming webhook, sent a formatted
|
|
message.
|
|
- `SWWAF_ALERT_NTFY_URL` (the full topic URL) and `SWWAF_ALERT_NTFY_TOKEN`:
|
|
title, priority and tags set from the event type.
|
|
- `SWWAF_ALERT_EVENTS` (default
|
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
|
which event types are sent. `source_failure` is a reputation source or GeoJS
|
|
failing or refusing `smallwebwaf`. `file_error` is one of:
|
|
- a rule file or state file edited while running that does not parse;
|
|
- a replacement lookup database that cannot be read;
|
|
- a state file that cannot be written.
|
|
- `SWWAF_ALERT_COOLDOWN` (default `15m`): the same event for the same netblock,
|
|
source, file or anomaly scope is not repeated within this time. A count of
|
|
suppressed repeats is included in the next one.
|
|
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): 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.
|
|
|
|
- Per client: `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`,
|
|
`SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`,
|
|
`SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` and
|
|
`SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
|
- Per surrounding netblock (`SWWAF_ANOMALY_NET_V4_PREFIX` default `24`,
|
|
`SWWAF_ANOMALY_NET_V6_PREFIX` default `48`):
|
|
`SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
|
|
`SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` and `..._PER_HOUR`.
|
|
- Per AS number, which needs a lookup source:
|
|
`SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
|
|
`SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` and `..._PER_HOUR`.
|
|
- The whole service: `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
|
|
`SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` and `..._PER_HOUR`.
|
|
- Named netblocks: `SWWAF_WATCH_NETS`, for example
|
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`, with
|
|
`SWWAF_WATCH_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
|
|
`SWWAF_WATCH_BYTES_PER_MINUTE` and `..._PER_HOUR` applied to each named
|
|
netblock as a whole.
|
|
- 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. The files are plain text meant to be edited by hand. A new
|
|
scanning pattern seen in the request log can be turned into a rule in one line.
|
|
`smallwebwaf` watches the directory: a file edited, added or removed there takes
|
|
effect while it runs.
|
|
|
|
- One rule per line, four fields separated by spaces or tabs. The fourth field
|
|
runs to the end of the line:
|
|
|
|
```
|
|
<id> <target> <action> <regex>
|
|
```
|
|
|
|
- Blank lines and lines starting with `#` are ignored.
|
|
- `id`: a short name of letters, digits, `-` and `_`, unique across all files.
|
|
It appears in the request log, the metrics, the alerts and the ban notes.
|
|
- `target`: what the regex is matched against.
|
|
- `path`: the URL path without the query, both as received and once
|
|
percent-decoded, so an encoded probe cannot slip past.
|
|
- `query`: the raw query string.
|
|
- `uri`: the path and query together, both as received and once
|
|
percent-decoded.
|
|
- `method`, `host`, `user_agent`, `referer`.
|
|
- `header:<Name>`: any one request header but `Host` and
|
|
`Transfer-Encoding`, which Go's HTTP server takes out of every request.
|
|
- Request bodies are not available to rule files. Reading them is the Core
|
|
Rule Set's job, once `SWWAF_WAF_BODY_LIMIT` switches it on.
|
|
- `action`:
|
|
- `log`: note the match in the request log and do nothing else.
|
|
- `block`: refuse the request with `403`. The client is not banned for it,
|
|
but the refusal counts toward the error burst.
|
|
- `ban`: the request is a clear sign of attack (see "Bans"). Use it only for
|
|
requests no real visitor sends. Anchor a path 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.
|
|
- `regex`: Go regular expression syntax (RE2). 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. `(?i)` at the front makes a rule ignore case. A
|
|
rule matches if the regex matches anywhere in the target; anchor with `^` and
|
|
`$` when that is not wanted.
|
|
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
|
|
rules in line order.
|
|
- Rules are compiled when their file is read and held in memory. At start, a
|
|
line 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. Once the file is fixed, it is read again. An empty directory is not
|
|
an error. To run without rule files, mount an empty directory or set
|
|
`SWWAF_RULES_ENABLED=false`.
|
|
- The image ships a default file in `SWWAF_RULES_DIR`. The app's Dockerfile can
|
|
copy files of its own into it, beside the default. Mounting a directory over
|
|
it replaces the defaults; mounting single files into it adds to them. Many
|
|
editors save a file by replacing it, and a container keeps seeing the old one
|
|
when the file alone is mounted. So rules meant to be edited while
|
|
`smallwebwaf` runs belong in a mounted directory, with a copy of the default
|
|
file if the defaults are to stay. The lookup database is mounted the same way,
|
|
for the same reason.
|
|
- Clients in `SWWAF_ALLOW_NETS` are not checked.
|
|
|
|
The default file the image ships:
|
|
|
|
```
|
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
|
|
|
# id target action regex
|
|
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
|
vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$)
|
|
secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/
|
|
secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
|
|
editor-dir path ban (?i)^/\.(vscode|idea)/
|
|
backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
|
|
log-file path ban (?i)^/(debug|error|access)\.log$
|
|
compose-file path ban (?i)^/(docker-)?compose\.ya?ml$
|
|
php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$
|
|
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
|
|
path-traversal uri block (\.\./){2,}
|
|
empty-agent user_agent log ^$
|
|
```
|
|
|
|
It is 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. Anything app-specific belongs in a file the app's
|
|
Dockerfile adds or the deployer mounts. A request for `/wp-login.php`, for
|
|
example, is a clear sign of attack in front of gitea and an ordinary login in
|
|
front of WordPress:
|
|
|
|
```
|
|
# 50-gitea.rules: WordPress probes, which gitea never serves
|
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
|
```
|
|
|
|
## 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.
|
|
|
|
The files, each holding one kind of state:
|
|
|
|
- `bans.json`: active and past bans, up to `SWWAF_MAX_BANS` that `smallwebwaf`
|
|
made and every ban an admin made. Per entry:
|
|
- the netblock, start and expiry (`null` for permanent);
|
|
- the cause: `attack`, `limit`, `admin` or `crowdsec`;
|
|
- a short reason;
|
|
- when it was lifted, if an admin lifted it;
|
|
- the notes below.
|
|
- `clients.json`: per client, its minute, hour and day counters and its history
|
|
since it was first seen. The history holds:
|
|
- the first and last time seen;
|
|
- the AS number, AS name and country last looked up, and when;
|
|
- total requests, and how many were forwarded and how many refused;
|
|
- bytes in each direction;
|
|
- responses by status class;
|
|
- offences by kind.
|
|
|
|
Clients that were never banned are kept too, so every client's history
|
|
survives a restart.
|
|
|
|
- `lookups.json`: GeoJS answers, one per client: the AS number, AS name and
|
|
country, when GeoJS was asked and when the answer was last used.
|
|
- `reputation.json`: the last good copy of each list fetched from a URL; the
|
|
cached DNS blocklist verdicts and AbuseIPDB scores, each with the time it was
|
|
fetched; and the AbuseIPDB checks spent today, so that a restart does not
|
|
reset the daily budget.
|
|
- `alerts.json`:
|
|
- the anomaly counters;
|
|
- for each event and netblock, source, file or anomaly scope, when it was
|
|
last sent and how many repeats the cooldown has held back since;
|
|
- the alerts sent this hour;
|
|
- the alerts still waiting to be sent.
|
|
|
|
Ban notes hold what an admin needs to decide whether to lift a ban, drawn from
|
|
what `smallwebwaf` already knows. Nothing is looked up to fill them:
|
|
|
|
- the AS number, AS name and country, added when the lookup answers (empty when
|
|
lookups are off);
|
|
- what was broken: the rule id and target that matched, or the trap path, or the
|
|
limit, its window and the count reached;
|
|
- the client's limit percentage, with the setting that gave it;
|
|
- the reputation sources that listed the client;
|
|
- the request that caused the ban: time, method, host, path with its query
|
|
string, status and user agent, each text cut to 256 bytes;
|
|
- the netblock's total requests since it was first seen, and the requests
|
|
refused under this ban so far, kept up to date while the ban lasts;
|
|
- how many earlier bans of each kind the netblock has had.
|
|
|
|
Each file is rewritten whole, so each has a bound. `clients.json` takes about 1
|
|
KiB per client, about 20 MiB at the default `SWWAF_MAX_TRACKED_CLIENTS`, which
|
|
written every 15 minutes is under 2 GiB of disk writes a day. `bans.json` takes
|
|
about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at the default
|
|
`SWWAF_MAX_BANS`. With a full file, a hundred new bans a day come to about 600
|
|
MiB of writes. `lookups.json` takes about 150 bytes per answer, about 15 MiB
|
|
when full, under 1.5 GiB of writes a day. The other two files are usually a few
|
|
MiB or less. Writing a file of these sizes takes well under a second on an
|
|
ordinary disk, in the background, so rewriting whole files needs nothing
|
|
cleverer.
|
|
|
|
Format:
|
|
|
|
- indented 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;
|
|
- `clients.json` and `lookups.json` put each entry on one line instead. That
|
|
halves their size and lets `grep` show everything about one client.
|
|
|
|
The aim is that a person can open `bans.json` in an editor, find an address, and
|
|
remove or add an entry.
|
|
|
|
Writing:
|
|
|
|
- serialise from a snapshot taken under the lock, so requests are not held up
|
|
while the file is written;
|
|
- write to a temporary file in the same directory, sync it, rename it over the
|
|
real name, sync the directory. A crash at any point leaves either the old
|
|
complete file or the new complete file, never a partial one;
|
|
- `bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban changes. Changes
|
|
that only update the counts in its notes wait for the write every
|
|
`SWWAF_STATE_COUNTER_INTERVAL`, as the other four files do. All five are
|
|
written on orderly shutdown (`SIGTERM`);
|
|
- before writing a file, `smallwebwaf` checks whether it changed on disk since
|
|
it last read or wrote it. If it did, `smallwebwaf` takes that edit in first,
|
|
so an admin's edit is never overwritten.
|
|
|
|
Reading at start:
|
|
|
|
- Active bans go into an in-memory prefix lookup; everything else into maps.
|
|
Counter buckets and cache entries whose time has passed are discarded.
|
|
- A missing file means empty state, and is normal on first run.
|
|
- 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.
|
|
- `SWWAF_STATE_DIR` not writable at start: the process exits.
|
|
|
|
Edits while running:
|
|
|
|
- `smallwebwaf` watches `SWWAF_STATE_DIR` and 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 wrote.
|
|
- An edit that parses is taken in at once: what the file says replaces what
|
|
`smallwebwaf` held for that file. Removing a ban's entry from `bans.json`
|
|
lifts the ban; adding an entry bans. Changes `smallwebwaf` made after the
|
|
admin opened the file, such as a new ban, are lost when the admin saves over
|
|
them. Most editors warn when a file changed on disk while it was open.
|
|
- An edit that does not parse does not stop the running `smallwebwaf`. It keeps
|
|
the state it has and renames the edited file to `<name>.bad` (for example
|
|
`bans.json.bad`), so the edit is kept. It writes the file again from memory,
|
|
logs the file and the position of the error, and sends them in one
|
|
`file_error` alert. The admin fixes the `.bad` file and moves it back.
|
|
|
|
A write that fails while running:
|
|
|
|
- state stays correct in memory;
|
|
- the failure is logged and counted in the metrics;
|
|
- one `file_error` alert is sent per cooldown;
|
|
- the write is retried at the next write.
|
|
|
|
What a hard kill can lose:
|
|
|
|
- ban changes from the last `SWWAF_STATE_WRITE_DELAY` (10 seconds);
|
|
- everything else from the last `SWWAF_STATE_COUNTER_INTERVAL` (15 minutes):
|
|
counts and history, the counts in ban notes, GeoJS answers, reputation
|
|
verdicts and the AbuseIPDB count, anomaly counters and alert cooldowns.
|
|
|
|
Losing the volume loses ban history, not service.
|
|
|
|
## Request log
|
|
|
|
One JSON object per line on stdout for every request, including refused ones,
|
|
apart from those Go's HTTP server ends before `smallwebwaf` sees them (see "Size
|
|
and time limits"). stdout is always on. When `SWWAF_LOG_REMOTE_URL` is set the
|
|
same lines are also sent there, so a deployment can stop depending on docker's
|
|
log handling while `docker logs` keeps working.
|
|
|
|
- Standard web log fields: `time` (RFC 3339 with milliseconds), `instance`,
|
|
`client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`,
|
|
`status`, `request_bytes`, `response_bytes`, `referer`, `user_agent`.
|
|
- Request detail:
|
|
- `request_id`, generated if traefik did not supply one, and passed to the
|
|
app;
|
|
- `peer_ip`, the TCP peer, normally traefik;
|
|
- `forwarded_for`, the header as received;
|
|
- `client_group`, the /64 or configured prefix used for counting;
|
|
- `asn`, `as_name`, `country`;
|
|
- `content_type`, `content_length`, and the headers named in
|
|
`SWWAF_LOG_REQUEST_HEADERS`;
|
|
- `has_authorization` and `has_cookie`, as booleans;
|
|
- `websocket`, when the connection was upgraded.
|
|
- Response detail: `response_content_type`, `upstream_status` (differs from
|
|
`status` when `smallwebwaf` answered itself), `cache_control`, `location` on
|
|
redirects, `aborted` when the client went away early.
|
|
- Decision:
|
|
- `action`: `forward`, `banned`, `denied`, `country_denied`, `rate_limited`,
|
|
`rule_blocked`, `waf_blocked`, `too_large`, `timed_out`, `upstream_error`,
|
|
or `admin` for a request `smallwebwaf` answered at one of its own
|
|
endpoints;
|
|
- `would_action`, in `observe` mode;
|
|
- `limit_percent`, and which setting gave it;
|
|
- `counts`, the client's minute, hour and day request and byte totals after
|
|
this request;
|
|
- `limit_hit`, which window;
|
|
- `rule_ids`, the rule file rules that matched;
|
|
- `waf_rule_ids` and `waf_score`;
|
|
- `reputation`, the sources that listed the client;
|
|
- `offence`, when one was recorded;
|
|
- `ban_expires`.
|
|
- Timings in milliseconds: `duration_total`, `duration_checks` (everything
|
|
`smallwebwaf` did before forwarding), `duration_waf`,
|
|
`duration_upstream_connect`, `duration_upstream_first_byte` and
|
|
`duration_upstream_total`.
|
|
- 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"`.
|
|
- Remote sending:
|
|
- each line is the message of an RFC 5424 syslog record, with octet-counted
|
|
framing on TCP and TLS;
|
|
- sending happens on its own goroutine from a bounded buffer
|
|
(`SWWAF_LOG_REMOTE_BUFFER`). An unreachable or slow server never delays a
|
|
request and never stops stdout. The sender reconnects with backoff, drops
|
|
the oldest lines when the buffer is full, and counts the drops in the
|
|
metrics. UDP gives no delivery signal at all and is offered only for
|
|
compatibility.
|
|
|
|
## Metrics endpoint
|
|
|
|
Prometheus text format at `/_smallwebwaf/metrics`, for a request carrying
|
|
`SWWAF_METRICS_TOKEN`.
|
|
|
|
- Traffic: requests and bytes in and out, by status class and `action`; request
|
|
duration and upstream duration histograms; requests in flight.
|
|
- Limits and bans:
|
|
- limit hits by window and kind (requests, bytes or refusals);
|
|
- size and time limit hits by limit;
|
|
- offences by kind;
|
|
- bans made by cause, permanent bans, active bans;
|
|
- requests refused by the country lists, by country.
|
|
- Attack detection:
|
|
- rule file matches by rule id and action, and the number of rules loaded;
|
|
- Core Rule Set matches by mode and rule id, limited to the rules that
|
|
fired.
|
|
- Lookup and reputation:
|
|
- requests and bytes by AS number and by country, limited to the
|
|
`SWWAF_METRICS_TOP_N` busiest of each with the rest summed as `other`, so
|
|
the label set stays bounded;
|
|
- reputation queries, hits, failures and remaining daily budget by source;
|
|
- GeoJS requests and failures, and clients that counted as unknown because
|
|
it did not answer in time;
|
|
- when each list was last fetched, and the lookup database last read.
|
|
- Housekeeping:
|
|
- tracked clients;
|
|
- state file writes, write failures, last successful write time and size per
|
|
file; edits taken in, and edits set aside because they did not parse;
|
|
- alerts sent, failed, suppressed and dropped by destination;
|
|
- remote log lines sent, dropped and buffer depth;
|
|
- the standard Go runtime and process 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.
|
|
|
|
- `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`.
|
|
- `GET /_smallwebwaf/metrics`: the metrics; needs `SWWAF_METRICS_TOKEN`.
|
|
- `GET /_smallwebwaf/bans`, `POST /_smallwebwaf/bans` (a client or netblock, a
|
|
duration, a reason) and `DELETE /_smallwebwaf/bans/<client>`: need
|
|
`SWWAF_ADMIN_TOKEN`. Editing `bans.json` does the same without a token.
|
|
- `GET /_smallwebwaf/clients/<ip>`: needs `SWWAF_ADMIN_TOKEN`. The client's
|
|
current counters and history, which holds its lookup result and offences, and
|
|
its bans with their notes. It answers "why was this address refused" and "what
|
|
has this netblock been doing".
|
|
- A token is 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.
|
|
- An admin whose own address is banned lifts the ban by editing `bans.json`.
|
|
|
|
## Alert webhook schema
|
|
|
|
One JSON object per alert:
|
|
|
|
- `instance`, `time`, `event` (one of the `SWWAF_ALERT_EVENTS` values, or
|
|
`summary` for the hourly summary);
|
|
- `client`, `netblock`, `asn`, `as_name`, `country`;
|
|
- `reason`: a short human-readable sentence;
|
|
- `detail`: event-specific fields, for example `window`, `count`, `limit`,
|
|
`limit_percent`, `rule_ids`, `path`, `ban_expires`, for a ban its notes, and
|
|
for `file_error` the file and the position of the error;
|
|
- `suppressed_repeats`: the number of identical alerts held back by the
|
|
cooldown.
|
|
|
|
## 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, the newest long-term support release of Ubuntu, pinned by
|
|
digest. The image moves to the next LTS release when that ships.
|
|
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, installed from a dated
|
|
snapshot of its archive and checked by hash (see "Pinning"). The Ubuntu image
|
|
has no CA certificates; without them Nix cannot download packages and
|
|
`smallwebwaf` cannot reach GeoJS.
|
|
- Nix and nixpkgs, fixed at one release (see "Pinning"). Root uses Nix directly;
|
|
no Nix daemon runs, and `build-users-group =` in `/etc/nix/nix.conf` lets root
|
|
build without the build users `nix-bin` does not create.
|
|
- runit, and `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 (`github.com/peterbourgon/runsvinit`, 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` and runs it again whenever it exits. Ubuntu's
|
|
`sv` looks for services there too.
|
|
- The `smallwebwaf` binary, and a user of its own, `smallwebwaf` (uid and gid
|
|
65532).
|
|
- The service directory `/etc/service/smallwebwaf`. Its `run` script waits one
|
|
second (`sleep 1`), makes `SWWAF_STATE_DIR` and every file in it belong to the
|
|
`smallwebwaf` user, and starts `smallwebwaf` as that user with runit's
|
|
`chpst`.
|
|
- The directory `/var/lib/smallwebwaf` for the state files, and
|
|
`/etc/smallwebwaf/rules.d` with the default rule file.
|
|
- Port 8080 declared (`EXPOSE 8080`), and the health check described below
|
|
(`HEALTHCHECK`).
|
|
|
|
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 on `127.0.0.1:8081`. 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.
|
|
|
|
An example, for an app whose binary is `app` and which takes the address it
|
|
listens on and the proxies it trusts as options of its own:
|
|
|
|
```dockerfile
|
|
# The smallwebwaf image, pinned by digest.
|
|
FROM <registry>/smallwebwaf@sha256:<digest>
|
|
|
|
# Packages the app needs, if any, from the nixpkgs in the image.
|
|
RUN nix-env -iA nixpkgs.git
|
|
|
|
# The app's binary, and a user of its own to run it.
|
|
COPY app /usr/local/bin/app
|
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
|
|
|
|
# The app's runit service.
|
|
COPY --chmod=755 app.run /etc/service/app/run
|
|
```
|
|
|
|
with `app.run` beside the Dockerfile:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
main() {
|
|
sleep 1
|
|
exec chpst -u app:app /usr/local/bin/app \
|
|
--listen 127.0.0.1:8081 \
|
|
--trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
|
|
}
|
|
|
|
main "$@"
|
|
```
|
|
|
|
### Pinning
|
|
|
|
- Ubuntu's packages come from Ubuntu's snapshot service, the archive as it was
|
|
at the moment the Dockerfile names (`--snapshot 20261001T000000Z`), never
|
|
earlier than the pinned Ubuntu image's date. The Dockerfile names the SHA-256
|
|
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
|
|
hashes are the amd64 archive's, so the image is built for amd64 only. The
|
|
snapshot is fetched over HTTPS with the CA certificates of the Go image that
|
|
builds `smallwebwaf`, itself pinned by digest, since the Ubuntu image has
|
|
none.
|
|
- nixpkgs comes from a release file on `releases.nixos.org` (`nixexprs.tar.xz`
|
|
of `nixos-26.05.11045.774debe7a0d1`), named with its SHA-256 hash, not from a
|
|
GitHub archive, whose bytes can change. It is set up for root as `nixpkgs`.
|
|
Root's Nix profile, `/nix/var/nix/profiles/default/bin`, is last on the
|
|
`PATH`, so that no package hides the image's own commands; busybox's `sv`, for
|
|
one, looks for services elsewhere. Unpacked, nixpkgs takes about 500 MiB.
|
|
- 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.
|
|
|
|
### 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 broken helper 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. Those requests can finish only while the
|
|
app still answers. An app that exits at once on `SIGTERM` ends them: the
|
|
client gets `502`, or a response cut short. To let them finish, the app must
|
|
keep taking and answering requests 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 the address in
|
|
`SWWAF_UPSTREAM_URL`. It fails when either does not. The container therefore
|
|
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`, and its own
|
|
endpoints are on that listener. 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
|
|
`127.0.0.1:8081` only. An app that listens on every address can be reached
|
|
around `smallwebwaf` by anything that reaches the container.
|
|
|
|
### State
|
|
|
|
The app keeps its data in directories of its own, such as `/var/lib/app`, and
|
|
`smallwebwaf` keeps its state files beside them in `SWWAF_STATE_DIR`. 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`, as in the
|
|
example.
|
|
|
|
### 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.
|
|
|
|
### Rollout per service
|
|
|
|
Build the app's image on the `smallwebwaf` image, with no setting, and deploy
|
|
it. Nothing needs tuning first. Afterwards the request log and the ban notes say
|
|
why each client was refused. A real visitor refused by mistake is let through
|
|
with an exclusion (`SWWAF_WAF_DISABLED_RULES`, `SWWAF_WAF_EXEMPT_PATHS`,
|
|
`SWWAF_RATE_LIMIT_EXEMPT_PATHS`) or an exemption
|
|
(`SWWAF_RATE_LIMIT_EXEMPT_NETS`, `SWWAF_ALLOW_NETS`), and its ban is lifted in
|
|
`bans.json`. Additions to consider at any time:
|
|
|
|
- an alert destination;
|
|
- country lists or biased thresholds;
|
|
- reputation sources;
|
|
- the metrics and admin tokens;
|
|
- a remote log server;
|
|
- an app-specific rule file.
|
|
|
|
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.
|
|
|
|
## Risks the design has to handle
|
|
|
|
- Forged `X-Forwarded-For`: handled by step 2 of "What happens to a request".
|
|
The default trusts every private address, which in the intended deployment
|
|
means traefik. Where other containers reach `smallwebwaf` directly, or
|
|
visitors on a private network reach traefik, they can name another address in
|
|
the header. `SWWAF_TRUSTED_PROXIES` set to traefik's own network closes that
|
|
gap.
|
|
- Many clients behind one address (mobile carriers, offices, Tor) share its
|
|
limits and its bans. The defaults sit well above one person's use, the static
|
|
lists take known shared addresses out, and the ban notes show an admin what
|
|
happened before a ban is lifted.
|
|
- A `ban` rule that matches a real visitor bans it for a long time (see "Bans").
|
|
So the default rule file keeps to requests no real visitor sends, anchored at
|
|
the site root, and a ban is lifted in `bans.json`.
|
|
- IPv6 address rotation inside a /64: handled by grouping.
|
|
- Widely distributed scrapers, thousands of addresses at low rates each:
|
|
per-client limits do not see them. The AS number and netblock anomaly alerts
|
|
reveal them, and a low `SWWAF_ASN_LIMIT_PERCENT` for that AS number is the
|
|
response.
|
|
- Core Rule Set false positives: a match refuses only that request and bans no
|
|
one by itself. The request log names the rule, and exclusions by rule id and
|
|
path fix it. An engine error on a request forwards the request, and is logged
|
|
and counted. Attacks in request bodies are not refused by default, for the
|
|
reason under "Attack detection".
|
|
- The fourth and fifth changes to the Core Rule Set hold in front of every app,
|
|
and no setting restores those rules. An app that uses one of those query
|
|
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. The same holds for an app
|
|
that reads a cookie named `gitea_flash` or `redirect_to`, or passes `Referer`
|
|
to a shell.
|
|
- The admin endpoints can be reached from the internet. All but the health check
|
|
need a token and are off while it is unset, and token guessing is banned
|
|
through the error burst.
|
|
- GeoJS, the default lookup source: every new visitor's address goes to a third
|
|
party, and a swarm of fresh addresses is when GeoJS may slow down or block
|
|
`smallwebwaf`. Kept answers and batched requests keep the number of requests
|
|
low. The file source has neither risk.
|
|
- Slow-request attacks: bounded by `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` and `SWWAF_CLIENT_IDLE_TIMEOUT`.
|
|
- Alert floods: the cooldown and the hourly cap.
|
|
|
|
## 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".
|