check / check (push) Failing after 2s
The Dockerfile's last stage is now the image of "Deployment" in SPEC.md: Ubuntu 26.04 with ca-certificates, nix-bin and runit from a dated snapshot whose InRelease files are checked by hash, nixpkgs from its release file checked by SHA-256, runsvinit built at a fixed commit, and smallwebwaf as a runit service. smallwebwaf answers /_smallwebwaf/healthz, and `smallwebwaf healthcheck`, which takes no further argument, is the image's HEALTHCHECK. script/example-app builds an app on the image and checks it end to end. The Nix profile comes last on the PATH: first, busybox from nixpkgs replaced runit's own runsvdir and sv. SPEC.md is corrected to match what was built. Model: opus-5-5
1643 lines
102 KiB
Markdown
1643 lines
102 KiB
Markdown
# smallwebwaf SPEC (draft): protective reverse proxy for one app
|
|
|
|
Status: fourth draft, with the owner's rulings to date applied. Milestone 1 of
|
|
the build order is built. `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 image built on Ubuntu 26.04 LTS
|
|
with nixpkgs installed, which 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_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
|
|
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: `smallwebwaf` passes bodies through unchanged, so a limit on
|
|
each side would bound the same bytes and the lower one would always decide.
|
|
Bodies stream straight through, so a request body reaches the app while the
|
|
client is still sending it.
|
|
- `SWWAF_CLIENT_REQUEST_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 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_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long `smallwebwaf`
|
|
may take to connect to the app and send it the whole request.
|
|
- `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, as
|
|
the client sends it and the app receives it.
|
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body, as
|
|
the app sends it and the client receives it.
|
|
- 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.
|
|
- Since a request body streams through, each side can hold up the other: a
|
|
slow client slows the send to the app, and an app slow to take the body
|
|
slows the client's send. So while a request body is still on its way, a
|
|
request timeout that runs out, `SWWAF_CLIENT_REQUEST_TIMEOUT` or
|
|
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`, answers `408` if `smallwebwaf` was
|
|
waiting for the client to send more at that moment, and `504` if it was
|
|
waiting for the app to take what it had.
|
|
- Go's HTTP server, on which `smallwebwaf` is built, reads a request's line
|
|
and headers before `smallwebwaf` sees the request. A client that takes
|
|
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets no answer:
|
|
the server closes its connection. Headers over
|
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are answered `431` by the server
|
|
itself. Neither request gets a line in the request log.
|
|
- 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 in memory for 7 days, apart from the table of
|
|
clients, so it 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.
|
|
Writing the answers to `lookups.json` and reading them back at start, so
|
|
that they survive a restart, comes in milestone 3 or later
|
|
(https://git.eeqj.de/sneak/smallwebwaf/issues/17); until then a restart
|
|
loses them.
|
|
- `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. This file comes in milestone 3 or later; until
|
|
then the answers are kept in memory only.
|
|
- `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,
|
|
apart from those Go's HTTP server ends before `smallwebwaf` sees them (see
|
|
"Configuration surface", size and time limits). 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, from nixpkgs, 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:
|
|
|
|
- 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.
|
|
- Three packages from Ubuntu, `ca-certificates`, `nix-bin` and `runit`,
|
|
installed from a dated snapshot of Ubuntu's archive and checked by hash (see
|
|
"Packages from Ubuntu" below). The Ubuntu image has no CA certificates, and
|
|
they come with `nix-bin` only because a library it uses recommends them, so
|
|
`ca-certificates` is installed by name. Without it Nix cannot download
|
|
packages, and `smallwebwaf`, unable to reach GeoJS, would count every visitor
|
|
as coming from an unknown country.
|
|
- Nix, the package manager, from Ubuntu's own `nix-bin` package, and nixpkgs,
|
|
the collection of packages Nix installs from, fixed at one commit (see
|
|
"Packages from nixpkgs" below). Root uses Nix directly, and no Nix daemon runs
|
|
in the container. Nix run by root expects a group of build users, `nixbld`,
|
|
which `nix-bin` does not create, so the image writes `build-users-group =` to
|
|
`/etc/nix/nix.conf`, and root's builds then run without build users.
|
|
- runit, from Ubuntu's own `runit` package, and `runsvinit` as the entrypoint,
|
|
as the owner's code style guide asks for service containers. Neither Ubuntu
|
|
nor nixpkgs packages `runsvinit`, so the image builds it from its source
|
|
(`github.com/peterbourgon/runsvinit`) at a fixed commit hash. Its repository
|
|
is archived and has not changed since 2015, and its last tag is `v2.0.0`. It
|
|
has no `go.mod`, and `go build` of its directory needs one, so the build
|
|
writes one; since `runsvinit` uses only Go's standard library, that file names
|
|
nothing else. `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. Ubuntu's runit looks for
|
|
services in `/etc/service` too, so when `docker stop` has `runsvinit` stop
|
|
each service with runit's `sv`, `sv` finds it.
|
|
- 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` 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 (see "Rule files").
|
|
- Port 8080 declared (`EXPOSE 8080`), and the health check described below
|
|
(`HEALTHCHECK`).
|
|
|
|
Every `run` script, the app's included, is a bash script that 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`, whose `run` script waits one second (`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 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:<pinned 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 "$@"
|
|
```
|
|
|
|
Packages from Ubuntu: the image installs `ca-certificates`, `nix-bin` and
|
|
`runit` from Ubuntu's snapshot service, which serves the archive as it was at a
|
|
given moment, rather than from the archive itself, whose packages change with
|
|
every update. The image's Dockerfile names that moment, in apt's
|
|
`--snapshot 20261001T000000Z` form, on both `apt-get update` and
|
|
`apt-get install`. That moment is never earlier than the date of the pinned
|
|
Ubuntu image, since packages from an older snapshot can need older versions of
|
|
packages the Ubuntu image already holds, and it moves forward whenever that
|
|
image's digest does. `apt-get update` keeps the snapshot's `InRelease` files,
|
|
which apt checks against the archive's signature, in `/var/lib/apt/lists/`; each
|
|
lists the SHA-256 hash of the package lists it covers, and each package list the
|
|
hash of every package in it. The Dockerfile also names the SHA-256 hash of each
|
|
of the snapshot's `InRelease` files, and the build checks them after
|
|
`apt-get update` and before `apt-get install`, so every package apt installs is
|
|
checked, through those files, against hashes the Dockerfile names.
|
|
`apt-get update` also fetches the live archive's `InRelease` files into the same
|
|
directory; their hashes change whenever the archive does, and the install does
|
|
not use them, so the check leaves them out. The hashes are those of the archive
|
|
for amd64, and so the image is built for amd64: other architectures use Ubuntu's
|
|
ports archive, whose `InRelease` files differ. The snapshot service is reached
|
|
over HTTPS, and the Ubuntu image has no CA certificates of its own, so this one
|
|
install uses those of the Go image that `smallwebwaf` is built in, which is
|
|
pinned by digest too: apt's `Acquire::https::CaInfo` option names that image's
|
|
CA certificate file, `/etc/ssl/certs/ca-certificates.crt`, mounted for that one
|
|
step.
|
|
|
|
Packages from nixpkgs: nixpkgs is fixed at one commit of its newest release
|
|
branch, `nixos-26.05` today. For each commit of the branch that has passed its
|
|
tests, the Nix project publishes a release on `releases.nixos.org`, such as
|
|
`nixos-26.05.11045.774debe7a0d1`, and the image takes nixpkgs from that
|
|
release's file `nixexprs.tar.xz`, not from a GitHub archive of the commit, whose
|
|
bytes can change. The image's Dockerfile names the release and the SHA-256 hash
|
|
of that file, which the release's page lists, and the build checks the hash
|
|
before unpacking it. nixpkgs is set up for root under the name `nixpkgs`, so the
|
|
app's Dockerfile installs a package with `nix-env -iA nixpkgs.<name>`, and
|
|
whatever it installs is on the `PATH` of every service: the image adds root's
|
|
Nix profile, `/nix/var/nix/profiles/default/bin`, at the end of the `PATH`,
|
|
after Ubuntu's own directories, so that no package hides the image's own
|
|
commands. busybox, for one, brings its own `sv`, which looks for services
|
|
elsewhere. Because nixpkgs stays at that commit, an app built on the same
|
|
`smallwebwaf` image gets the same packages each time it is built. Unpacked,
|
|
nixpkgs takes about 500 MiB of disk, more on some filesystems such as ZFS, and
|
|
each package an app installs from it adds its own size, with everything it
|
|
depends on. A newer commit of the branch, with its security fixes, comes with a
|
|
newer `smallwebwaf` image, as do Ubuntu's own fixes; an app takes them by
|
|
changing the digest in its `FROM` line. When nixpkgs makes its next release,
|
|
every six months, the image moves to that release's branch.
|
|
|
|
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` runs `smallwebwaf healthcheck`,
|
|
which 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`, and 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 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"). `SWWAF_LISTEN_ADDR` may set another
|
|
port: the image's health check takes its port from that setting, and traefik's
|
|
port label (`traefik.http.services.<name>.loadbalancer.server.port`) must name
|
|
the same port, and the app must leave that port free. The address part of
|
|
`SWWAF_LISTEN_ADDR` stays empty (for example `:9000`, never `127.0.0.1:9000`),
|
|
so `smallwebwaf` keeps listening on every address: traefik reaches it on the
|
|
container's address, and the health check on `127.0.0.1`. The app 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 and every file in it belong to the `smallwebwaf` user, so
|
|
a host directory mounted there needs no change of owner, and files an earlier
|
|
owner left in it can be read and replaced. 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.
|
|
|
|
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, 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, as does the directory that
|
|
holds any token file.
|
|
|
|
- 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 the
|
|
client sends too slowly (`504` if gitea is too slow to take it); the
|
|
upload fails, and no one is banned for it. A gitea that takes large
|
|
uploads needs `SWWAF_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 (see "Deployment").
|
|
- 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
|
|
|
|
- Milestone 1 (https://git.eeqj.de/sneak/smallwebwaf/issues/13): a pass-through
|
|
proxy with read and write timeouts, size limits and a request log. It
|
|
identifies the client and passes requests and answers through unchanged within
|
|
the four timeouts and the two size limits; the header size and the idle time
|
|
are fixed at their defaults, not settings. Each request gets a line in the
|
|
request log, with part of its fields. It writes nothing to disk.
|
|
- Milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14): per-IP rate
|
|
limits and country allow and deny lists, ready for production.
|
|
- The request rate limits per client, over a minute, an hour and a day. A
|
|
client over one is refused with `429` until it is back under every limit;
|
|
bans come later.
|
|
- The country lists, with each client's country looked up through GeoJS,
|
|
only while a list is set. A client on a private, loopback or link-local
|
|
address has no country, and neither list checks it.
|
|
- Like milestone 1, it writes nothing to disk: the GeoJS answers and the
|
|
rate counters are kept in memory only, and a restart loses them. The
|
|
header size and the idle time stay fixed at their defaults.
|
|
- The container image described under "Deployment", with runit and the
|
|
container's health check. The health check calls `/_smallwebwaf/healthz`,
|
|
so milestone 2 answers that path, although the other admin endpoints come
|
|
later. The image's `/var/lib/smallwebwaf`, which the `run` script gives to
|
|
the `smallwebwaf` user, and `/etc/smallwebwaf/rules.d` come with the state
|
|
files and the rule files.
|
|
- Milestone 3 and later: the rest of the design, in this order:
|
|
- static lists, the bans that broken request limits lead to, the ban ledger
|
|
and the JSON state files with edits taken in while running, exemptions,
|
|
`observe` mode, the rest of the request log's fields, the metrics
|
|
endpoint, and the header size and the idle time as settings
|
|
(`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_CLIENT_IDLE_TIMEOUT`).
|
|
With the static lists comes `SWWAF_ALLOW_NETS`, and from then on
|
|
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses a client on a private,
|
|
loopback or link-local address unless `SWWAF_ALLOW_NETS` lists it;
|
|
- rule files, the other admin endpoints, alerting to all three destinations,
|
|
remote log sending;
|
|
- AS number and country lookup for every client, from the file or GeoJS,
|
|
biased thresholds, byte limits, anomaly thresholds;
|
|
- blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed;
|
|
- attack detection with Coraza and the Core Rule Set, trap paths, error
|
|
bursts.
|
|
- Each stage is usable on its own; milestone 2 is the first to go to production.
|