Files
smallwebwaf/SPEC.md
T
sneak 0d81aa20df Deploy model: apps build FROM the smallwebwaf image (closes #12)
SPEC.md and README.md now describe the recommended deploy: an app's
Dockerfile builds FROM the smallwebwaf image, and runit, started by
runsvinit, runs smallwebwaf on :8080 in front of the app on
127.0.0.1:8081, with no setting required. They cover which user each
process runs as, what happens when either exits, the health check, the
ports, the state directory and its volume, the app's trusted proxies,
the new defaults and upaas needing no change, with an example app
Dockerfile in place of the docker-compose examples. Every setting
carries the SWWAF_ prefix, and the spec no longer calls smallwebwaf a
sidecar.

Model: opus-5-5
2026-09-28 23:34:48 +00:00

1494 lines
92 KiB
Markdown

# smallwebwaf SPEC (draft): protective reverse proxy for one app
Status: fourth draft, with the owner's rulings to date applied. Nothing has been
built yet. `EVALUATION.md` beside this file explains why no existing tool was
chosen.
## Purpose
A reverse proxy that runs inside the container of the one application it
protects, between traefik and the app. The app's Dockerfile builds `FROM` the
`smallwebwaf` image; in the container, `smallwebwaf` listens on port 8080, where
traefik sends the app's requests, and forwards them to the app on
`127.0.0.1:8081` (see "Deployment"). It limits request and byte rates per
client, bounds the size and duration of every request and response, detects
attacks, bans abusers (briefly at first, for seven days on a clear sign of
attack, permanently when they keep at it), consults IP reputation sources, looks
up the AS number and country of each client, can refuse whole countries, lowers
its limits for listed AS numbers and countries, and sends alerts.
It is meant to protect an app on the open internet from the first request with
no setting at all: every setting has a default chosen for a service facing the
internet in 2026. Everything is configured by environment variables, each named
with the `SWWAF_` prefix, apart from the attack detection rules, which are read
from a directory of hand-editable text files.
## Non-goals
- TLS termination, certificates, hostname routing: traefik's job.
- More than one application per `smallwebwaf`. Each app's image carries its own.
- Browser challenges (captcha, proof of work). If wanted, chain Anubis between
`smallwebwaf` and the app.
- Defence against traffic floods that saturate the host's network link. That
needs help upstream of the host.
- A web UI or a configuration file. Settings are environment variables. Apart
from settings given as files (the `_FILE` form of any setting, such as
`SWWAF_ADMIN_TOKEN_FILE`, and `SWWAF_LOG_REMOTE_TLS_CA_FILE`), its own state
files and the lookup database, the only files read are the rule files, which
hold one regex per line and nothing more elaborate.
- Sharing bans between the `smallwebwaf` instances of several apps in the first
version: each instance keeps its own. Running one CrowdSec engine per host,
which every instance would report its bans to, is reconsidered once real ban
volumes are known. Reading a CrowdSec decision list is supported (see
Reputation).
## Architecture
- One statically linked Go binary, shipped in an Alpine Linux image that is the
base of the app's own image, so that `smallwebwaf` and the app run in one
container (see "Deployment"). There runit starts `smallwebwaf` as its own
non-root user, and `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`, and it answers the endpoints of `smallwebwaf`
itself for health, metrics and ban management, under `/_smallwebwaf/`, which
never reach the app (see "Admin endpoints").
- Components inside the process:
- Client identification: works out the real client IP from
`X-Forwarded-For`, trusting only the proxy netblocks in
`SWWAF_TRUSTED_PROXIES`, by default the private address ranges.
- Lookup: AS number and country, by default from the GeoJS web service,
whose answers are kept for seven days, or instead from a database file the
operator supplies, held in memory.
- Reputation: static blocklists fetched on a schedule; DNSBL and reputation
API queries made in the background and cached.
- Counters: per-client request and byte counts per minute, hour and day,
held in memory and written out with the rest of the state, so they survive
a restart.
- Attack detection: regex rules read from a directory of plain text rule
files and read again whenever the files change (see "Rule files"), Coraza
with the OWASP Core Rule Set, and simple signals (requests for listed trap
paths, bursts of requests refused by the rule files or the Core Rule Set).
- Ban ledger: active and past bans with their notes, and each client's
history, held in memory and kept in JSON files that `smallwebwaf` watches
for an admin's edits (see "Bans" and "Persistent state"). No database of
any kind is used.
- Request log: one JSON object per request on stdout, optionally also sent
to a remote syslog server (see "Request log").
- Metrics: Prometheus counters and gauges (see "Metrics endpoint").
- Alerting: a queue with de-duplication feeding webhook, Slack and ntfy
senders.
- Proxy: the standard library's `net/http/httputil.ReverseProxy`, streaming
in both directions within the size and time limits, with WebSocket upgrade
support.
- Proposed libraries (to be checked against the owner's Go dependency defaults
before any are added):
- standard library for the proxy, HTTP clients, DNS, logging (`log/slog`),
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, for attack detection. The changes `smallwebwaf`
makes to the Core Rule Set and the default `SWWAF_WAF_DISABLED_RULES` (see
"Configuration surface", attack detection) are built for that version,
since rule ids and what each rule matches change between versions, and are
checked again before the version changes;
- `github.com/oschwald/maxminddb-golang/v2`, the current major version, to
read the lookup database;
- `github.com/fsnotify/fsnotify` to notice files that are edited or added
while `smallwebwaf` runs;
- `github.com/prometheus/client_golang` for metrics;
- remote log sending is written in this project: the standard library's
`log/syslog` is frozen and writes only the older syslog format, not the
current one (RFC 5424). RELP is not in the first release; it is revisited
against the available packages afterwards.
## Data flow for one request
Steps run in this order; the first step that produces a final answer ends
processing. The size and time limits (see "Configuration surface") apply to the
whole exchange.
- A `GET /_smallwebwaf/healthz` is answered at once (see "Admin endpoints").
- Identify the client.
- If the TCP peer is inside `SWWAF_TRUSTED_PROXIES`, walk `X-Forwarded-For`
from the right and take the first address not inside
`SWWAF_TRUSTED_PROXIES`. If every address in the header is inside
`SWWAF_TRUSTED_PROXIES`, take the leftmost, so a visitor on a private
network who comes through traefik is known by its own address, not
traefik's. If there is no header, as when another container calls
`smallwebwaf` directly, the TCP peer is the client.
- If the TCP peer is outside `SWWAF_TRUSTED_PROXIES`, it is the client, and
the header is ignored.
- IPv6 clients are grouped by prefix (`SWWAF_IPV6_GROUP_PREFIX`, default 64)
for counting and banning, because one abuser usually controls a whole /64.
A client is therefore one IPv4 address or one IPv6 group, a /64 by
default.
- Static lists.
- In `SWWAF_ALLOW_NETS`: skip every check below and forward, or answer a
request under `/_smallwebwaf/` (see "Admin endpoints"). Still counted for
anomaly alerts.
- In `SWWAF_DENY_NETS`: refuse.
- Ban ledger. An active ban on the client's netblock: refuse with
`SWWAF_BAN_RESPONSE`. If a clear sign of attack caused the ban, the ban
becomes permanent (see "Bans").
- Look up the AS number and country, unless `SWWAF_LOOKUP_SOURCE` is `off`. With
GeoJS, the default, a request waits for its client's first answer, up to
`SWWAF_LOOKUP_TIMEOUT`, only when a setting needs it before the request goes
on: the country lists, the biased thresholds or `SWWAF_ADD_LOOKUP_HEADERS`.
Otherwise the request goes on at once; the answer is added to the client's
history and ban notes when it comes, and a request that ends before then is
logged without it. A client the lookup cannot place, which includes every
private, loopback and link-local address, has an unknown country and AS
number: the exclusive country list refuses it, the biased thresholds give it
`SWWAF_UNKNOWN_LIMIT_PERCENT`, and nothing else treats it differently.
- Country lists. A client whose country is in `SWWAF_DENIED_COUNTRIES`, or, when
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, is not in it, is refused with
`SWWAF_BAN_RESPONSE`. Every step up to here needs only the client's address,
so the request body has not been read yet, and the refusal skips everything
below, the rule files and the Core Rule Set included. It is counted in the
metrics, but it is not an offence and makes no ban.
- Reputation.
- Address inside a fetched blocklist: apply `SWWAF_BLOCKLIST_ACTION`.
- Cached DNSBL or reputation API result: apply `SWWAF_REPUTATION_ACTION`.
- No cached result: queue a background query and carry on. A first request
is never delayed by a reputation query.
- Work out the client's limit percentage: the lowest of the percentages that
apply (AS number, country, reputation), or 100 if none applies.
- Request rate limits. Unless the client is in `SWWAF_RATE_LIMIT_EXEMPT_NETS`,
check the minute, hour and day counters against each limit times the
percentage. A request over any of them breaks that limit: it is refused with
`SWWAF_BAN_RESPONSE` and the client is banned (see "Bans").
- Rule files. The request is checked against the rules loaded from
`SWWAF_RULES_DIR`, in file name order then line order. Each rule that matches
takes its action: only log, refuse with 403, or refuse and ban. Matching stops
at the first rule that refuses or bans.
- Core Rule Set inspection of the request: its method, its URL with the query
string, and its headers. By default no body is read. When
`SWWAF_WAF_BODY_LIMIT` is set to a size, a body is read up to that size if it
is form data, multipart, JSON or XML, the kinds the Core Rule Set can read.
Any other body, such as a git push or a container image layer, reaches the app
uninspected, and so does a JSON or XML body larger than
`SWWAF_WAF_BODY_LIMIT`, which cannot be read in part. Responses are not
inspected. In `block` mode, the default, a match at or over the anomaly
threshold is refused with 403; in `detect` mode it is only logged and alerted.
- A request under `/_smallwebwaf/` is answered by `smallwebwaf` here and goes no
further (see "Admin endpoints").
- Forward to `SWWAF_UPSTREAM_URL`, streaming. Add `X-Forwarded-For` and, if
enabled, `X-Client-ASN` and `X-Client-Country` for the app's own logs.
- After the response.
- Add response bytes (and request body bytes) to the byte counters. A client
whose byte total passes a byte limit times the percentage has broken that
limit and is banned. The response in progress is not cut off.
- Count the client's requests that `smallwebwaf` refused after a rule file
or Core Rule Set match, or for a missing or wrong token (see "Admin
endpoints"); answers the app gave are not counted. More than
`SWWAF_ERROR_BURST_THRESHOLD` of them within a minute breaks a limit and
bans the client.
- Update the client's history and the anomaly counters, and evaluate alert
thresholds.
## Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client,
with the previous bucket 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; a request never waits on the disk.
They are written to `clients.json` every `SWWAF_STATE_COUNTER_INTERVAL` and at
shutdown 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 by `SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS` and the
number of GeoJS answers kept (see "Configuration surface"). When the table of
clients is full, the least recently seen client is dropped first, with its
history. When `SWWAF_MAX_BANS` bans `smallwebwaf` made are held, the one that
has gone longest without a request from its netblock is dropped first, whether
it is past, active or permanent. Bans an admin made are never dropped, and
there are as many as the admin adds (see "Bans").
## Bans
A ban refuses every request from a netblock, answering with `SWWAF_BAN_RESPONSE`
(`403` by default), until the ban ends. A permanent ban does not run out: it
ends when an admin lifts it or, if `smallwebwaf` made it, when a new ban is made
while `SWWAF_MAX_BANS` bans `smallwebwaf` made are held and it is the one that
has gone longest without a request. 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, however many there are. An admin who wants to
keep a ban `smallwebwaf` made sets its cause to `admin`.
The netblock a ban covers is the client: its IPv4 address, or its IPv6 group of
`SWWAF_IPV6_GROUP_PREFIX` (a /64 by default). `SWWAF_BAN_SCOPE_V4_PREFIX` can
widen an IPv4 ban to the surrounding netblock. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did
nothing.
An offence is a request `smallwebwaf` holds against the client: one that carries
a clear sign of attack, breaks a limit, or is refused by a rule file or the Core
Rule Set. Offences are counted by kind in the client's history. Two kinds lead
to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule file 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, but its
next clear sign of attack bans it permanently at once.
- A broken limit: a request over a request limit, a response that takes the
client's byte total past a byte limit, or more than
`SWWAF_ERROR_BURST_THRESHOLD` requests within a minute refused after a rule
file or Core Rule Set match, or for a missing or wrong token.
- The first such ban, or one that comes more than
`SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban
ended, lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`).
- 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; with the defaults, that is the sixth ban in a row.
- Each such ban sets the client's counters for every limit back to zero, so
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. Requests refused under a ban are not
counted against limits, but they are counted in the client's history and in the
ban's notes.
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 (see
"Admin endpoints"). A ban lifted before it ends is kept, marked as lifted, and
does not count toward a longer ban; deleting its entry from `bans.json` forgets
it entirely.
Clients of the AS numbers and countries listed in the biased thresholds, and
clients listed by a reputation source whose action is `limit:<percent>`, get
lower limits (see "Biased thresholds"), so the same rules ban them after fewer
requests. For them the result is always a ban, first a temporary one and then,
for repeated abuse, a permanent one.
Some refusals make no ban at all. `SWWAF_DENY_NETS`, a blocklist or reputation
hit whose action is `deny`, and the country lists (`SWWAF_DENIED_COUNTRIES`,
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`) refuse each request they cover and record
nothing in `bans.json`.
## Configuration surface
Conventions: lists are comma separated; netblocks are CIDR (a bare address means
/32 or /128); durations use Go syntax plus `d` for days (`90s`, `15m`, `24h`,
`7d`); byte sizes accept `K`, `M`, `G` suffixes, which are powers of 1024: `1K`
is 1024 bytes, `1M` is `1024K` and `1G` is `1024M`. Countries are two-letter ISO
codes in either case (`de` and `DE` are the same); a code that is not a country
code, such as `nk` (North Korea is `kp`), stops the start with a message naming
it.
- No setting is required. Every setting has a default chosen for a service
facing the internet in 2026, or stays off until the operator supplies what it
needs: an alert destination, an account key, a token.
- Every setting's name starts with `SWWAF_`, since `smallwebwaf` shares its
container, and so its environment variables, with the app it protects.
- Any limit or threshold can be switched off with the value `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Every setting may instead be given as a file holding the value, named by the
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
secrets and long lists.
- Settings, including those given as files, 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.
- Invalid configuration stops the process at start with a message naming the
variable. At start the effective configuration is logged with secrets masked.
The settings, by group:
- Core
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the application,
which listens there in the same container (see "Deployment").
- `SWWAF_LISTEN_ADDR` (default `:8080`): the one listener, for the requests
`smallwebwaf` forwards and for its own endpoints (see "Admin endpoints").
- `SWWAF_ADMIN_TOKEN`: bearer token for the ban endpoints and
`/_smallwebwaf/clients/<ip>`, a long random value, since they can be
reached from the internet. A token shorter than 32 characters stops the
start with a message naming the variable. Unset by default, which switches
them off; bans are then managed by editing `bans.json`.
- `SWWAF_INSTANCE_NAME` (default: the container's host name, which docker
sets to the first 12 characters of the container's id unless the
deployment names one): included in every log line, metric and alert. Set
it, for example to `fsn1app1/gitea`, for a name that stays the same when a
deploy replaces the container and that tells instances apart when several
report to one place.
- `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):
netblocks whose `X-Forwarded-For` is believed, normally where traefik
reaches `smallwebwaf` from. The header is used only when the TCP peer is
inside this list; otherwise the peer's own address is the client, so 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`).
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): clients held in memory and
in `clients.json` (see "Persistent state").
- `SWWAF_MAX_BANS` (default `5000`): bans `smallwebwaf` made, past, active
and permanent, held in memory and in `bans.json`. Bans an admin made are
kept besides these and never dropped (see "Bans").
- Persistent state
- `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. A volume mounted there keeps the state when a deploy
replaces the container (see "Deployment").
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): after a ban is made, lifted or
made permanent, `bans.json` is written this long later with every change
made in between, so a burst of changes becomes one write.
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often `clients.json`,
`lookups.json`, `reputation.json` and `alerts.json` are written, and
`bans.json` when only the counts in its notes changed.
- Logging
- The request log on stdout is always on and has no switch.
- `SWWAF_LOG_LEVEL` (default `info`): for the process's own messages
(start-up, fetch failures, state writes), 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`): extra
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 to this
endpoint. Forms: `syslog+udp://host:514`, `syslog+tcp://host:514`,
`syslog+tls://host:6514`.
- `SWWAF_LOG_REMOTE_TLS_CA_FILE`: optional CA certificate for the `+tls`
forms.
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): lines held in memory while
the endpoint is unreachable; when full the oldest are dropped and counted.
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`),
`SWWAF_LOG_REMOTE_APP_NAME` (default `SWWAF_INSTANCE_NAME`): syslog header
fields.
- Metrics
- `SWWAF_METRICS_TOKEN`: bearer token a scraper sends for
`/_smallwebwaf/metrics`, a long random value. A token shorter than 32
characters stops the start with a message naming the variable. Unset by
default, which switches the metrics off, since they would otherwise be
open to anyone on the internet.
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and countries
get their own series; 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 request and byte limits only; the
error burst threshold, attack detection and bans still apply.
- `SWWAF_DENY_NETS`: always refused.
- Request rate limits, per client (R1, R2). Breaking one bans the client (see
"Bans"), 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 (see the notes
specific to gitea under "Deployment"), 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`), `SWWAF_RATE_LIMIT_PER_DAY`
(default `50000`).
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS`: path prefixes not counted (static assets,
health checks).
- Byte limits, per client. A response's bytes are counted when it ends, so every
default sits above the largest response allowed
(`SWWAF_CLIENT_RESPONSE_MAX_BYTES`, 5 GiB) and no single download breaks one.
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`),
`SWWAF_BYTES_LIMIT_PER_HOUR` (default `20G`), `SWWAF_BYTES_LIMIT_PER_DAY`
(default `50G`).
- `SWWAF_BYTES_COUNT` (default `both`): `response`, `request` or `both`.
- Size and time limits, per request, in both directions. The client-facing
limits apply between the client and `smallwebwaf`, the app-facing ones between
`smallwebwaf` and the app. Bodies stream straight through, so a request body
reaches the app while the client is still sending it, and of two matching
limits the lower one acts first.
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take
to send its whole request, headers and body.
- `SWWAF_CLIENT_REQUEST_MAX_BYTES` (default `100M`): the largest request
body a client may send.
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest
request line and headers a client may send. Over it, `smallwebwaf` answers
`431` and closes the connection, and nothing reaches the app.
- `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 a
connection it is not using, so traefik closes first and never sends a
request on a connection `smallwebwaf` is closing.
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long `smallwebwaf`
may take to deliver one response to the client, from the end of the
request to the last byte.
- `SWWAF_CLIENT_RESPONSE_MAX_BYTES` (default `5G`): the largest response
body sent to a client.
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long `smallwebwaf`
may take to connect to the app and send it the whole request.
- `SWWAF_UPSTREAM_REQUEST_MAX_BYTES` (default `100M`): the largest request
body sent to the app.
- `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_UPSTREAM_RESPONSE_MAX_BYTES` (default `5G`): the largest response
body taken from the app.
- When a limit is passed before the response has started, `smallwebwaf`
answers itself: `413` for a request body that is too large, `408` for a
client that is too slow, `502` for a response that is too large, `504` for
an app that is too slow. A request that announces a body larger than its
limit is refused before anything reaches 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 (R7). 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`.
- `SWWAF_LOOKUP_DB_PATH`: the file for `file`, the IPinfo Lite database in
its `.mmdb` form (`ipinfo_lite.mmdb`), one file that carries both country
and AS number; `smallwebwaf` reads its `asn`, `as_name` and `country_code`
fields. 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. A file that is missing or unreadable at start stops the start.
- Refreshing the file is the operator's business; IPinfo updates it daily.
`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. Mount the directory that holds
the file rather than the file itself: docker does not show a single
mounted file being replaced on the host.
- `geojs` asks the free GeoJS web service, which needs no account or key,
about up to 200 addresses in one request
(`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), and reads each answer's
`country_code`, `asn` and `organization_name` (the AS name). An answer
without a `country_code`, which GeoJS sends for ranges it cannot place,
and an `asn` of `64512`, which it gives when it knows none, count 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, the client's
history and ban notes carry the AS number and country. Private, loopback
and link-local addresses, which no source can place, are never sent.
- Each GeoJS answer is kept for 7 days in memory and in `lookups.json`,
apart from the table of clients, so it survives a restart and outlasts a
client dropped from that table; after 7 days the client's next request
asks again. Up to 100,000 answers are kept, about 15 MiB; when that many
are held, 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 (see "Data flow for one
request"); GeoJS normally answers in a fraction of that. At most one
request to GeoJS is under way at a time, the addresses that arrive
meanwhile are asked about together in the next one, up to 200 per request
with the rest in the requests after it, and a request that takes longer
than `SWWAF_LOOKUP_TIMEOUT` is abandoned. A client whose answer has not
come in time counts as unknown until it comes: its later requests do not
wait, and its address is asked about again in the background.
- 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`, clients with a kept answer
are unaffected and new clients count as unknown, so
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`, when set, refuses them.
`smallwebwaf` keeps asking with backoff and sends one `source_failure`
alert per cooldown. A service that cannot accept this uses the file.
- One source at a time: `SWWAF_LOOKUP_SOURCE=file` without
`SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other
`SWWAF_LOOKUP_SOURCE`, the default `geojs` included, stops the start with
a message naming both. So does a setting that needs lookups while
`SWWAF_LOOKUP_SOURCE` is `off`: the country lists and the biased
thresholds below, `SWWAF_ADD_LOOKUP_HEADERS`, and the per-AS-number
anomaly thresholds.
- `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, and so is every request from a client the lookup
cannot place: one whose address is missing from the database, one GeoJS
has not answered for in time, and any client on a private address, 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.
- Both may be set. `SWWAF_DENIED_COUNTRIES` then adds nothing, since the
exclusive list already refuses every other country, and a code on both
lists stops the start with a message naming it.
- A refused request is answered with `SWWAF_BAN_RESPONSE`, as a banned
client is, 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, is logged with the action `country_denied`, and is
counted in the metrics. It is a refusal, not a ban: it is not an offence
and makes no ban record.
- Biased thresholds (R8). 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`: same form with ISO country codes, for
example `CN:25,RU:50`. Each client from a listed country gets that
percentage of the normal per-client limits.
- 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.
- When more than one percentage applies to a client (its AS number, its
country, a reputation hit), the lowest applies.
- `SWWAF_ASN_BYTES_PERCENT`, `SWWAF_COUNTRY_BYTES_PERCENT`: optional
overrides applied to 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.
- Attack detection (R9). By default the Core Rule Set reads the method, the URL
with its query string, and the headers of every request, which 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 and comment text, wiki pages, files saved in the web editor and
package descriptions, and 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`): directory of rule
files, read at start and again whenever a file in it changes; format under
"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`), `SWWAF_WAF_ANOMALY_THRESHOLD`
(default `5`): the Core Rule Set's own two tuning values, at the Core Rule
Set's own defaults.
- `smallwebwaf` also 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
out of the box, ask to be sent back to `http://127.0.0.1` on the
user's own machine, and the server never fetches that address.
- The query parameters in which gitea sends file paths, branch and
workflow names and the page to return to after signing in (`path`,
`files`, `skip-to`, `sub_path`, `ref`, `sha`, `branch`, `workflow`,
`artifactName` and `redirect_to`) are not checked against the Core
Rule Set's lists of system files (930120), shell paths (932160) and
command names (932260). In a repository any name on those lists can be
an ordinary file or branch, such as `.gitignore`, `package.json`,
`docker-compose.yml`, `bin/docker-entrypoint` or a branch named
`docker-build`, and gitea reads these values as names within a
repository or its own records, or as a page of its own site. Like the
other changes, this one holds in front of every app, not only gitea,
and no setting restores the three rules in those parameters. What only
these three rules refuse is let through there, and that is more than a
name such as `/etc/passwd` or `whoami` on its own: commands such as
`|cat /etc/passwd`, `wget http://…` and `nc -e /bin/sh …`, and
`file:///etc/passwd`, pass as well. Path traversal (`../`), SQL and
script injection and PHP, Java and Node.js code are still refused
there, and 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, gets no help from the three rules there (see "Risks the
design has to handle").
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies, and does not check `Referer` for a Unix command
given without arguments (932340) or for Java starting a process
(944110). Gitea writes into `gitea_flash` a message for the next page
naming what was just done, such as a file deleted in the web editor or
a branch, milestone or project created, and into `redirect_to` the
page to return to after signing in, often the one the visitor clicked
"Sign in" on; `Referer` is the address of the page a request comes
from. In these cookies the Core Rule Set takes names such as
`package.json`, `document.write.js` or `Get-ChildItem.ps1`, and titles
that hold them, for attacks. In `Referer` it takes the address of a
search for one word, such as `env`, `set` or `last`, when the address
ends in it, and the address of OpenJDK's
`src/java.base/share/classes/java/lang/Runtime.java`, which holds both
`runtime` and `java.`. A browser sends such a cookie with every
request until gitea replaces or removes it, which gitea cannot do
while `smallwebwaf` refuses those requests, so one refusal would keep
the browser out of the whole site. A browser names a page in `Referer`
on everything the page loads and on every link followed from it, so
all of those would be refused. Gitea shows the message with any script
removed and returns only to a page of its own site. Like the other
changes, this one holds in front of every app: an attack sent in a
cookie of either name is not refused by the Core Rule Set, nor is a
Unix command without arguments or Java starting a process sent in
`Referer`. Every other cookie is read in full, and `Referer` keeps the
other rules, script and SQL injection among them.
- 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.
- `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 the rules that
refuse a request for the type of its body, when it is missing or not on
the Core Rule Set's short list (920340, 920420, 920640); for its file
extension, such as `.sh` or `.sql` (920440); and for a file or directory
name in its path, such as `.git/`, `.gitignore`, `Dockerfile`,
`package.json` or an editor's settings directory (930130, 930140). 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, such as `/.env`, `/.git/config`,
`/.aws/credentials`, `/.ssh/id_rsa`, `/.htpasswd` and `/wp-config.php.bak`
(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`, has the Core Rule Set read form data and multipart
bodies up to that size, streaming the rest of a longer one on without
holding it in memory, and JSON and XML bodies no larger than it, since
those cannot be read in part. Any other body is not read, since the Core
Rule Set would read it as form data, where binary content such as a git
push trips rules written for text. Body inspection suits apps whose forms
carry no code. In front of gitea it refuses 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), and attachments named like `debug.log` or
`config.yml` (932180), until the rule ids the request log names are added
to `SWWAF_WAF_DISABLED_RULES`.
- `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 env-var short form of a `path`
rule with the `ban` action, for deployments that mount no rule files.
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): requests per client per
minute that `smallwebwaf` refused after a rule file or Core Rule Set
match, or for 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, container image and package clients are
answered 404 by design, for each layer a push checks and each package a
lookup asks about, often hundreds of times a minute, and git and registry
clients are answered 401 at the start of every push, every fetch from a
private repository and every image pull.
- Bans (R5), following the rules under "Bans"
- `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 (R6). No source is on by default, for the reason the country lists
and the biased thresholds are empty: a source refuses the clients that a list
kept elsewhere names, or lowers their limits, while the defaults judge a
client only by what it does to this service. 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, and Spamhaus may block an address that downloads it too
often. Each `smallwebwaf` fetches its own copy, so 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. Refreshed every `SWWAF_BLOCKLIST_REFRESH`
(default `24h`); 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`: optional resolver address, since public resolvers
are refused by several list operators.
- `SWWAF_ABUSEIPDB_KEY`, `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`),
`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`, `SWWAF_CROWDSEC_LAPI_KEY`: optional. If a
CrowdSec engine exists on the host, pull its decision list on a schedule
and treat listed addresses as banned. This is how a fleet-wide blocklist
can arrive without `smallwebwaf` depending on CrowdSec. The list is kept
like a fetched blocklist, and a listed client's request makes a ban with
the cause `crowdsec` that lasts as long as CrowdSec's decision, so a long
list does not fill `bans.json`.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>`,
or `log`, for DNSBL and API 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`), `SWWAF_REPUTATION_TIMEOUT`
(default `2s`).
- Alerting (R3)
- `SWWAF_ALERT_WEBHOOK_URL`: JSON POST; schema below.
`SWWAF_ALERT_WEBHOOK_HEADERS`: optional `Name:value` pairs for
authentication.
- `SWWAF_ALERT_SLACK_WEBHOOK_URL`: Slack incoming webhook, formatted
message.
- `SWWAF_ALERT_NTFY_URL` (full topic URL), `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 a rule file or
state file edited while running that does not parse, a replacement lookup
database that cannot be read, or a state file that cannot be written.
- `SWWAF_ALERT_COOLDOWN` (default `15m`): the same event type for the same
client or netblock 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.
- Anomaly thresholds, alert only, nothing is blocked (R4). None is set by
default, and a threshold left unset sends no alert: these only 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`,
`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`, `..._PER_HOUR`.
- Per AS number, which needs a lookup source:
`SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE`, `..._PER_HOUR`.
- Whole service: `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE`, `..._PER_HOUR`,
`SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE`, `..._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`, `..._PER_HOUR` applied to each named block
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.
## Rule files
A required feature: `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, so that 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, metrics, alerts and ban notes.
- `target`: what the regex is matched against.
- `path`: the URL path as received, before any decoding.
- `query`: the raw query string.
- `uri`: path and query together, both as received and once percent-decoded,
so an encoded probe cannot slip past.
- `method`, `host`, `user_agent`, `referer`.
- `header:<Name>`: any one request header.
- 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 (see "Bans").
- `ban`: the request is a clear sign of attack. Refuse it and ban the
client's netblock for seven days (`SWWAF_ATTACK_BAN_DURATION`); any
further request during those days, or a later clear sign of attack, makes
the ban permanent (see "Bans"). Use it only for requests no real visitor
sends, and 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, and 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
case-insensitive. 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. While running, the
same faults leave the rules as they were, including the earlier version of
that file, and the log and one `file_error` alert name the file and line; once
the file is fixed, it is read again. A `SWWAF_RULES_DIR` that does not exist
stops the start with a message naming it. An empty directory is not an error:
the log says that no rules were loaded. 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. Docker
does not show a single mounted file being replaced on the host, which is how
many editors save, 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.
- In `SWWAF_MODE=observe` every action is logged as what would have happened and
nothing is refused.
- 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, and its
path rules are 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 or search crawler may open. Its path rules ban the common
probes for secrets, version control directories, backups and logs. The Core Rule
Set's rules for file names and extensions (930130, 920440) refuse such names
anywhere in a path, which is why the default `SWWAF_WAF_DISABLED_RULES` switches
them off: a code forge serves such files deeper in its paths. 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.
No database is used, and nothing is read from disk while serving a request. 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.
- 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, expiry (`null` for permanent), what caused it (`attack`, `limit`,
`admin` or `crowdsec`), a short reason, when it was lifted if an admin
lifted it, and a `notes` field (below).
- `clients.json`: per client, its minute, hour and day counters and its
history since it was first seen: first and last time seen, the AS number,
AS name and country last looked up and when, total requests and bytes in
each direction, how many requests were forwarded and how many refused,
responses by status class, and offences by kind. Clients that were never
banned are kept too, so every client's history survives a restart;
`GET /_smallwebwaf/clients/<ip>` shows it.
- `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; up to
100,000, each for 7 days.
- `reputation.json`: the last good copy of each list fetched from a URL,
cached DNSBL and reputation API verdicts, each with the time it was
fetched, and the AbuseIPDB checks spent today, so a restart does not reset
the daily budget.
- `alerts.json`: the anomaly counters for surrounding netblocks, AS numbers,
named netblocks and the whole service; for each event type and client or
netblock, when it was last sent and how many repeats the cooldown has held
back since; the alerts sent this hour; and alerts still waiting to be
sent.
- Ban notes. The notes on a ban hold what an admin needs to decide whether to
lift it, 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 ids and target that matched, or the limit, its
window, the count reached and the client's limit percentage with what set
it; and any reputation sources that listed the client;
- the requests that caused the ban, up to the last ten: time, method, host,
path with its query string, status and user agent, each text cut to 256
bytes;
- how many requests counted toward the ban, and the time span over which
they came;
- 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.
- Size and disk writes. Each file is rewritten whole, so each has a bound:
- `clients.json` takes about 1 KiB per client. Clients are dropped only when
the table is full, so on a public service the file grows to the default
`SWWAF_MAX_TRACKED_CLIENTS` of 20,000, about 20 MiB. Written every 15
minutes, that is under 2 GiB of disk writes a day.
- `bans.json` takes about 2 KiB per ban and at most about 8 KiB, since the
texts in the notes are cut short. At the default `SWWAF_MAX_BANS` of 5,000
it is about 10 MiB, and never more than about 40 MiB, plus whatever bans
an admin made. It is written when a ban is made, lifted or made permanent,
at most once every 10 seconds, and otherwise with the 15-minute write, so
its writes follow the bans made: with a full file, a hundred new bans a
day come to about 1 GiB of disk writes.
- `lookups.json` takes about 150 bytes per answer, about 15 MiB when full.
Written every 15 minutes, that is under 1.5 GiB of disk writes a day.
- `reputation.json` and `alerts.json` 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. The aim is 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 instead, which halves their size and
lets `grep` show everything about one client.
- 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 is made,
lifted or made permanent. Changes that only update the counts in its notes
wait for the write every `SWWAF_STATE_COUNTER_INTERVAL`, as all of
`clients.json`, `lookups.json`, `reputation.json` and `alerts.json` 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 (below), 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, and since writes are atomic
a broken file can only come from a hand edit, which the editor should hear
about.
- 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, renames the edited file to `<name>.bad` (for
example `bans.json.bad`) so the edit is kept, 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.
- `SWWAF_STATE_DIR` not writable at start: the process exits. A write that fails
while running: state stays correct in memory, the failure is logged, counted
in metrics, sent as a `file_error` alert once per cooldown, and retried at the
next write.
- What a hard kill can lose: ban changes from the last `SWWAF_STATE_WRITE_DELAY`
(10 seconds), and 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.
stdout is always on. When `SWWAF_LOG_REMOTE_URL` is set the same lines are also
sent to the remote endpoint, 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`, 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`, and `admin` for a request `smallwebwaf` answered at one of
its own endpoints), `would_action` in `observe` mode, `limit_percent` and
which rule set it, `counts` (the client's minute, hour and day request and
byte totals after this request), `limit_hit` (which window), `rule_ids` (rule
file rules that matched), `waf_rule_ids`, `waf_score`, `reputation` (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`,
`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:
- syslog forms send each line as the message of an RFC 5424 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 endpoint never delays
a request and never stops stdout; it reconnects with backoff, drops the
oldest lines when the buffer is full, and counts the drops in 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` (see "Admin endpoints"). While the token is unset, the
metrics are off. The token is separate from `SWWAF_ADMIN_TOKEN`, so a scraper
that holds it cannot manage bans.
- 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 or bytes), size and
time limit hits by limit, offences by kind, bans created by cause, permanent
bans, active bans (gauge); 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 (label limited to the
rules that actually fired).
- Lookup and reputation: requests and bytes by AS number and by country, limited
to the `SWWAF_METRICS_TOP_N` (default `50`) 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; age of
each blocklist and lookup database.
- Housekeeping: tracked clients (gauge), state file writes, write failures, last
successful write time and size per file; files read again after an edit, and
edits set aside because they did not parse; alerts sent, failed and suppressed
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
`smallwebwaf` has one listener, on port 8080. 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, which the
container's health check does (see "Deployment"). 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 (see "Metrics endpoint"); needs
`SWWAF_METRICS_TOKEN`.
- `GET /_smallwebwaf/bans`, `POST /_smallwebwaf/bans` (client or netblock,
duration, reason), `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`. Current counters,
history, lookup result, reputation, offences, and bans with their notes; for
answering "why was this address refused" and "what has this netblock been
doing".
- A token is sent as `Authorization: Bearer <token>`, and one set shorter than
32 characters stops the start (see "Configuration surface"). 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, and 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, so a client guessing tokens is 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)
- `client`, `netblock`, `asn`, `as_name`, `country`
- `reason`: 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`: number of identical alerts held back by the cooldown
## Deployment
The recommended deployment puts `smallwebwaf` inside the app's own container:
the app's Dockerfile builds `FROM` the `smallwebwaf` image. Each app remains one
container, deployed as before, and an app is hardened just by changing its base
image and perhaps installing on top of it the packages it needs. In the
container, `smallwebwaf` listens on port 8080, which traefik routes to, and
forwards each request to the app on `127.0.0.1:8081`, its default backend
(`SWWAF_UPSTREAM_URL`). No setting is required.
The image holds:
- Alpine Linux, so that an app built on it installs packages with `apk add`.
- runit, and `runsvinit` as the entrypoint. `runsvinit` starts runit's
`runsvdir`, which starts a `runsv` for each directory under `/etc/service`;
each `runsv` runs the `run` script in its directory, and runs it again
whenever it exits.
- The `smallwebwaf` binary, and a user of its own, `smallwebwaf` (uid and gid
65532).
- The service directory `/etc/service/smallwebwaf`, whose `run` script waits one
second (`sleep 1`), makes `SWWAF_STATE_DIR` 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 (see "Rule files").
- Port 8080 declared (`EXPOSE 8080`), and the health check described below
(`HEALTHCHECK`).
Beyond its `FROM` line, the app's Dockerfile adds:
- its binary;
- any packages it needs, with `apk add`;
- its runit service: a directory under `/etc/service` named after the app, never
`smallwebwaf`, whose `run` script starts with `sleep 1` and then starts the
app with `chpst`, listening on `127.0.0.1:8081`, as a user of its own that the
Dockerfile creates. 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:<pinned digest>
# Packages the app needs, if any.
RUN apk add --no-cache tzdata
# The app's binary, and a user of its own to run it.
COPY app /usr/local/bin/app
RUN adduser -D -H -s /sbin/nologin app
# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run
```
with `app.run` beside the Dockerfile:
```sh
#!/bin/sh
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
```
The two processes:
- `runsvinit` is the container's first process and runs as root, as do runit's
`runsvdir` and the `runsv` that watches each service. Each `run` script starts
as root and hands over to its program's user with `chpst`: `smallwebwaf` runs
as `smallwebwaf`, the app as its own user.
- Both start with the container's environment variables, which is why every
setting of `smallwebwaf` carries the `SWWAF_` prefix. Both write to the
container's output, which `docker logs` shows: the JSON lines of `smallwebwaf`
(see "Request log") and whatever the app writes.
- When `smallwebwaf` or the app exits, for whatever reason, runit runs its `run`
script again, which waits one second before starting it. The other process
keeps running, and the container does not stop. While `smallwebwaf` is down,
nothing answers on port 8080 and traefik answers `502`; while the app is down,
`smallwebwaf` answers `502` (see "Failure behaviour").
- A `smallwebwaf` that stops at start, for example on invalid configuration, is
started again every second and stops again, logging its message each time,
until the cause is fixed. The health check fails meanwhile.
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both
services, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
writes its state files as it stops (see "Persistent state").
- 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` passes while `smallwebwaf` answers
`GET /_smallwebwaf/healthz` on `127.0.0.1:8080` and the app accepts connections
at the address in `SWWAF_UPSTREAM_URL`, and fails when either does not. The
container therefore shows as healthy only while both processes are up. An app
with a health check of its own can replace the image's `HEALTHCHECK` with one
that checks both.
Ports: `smallwebwaf` listens on port 8080 on every address and on no other port;
its health check, metrics and ban management are all on that listener, under
`/_smallwebwaf/` (see "Admin endpoints"). The app must leave port 8080 free. It
listens on `127.0.0.1:8081` only, so that nothing outside the container reaches
it except through `smallwebwaf`: 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 `/var/lib/smallwebwaf`
(`SWWAF_STATE_DIR`), a directory of its own beside the app's data, which the app
keeps in directories of its own, such as `/var/lib/app`. Without a volume there,
the files live in the container: they 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 of
`smallwebwaf` makes it belong to the `smallwebwaf` user, so a host directory
mounted there needs no change of owner. It is a volume of its own, separate from
the app's, holds a few tens of MiB at most with the defaults (see "Persistent
state"), and needs no backup beyond whatever the host already does. The image
declares no volume, since every app image built on it would inherit it.
Milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14) writes no state
files and needs no volume.
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, which now starts `FROM` the `smallwebwaf` image,
and runs the one container as it runs any app, with the app's traefik labels,
environment variables and volumes. The labels route to port 8080
(`traefik.http.services.<name>.loadbalancer.server.port=8080`), any `SWWAF_`
settings go with the app's environment variables, and the volume for
`/var/lib/smallwebwaf` goes beside the app's own.
- The endpoints of `smallwebwaf` are reached through traefik like any other
request, for example `https://app.example.invalid/_smallwebwaf/metrics` for a
scraper that sends `SWWAF_METRICS_TOKEN` (see "Admin endpoints").
- Rollout per service: build the app's image on the `smallwebwaf` image, with no
setting, and deploy it as before. It protects the app from the first request,
and 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 endpoint, and an app-specific rule file.
- Notes specific to gitea, built on the image like any other app:
- SSH access to gitea does not pass through `smallwebwaf` and is not
protected by it.
- A clone is a response, and fits the defaults of 30 minutes and 5 GiB for
all but the largest repositories and slowest links. A push is a request,
and so is every other upload: at the defaults, one larger than 100 MiB or
taking more than 60 seconds is cut off, whether it is a git push, an LFS
object, a container image layer, a package file or a release attachment.
The client is answered `413` for a body that is too large, before anything
reaches gitea when the request announces its size, or `408` for one that
is too slow; the upload fails, and no one is banned for it. A gitea that
takes large uploads needs `SWWAF_CLIENT_REQUEST_MAX_BYTES`,
`SWWAF_UPSTREAM_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised to fit. The Core Rule Set does not
read an upload's body, which streams through without being held in memory.
- At the defaults (see "Configuration surface", attack detection), the Core
Rule Set lets gitea's ordinary use through, apart from the refusals in the
next note: browsing and views of files in a repository, with their
history, blame and the file tree; diffs, including their hidden lines and
large files, and pull request review; git's clone, fetch and push over
HTTP; signing in, including the return to the page a visitor came from and
sign-in with Git Credential Manager, git-credential-oauth or tea; the
API's calls for a file and its commits; pushing and pulling container
images and packages; Actions runners, and artifacts uploaded with
`actions/upload-artifact@v4`; and posting issues, pull requests, comments,
wiki pages and files saved in the web editor, code included, since no body
is read, and the page gitea shows after a change with its message naming
what was done, such as a file deleted or a branch, milestone or project
created.
- The Core Rule Set can still refuse the requests below. Each refusal
answers only that request, with 403, and bans no one by itself: it counts
toward the error burst, which a person does not reach this way. The
request log names the rule in `waf_rule_ids`, which
`SWWAF_WAF_DISABLED_RULES` can switch off.
- A query string that reads to it as an attack, most often a search: one
for a name on its lists of system files and commands, such as
`package.json`, `.gitignore` or `docker-compose.yml`, or for text that
starts with a command name, or whose first word has one right after a
`/`, such as `python3`, `ssh key` or `feat/docker-support`; or one
holding a shell command with its options or a system path (`ls -la`,
`sed -i`, `/bin/sh`), a command in backticks, script code (`fetch(`,
`${VAR}`, `process.env`), HTML (`<img src=`), an SQL statement
(`SELECT * FROM users WHERE`), or a URL naming an IP address or
`localhost`. The lists refuse such a name in any other query parameter
too, such as a release attachment uploaded through the API as
`docker-compose.yml`.
- A branch, tag or file name that starts with `docker-`, `python3`,
`ansible`, `base64`, `whoami` or another entry on the Core Rule Set's
list of commands (932260), or that has one of these right after a `/`,
such as `feat/docker-support`, `fix/docker-build` or
`renovate/docker-build-push-action-6.x`, where gitea's own pages send
it in a query parameter that 930120, 932160 and 932260 still check:
`refSubUrl`, the branch or tag of a directory listing, sent when the
listing asks for its entries' last commits in a second request, as it
does whenever gitea takes more than a second to work them out; `name`,
when a branch is deleted or restored on the branches page; `tag`, when
a release is started from a tag; `template`, the file of an issue
template, when an issue is opened from it; and `rule_name`, when a
branch protection rule is opened for editing in the repository's
settings or has just been saved. For a branch named `docker-build`, a
directory listing that makes that second request leaves those last
commits out and shows an error, and the branches page shows an error
instead of deleting or restoring the branch. A release started from
such a tag, an issue from such a template or such a rule opened for
editing gets the 403 answer of `smallwebwaf` in place of its form.
Saving such a rule stores it, but the branch settings page gitea then
returns to gets the 403 answer of `smallwebwaf`, so the save looks as
if it failed.
- A path: a file name ending in `~`, or an `.xhtml` file whose path
holds a space.
- Artifact uploads from `actions/upload-artifact@v3` send the header
`Content-Range`, which the Core Rule Set refuses (920450). The action
sends two files at a time, does not retry a 403 and sends no further file
once one is refused, so an upload is refused once or twice, however many
files it holds, and the step fails. One upload does not ban the runner;
more than 30 such refusals from its address within a minute, as when many
jobs upload at once, ban it through the error burst.
`actions/upload-artifact@v4` does not send the header.
- An Actions runner that reaches gitea through `smallwebwaf` sends requests
all day. One older than version 0.4 (April 2026) asks for work every 2
seconds, 43,200 requests a day, and reports a running job's log and state
every second, so it passes the day limit of 50,000 after about an hour and
a quarter of jobs: it is banned, the job it is running fails, and each
repeat within a day makes the ban longer. Newer runners ask less often,
but a busy one, or several on one address, can still pass it. Put the
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`, which takes them out
of the request and byte limits while the error burst, attack detection and
bans still apply.
- Archive download and blame or history pages are what scrapers hammer;
request limits do most of the work there.
## Failure behaviour
- Lookup database: a configured file that is missing or unreadable at start
stops the start. A replacement that cannot be read while running is ignored:
the file already loaded stays in use, the problem is logged, one `file_error`
alert.
- GeoJS slow, down or refusing `smallwebwaf`: clients with a kept answer are
unaffected, new clients count as unknown, `smallwebwaf` keeps asking with
backoff, one `source_failure` alert per cooldown.
- Reputation source down or over quota: no verdict, service continues, one
`source_failure` alert per cooldown.
- Alert destination down: retried with backoff from a bounded queue, oldest
dropped first, drops counted in metrics.
- App down or restarting: 502 from `smallwebwaf`, not counted as client
offences.
- `smallwebwaf` or the app exiting: runit starts it again a second later, and
the other keeps running (see "Deployment").
- Attack detection engine error on a request: request is forwarded, error logged
and counted.
- Remote log endpoint down: stdout continues, lines are buffered then dropped
oldest first, drops counted in metrics.
- State file write fails while running: memory stays authoritative, logged,
counted, one `file_error` alert per cooldown, retried.
- A state file or rule file edited while running that does not parse:
`smallwebwaf` keeps running on what it has. The state file is set aside as
`<name>.bad` and written again from memory; the rule file is left as it is.
Logged, one `file_error` alert.
- In short: the only things that stop the process happen at start: invalid
configuration, a configured lookup database that is missing or unreadable, a
rule file or state file that does not parse, and an unwritable
`SWWAF_STATE_DIR`. It is then started again every second by runit, and stops
again until the cause is fixed. Once running, a broken helper or a broken edit
never takes the protected service down. The nearest it comes is GeoJS failing
while `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set: new clients then cannot be
placed, and that list refuses them, as it refuses every client it cannot
place.
## Risks the design has to handle
- Forged `X-Forwarded-For`: handled by believing the header only from a peer
inside `SWWAF_TRUSTED_PROXIES` and by walking it from the right. The default
trusts every private address, which in the intended deployment means traefik;
where other containers can reach `smallwebwaf` directly, or visitors on a
private network reach traefik, they can name another address in the header,
and setting `SWWAF_TRUSTED_PROXIES` to traefik's own network closes that gap.
- Many clients behind one address (mobile carriers, offices, Tor): they share
its limits and its bans. The default limits sit several times above what one
busy person produces, `SWWAF_RATE_LIMIT_EXEMPT_NETS` and `SWWAF_ALLOW_NETS`
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 seven days, and the
visitor's next request during those days makes the ban permanent. So the
default rule file keeps to requests no real visitor sends, with its paths
anchored at the site root, where no app serves secrets, version control
directories, backups or web shells. A visitor banned by mistake is let back in
by lifting the ban in `bans.json`.
- IPv6 address rotation inside a /64: handled by grouping.
- Widely distributed scrapers using thousands of addresses at low rates each:
per-client limits do not see them. The AS number and netblock anomaly alerts
reveal them once their thresholds are set, and a low `SWWAF_ASN_LIMIT_PERCENT`
for that AS number is the response: it lowers the limits of each of its
clients. There is no shared budget for a whole AS number or country, which one
abuser could use up and so lock out everyone else there.
- Core Rule Set false positives against real apps (a search for code, and any
body once `SWWAF_WAF_BODY_LIMIT` is set): 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. Further gitea requests the Core Rule Set may refuse
at the defaults, found by reading gitea's source rather than a running gitea,
are collected in https://git.eeqj.de/sneak/smallwebwaf/issues/30 and checked
when milestone 1 runs in front of a real gitea.
- Attacks carried in request bodies: not refused by default, since on a code
forge bodies are full of code the Core Rule Set takes for attacks. Most of
what shows in URLs and headers is still refused, the client that sends an
attack is still subject to the rule files, the limits and the bans, and
`SWWAF_WAF_BODY_LIMIT` switches body inspection on for apps whose forms carry
no code.
- An app that uses `path`, `ref` or another of the query parameters left out of
930120, 932160 and 932260 (see "Configuration surface", attack detection) as a
file on the server, or passes it to a shell: that change holds in front of
every app and no setting restores the three rules, so what only they refuse
reaches the app in those parameters, such as `/etc/passwd`, `|cat /etc/passwd`
or `nc -e /bin/sh …`. Path traversal (`../`) is still refused there. Such an
app has to check those values itself, or refuse what it must never receive in
them with a rule file. So does an app that reads a cookie named `gitea_flash`
or `redirect_to`, which the Core Rule Set does not read, 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 a missing or wrong token
counts toward the error burst, so a client guessing tokens is soon banned.
Tokens are meant to be long random values, and 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; a token given as a file
that only the `smallwebwaf` user can read (`SWWAF_ADMIN_TOKEN_FILE`,
`SWWAF_METRICS_TOKEN_FILE`) is out of the app's reach.
- GeoJS, the default lookup source: every new visitor's address goes to a third
party, and a swarm of fresh addresses, when lookups peak, is when GeoJS may
slow down or block `smallwebwaf`. Keeping answers for 7 days and asking about
many addresses in one request keep the number of requests low; the file source
has neither risk.
- Slow-request attacks: `SWWAF_CLIENT_REQUEST_TIMEOUT` bounds how long a request
may take to arrive, `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` how large its
headers may be, and `SWWAF_CLIENT_IDLE_TIMEOUT` closes a kept-open connection
that sends nothing more.
- Alert floods: cooldown and hourly cap.
## Build order
- First: proxy with its size and time limits, client identification, static
lists, three-window request limits and the bans they lead to, the ban ledger
and the JSON state files with edits taken in while running, exemptions,
`observe` mode, the full request log on stdout, the metrics endpoint, health.
- Second: rule files, admin endpoints, alerting to all three destinations,
remote log sending.
- Third: AS number and country lookup from the file or GeoJS, the country lists,
biased thresholds, byte limits, anomaly thresholds.
- Fourth: blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed.
- Fifth: attack detection with Coraza and the Core Rule Set, trap paths, error
bursts.
- Each stage is usable on its own; the first three already cover the traffic
problem the fleet has today.