diff --git a/README.md b/README.md index 8071582..60f7bc6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # smallwebwaf `smallwebwaf` is a simple, fast, logging web application firewall for people who -host their own services. It is one small container that sits between your -reverse proxy (traefik) and one application: traefik points at `smallwebwaf`, -and `smallwebwaf` points at the app. It is configured with environment -variables, keeps its state in memory, and writes a detailed JSON log line for -every request. +host their own services. It runs inside the container of the one application it +protects, between your reverse proxy (traefik) and the app: the app's Dockerfile +builds `FROM` the `smallwebwaf` image, traefik sends the app's requests to +`smallwebwaf` on port 8080, and `smallwebwaf` passes them on to the app on +`127.0.0.1:8081`. It needs no setting, and protects the app from the first +request with defaults chosen for a service on the open internet. It keeps its +state in memory and in JSON files you can read and edit, and writes a detailed +JSON log line for every request. Status: design stage. This repository currently holds the documents only; no code has been written. The design is in [`SPEC.md`](SPEC.md), and the survey of @@ -17,73 +20,106 @@ Small self-hosted sites now receive a great deal of traffic nobody asked for: scrapers that ignore `robots.txt` and crawl every commit of every repository on a public git server, vulnerability scanners walking through lists of WordPress and `.env` paths, and credential-guessing bots. Most of it comes from a small -number of hosting networks and countries. A single-person operation has no -abuse desk and no CDN contract; it needs something small that can be put in -front of one service and left alone. +number of hosting networks and countries. A single-person operation has no abuse +desk and no CDN contract; it needs something small that can be put in front of +one service and left alone. The existing tools each solve part of this. Rule-based firewalls catch attack -payloads but do not limit request rates. Rate limiters count requests but -cannot tell a residential visitor from a rented server farm. The products that -do most of it want several containers, a database and a web console. None of -them can say "clients from these networks get half the normal allowance", which -is the most useful thing to be able to say when nearly all abuse comes from a -known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the -candidates one by one. +payloads but do not limit request rates. Rate limiters count requests but cannot +tell a residential visitor from a rented server farm. The products that do most +of it want several containers, a database and a web console. None of them can +say "clients from these networks are banned after half as many requests as +anyone else", which is the most useful thing to be able to say when nearly all +abuse comes from a known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) +goes through the candidates one by one. `smallwebwaf` is meant to fill that gap: - protect a service from misbehaving scrapers and scanners with per-client request and byte limits over a minute, an hour and a day; -- bias those limits against the countries and AS numbers that abuse commonly - comes from, so their clients get a configured percentage of the normal - allowance rather than an outright block; -- remember abusers, block them for a while, and ban the ones who keep coming - back; +- lower those limits for the countries and AS numbers that abuse commonly comes + from, so their clients are banned after fewer requests than others; +- ban abusers: briefly at first, longer each time they come back, and + permanently when they keep at it; a scanner's first probe bans it for seven + days; - log everything in a form that is easy to search and ship elsewhere; -- stay small enough to understand: one binary, one container, environment - variables, no database. +- stay small enough to understand: one binary in the app's own container, + environment variables, no database, no required setting. ## Proposed features -- Reverse proxy for one upstream application, streaming in both directions, - with WebSocket support. One `smallwebwaf` per app. -- Real client address worked out from `X-Forwarded-For`, trusting only the - proxy networks you list. IPv6 clients are counted by /64. +- Reverse proxy for one application, streaming in both directions, with + WebSocket support. One `smallwebwaf` per app, inside the app's own container. +- Internet-ready out of the box: no setting is required, and every setting has a + default chosen for a service facing the internet in 2026. Every setting's name + starts with `SWWAF_`, so that it cannot clash with the app's own. +- Real client address worked out from `X-Forwarded-For`, trusting only the proxy + networks you list, by default the private address ranges. IPv6 clients are + counted by /64 by default. +- Size and time limits on requests and responses, with the time limits both + between the client and `smallwebwaf` and between `smallwebwaf` and the app: by + default a request may take 60 seconds and 100 MiB, a response 30 minutes and 5 + GiB. - Rate limits per client on requests per minute, per hour and per day, and on - bytes per minute, per hour and per day. + bytes per minute, per hour and per day, on by default and set well above what + real visitors need. - Netblocks that bypass rate limiting, netblocks that bypass everything, and netblocks that are always refused. -- AS number and country lookup for every client from a local database file. +- AS number and country lookup for every client, on by default through the free + GeoJS web service, which is sent the address of every new visitor. The IPinfo + Lite database file, which you download and mount, can be used instead, or + lookups switched off (see "Country and AS number lookup" below). +- Country lists: `SWWAF_DENIED_COUNTRIES` refuses every request from the + countries listed, `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` every request from + anywhere else. Such a request gets the answer a banned client gets as soon as + the client's address has been looked up, before its body is read and without + the rule files or the Core Rule Set looking at it, and no ban is made. - Biased limits: listed AS numbers and countries get a percentage of every - limit, for example 50 percent for common abuse-source networks. Zero percent - refuses outright. + limit, for example 50 percent for common abuse-source networks, so their + clients are banned after fewer requests. Zero percent is a zero allowance: the + first request breaks the limit and bans the client. - Attack detection: - a directory of plain text rule files, one regex per line, for catching - scanning and penetration probes; easy to edit by hand; - - the OWASP Core Rule Set, run by the Coraza engine, in detect-only or - blocking mode; - - trap paths and bursts of error responses. -- Offences add up to a temporary block. Block lengths grow for repeat offenders - (for example one hour, then a day, then a week) and end in a permanent ban. + scanning and penetration probes; easy to edit by hand, and picked up while + running; + - the OWASP Core Rule Set, run by the Coraza engine, refusing the requests + it flags; it reads the URL and headers of every request, and request + bodies only once you switch that on, since on a code forge they are full + of code it would take for attacks; + - trap paths, and a ban for a client that the rule files or the Core Rule + Set refuse again and again. +- Bans: + - a clear sign of attack, such as a probe for a `.env` file or a scanner's + user agent, bans for seven days on the first request, and any further + request during those days makes the ban permanent; + - breaking a limit bans for an hour; breaking one again within a day of a + ban ending triples the length, and a ban that would last longer than seven + days is permanent instead; + - every ban carries notes on why it was made, to help decide whether to lift + it. - IP reputation: downloadable blocklists, DNS blocklists, AbuseIPDB, and an optional feed of decisions from a CrowdSec engine. Lookups happen in the - background and never delay a request. + background and never delay a request. None is on until you add it. - Alerts on attacks and bans to a generic webhook, Slack or ntfy, with a cooldown and an hourly cap so a wide attack cannot flood the channel. -- Anomaly alerts when requests or bytes per minute or hour cross a threshold, - for a single client, its surrounding netblock, an AS number, a named netblock - or the whole service. -- Observe mode: log and alert on every decision while refusing nothing, for the - first days in front of a new service. +- Anomaly alerts when requests or bytes per minute or hour cross a threshold you + set, for a single client, its surrounding netblock, an AS number, a named + netblock or the whole service. +- Observe mode: log and alert on every decision while refusing nothing. - Request log: one JSON object per request on stdout with the usual web log fields, the decision taken and why, AS number and country, and timings. - Optionally also sent to a remote syslog or RELP endpoint. -- Prometheus metrics on their own port. -- State (bans, offender history, hour and day counters, reputation cache) held - in memory and saved as readable, hand-editable JSON files, written atomically. - Nothing is read from disk while serving a request. -- A small admin endpoint for health checks, listing, adding and lifting bans, - and asking why a given address was refused. + Optionally also sent to a remote syslog server. +- Prometheus metrics, for a scraper that holds the metrics token. +- State (bans with their notes, each client's counters and history, the GeoJS + answers, the reputation cache, the alerting state) held in memory and kept in + readable JSON files, written regularly and at every stop, so a restart loses + nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks + up the change. Nothing is read from disk while serving a request. +- Health checks, the metrics, and listing, adding and lifting bans or asking why + a given address was refused, all on the one port every request uses: under + `/_smallwebwaf/` on the app's own address, through traefik like any other + request. The metrics need the metrics token, and the ban management the admin + token. Not planned: TLS termination, routing for several apps, browser challenges (captcha or proof of work), a web console, or defence against floods large @@ -96,54 +132,122 @@ For each request `smallwebwaf`: - works out who the client really is; - lets it straight through if it is on the bypass list, refuses it if it is on the deny list or currently banned; -- looks up its AS number and country, and any cached reputation verdict; +- looks up its AS number and country, and refuses it if that country is denied, + or is not among the only ones allowed; +- checks for a cached reputation verdict; - picks the client's limit percentage from those; -- checks the minute, hour and day request counters against the limits, and - answers 429 if one is exceeded; -- checks the request against the rule files and the Core Rule Set; -- forwards it to the app and streams the response back; -- counts the bytes, records any offence, bans the client if it has collected - enough of them, sends any alerts that are due, and writes the log line. +- checks the minute, hour and day request counters against the limits, and bans + the client if it breaks one; +- checks the request against the rule files and the Core Rule Set, and bans the + client at once for a clear sign of attack; +- forwards it to the app and streams the response back, within the size and time + limits; +- counts the bytes and any refusal by the rule files or the Core Rule Set, bans + the client if it broke a limit, updates its history, sends any alerts that are + due, and writes the log line. -A minimal deployment beside an app in docker-compose: +A minimal deployment is the app's own Dockerfile, built on the `smallwebwaf` +image, with no setting. That image is built on Ubuntu 26.04 LTS, the newest +long-term support release of Ubuntu, pinned by digest, and moves to the next one +when it ships. It has nixpkgs installed, so the app adds the packages it needs +from nixpkgs. Beyond its `FROM` line the app's Dockerfile adds the app's binary, +any packages it needs, and the app's runit service, which starts the app as a +user of its own, listening on `127.0.0.1:8081`: -```yaml -services: - app: - image: example/app - networks: [internal] +```dockerfile +# The smallwebwaf image, pinned by digest. +FROM /smallwebwaf: - waf: - image: /smallwebwaf: - environment: - UPSTREAM_URL: http://app:3000 - TRUSTED_PROXIES: 172.18.0.0/16 - MODE: observe - RATE_LIMIT_PER_MINUTE: "120" - RATE_LIMIT_PER_HOUR: "2000" - RATE_LIMIT_PER_DAY: "10000" - ASN_LIMIT_PERCENT: AS14061:50,AS16276:50 - COUNTRY_LIMIT_PERCENT: CN:25 - ALERT_NTFY_URL: https://ntfy.example.invalid/alerts - volumes: [waf-state:/data] - networks: [internal, traefik] - labels: - traefik.enable: "true" - traefik.http.routers.app.rule: Host(`app.example.invalid`) - traefik.http.services.app.loadbalancer.server.port: "8080" +# 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 ``` -A rule file is one rule per line: a name, what to match against, what to do, -and a regex. +with `app.run` beside the Dockerfile, where `--listen` and `--trusted-proxies` +stand for the app's own options: -``` -env-file path offence:3 (?i)/\.env(\.[a-z]+)?$ -scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b +```bash +#!/usr/bin/env bash +set -euo pipefail +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 ``` -[`SPEC.md`](SPEC.md) has the full design: every environment variable, the rule -file format, the state files, the log fields, the metrics, failure behaviour, -the build order, and the design questions still open for the owner. +- The image's entrypoint, `runsvinit`, has runit start `smallwebwaf` and the app + side by side, each as its own user, and start either again a second after it + exits. Leave out `ENTRYPOINT` and `USER` from the app's Dockerfile. +- `nix-env -iA nixpkgs.` installs a package from the nixpkgs in the image, + and the app finds it on its `PATH`. That nixpkgs is fixed at one commit, so + the same `smallwebwaf` image always gives the app the same packages; newer + ones come with a newer `smallwebwaf` image. +- Deploy it as you deploy any app, with traefik's labels on this one container + pointing at port 8080. upaas needs no change for this. +- The app has to trust `127.0.0.1` and `::1` for forwarded headers, besides the + private address ranges, since the requests it gets now come from `smallwebwaf` + on loopback. An app left at the usual default, the private ranges alone, sees + every visitor as `127.0.0.1`. +- Port 8080 is the only one the app must leave free: the health check, the + metrics and ban management are all on it, under `/_smallwebwaf/`. The image's + health check passes while `smallwebwaf` answers and the app accepts + connections. +- `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf`. Mount a volume + there to keep bans and client history when a deploy replaces the container; + without one, it still starts. + +A rule file is one rule per line: a name, what to match against, what to do, and +a regex. + +``` +env-file path ban (?i)^/\.env(\.[a-z]+)?$ +scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b +``` + +[`SPEC.md`](SPEC.md) has the full design: the deployment, every environment +variable, the ban rules, the rule file format, the state files, the log fields, +the metrics, failure behaviour and the build order. + +## Country and AS number lookup + +`smallwebwaf` looks up the AS number and country of every client, for the +request log, the metrics and the ban notes, and for the country lists and biased +limits when you set them. It works with no setup: by default it asks the free +GeoJS web service, which needs no account and no file. This means that, by +default, the address of every new visitor is sent to GeoJS. Each answer is kept +in memory for seven days, and many addresses are asked about in one request; +writing the answers to disk, so that they survive a restart, comes in milestone +3 or later. GeoJS publishes no rate limit but may block a caller it thinks asks +too much; while it is not answering, new visitors count as coming from an +unknown country, which `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses. + +To keep your visitors' addresses on your own host, set +`SWWAF_LOOKUP_SOURCE=off`, or use the database file instead of GeoJS: +`SWWAF_LOOKUP_SOURCE=file` reads the free IPinfo Lite database +(`ipinfo_lite.mmdb`). You download it with your own IPinfo account, mount the +directory that holds it into the container, point `SWWAF_LOOKUP_DB_PATH` at the +file and refresh it when you choose; `smallwebwaf` never downloads it itself, +and reads it again when you replace it. It has to be the directory rather than +the file itself: docker does not show a single mounted file being replaced, so a +refresh would go unseen. IPinfo releases it under the Creative Commons +Attribution-ShareAlike 4.0 International License and asks for attribution, in +its own words on https://ipinfo.io/lite: "The attribution requirements can be +met by giving our service credit as your data source. Simply place a link to +IPinfo on the website, application, or social media account that uses our data." +Its example of such a credit is a link mentioning "IP address data is powered by +IPinfo". A service that uses the database through `smallwebwaf` should carry +that link. + +Neither source can place a private address, so a client on one, such as a +visitor on your local network, another container or your monitoring, has no +country: `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` refuses it unless you list it in +`SWWAF_ALLOW_NETS`. Such addresses are never sent to GeoJS. ## Documents diff --git a/SPEC.md b/SPEC.md index 2fb5ec6..08a00de 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,338 +1,798 @@ -# smallwebwaf SPEC (draft): protective reverse-proxy sidecar +# smallwebwaf SPEC (draft): protective reverse proxy for one app -Status: second draft, with the owner's rulings on storage, logging and metrics -applied. Nothing has been built yet. `EVALUATION.md` beside this file explains why no existing tool was -chosen. Points that turn on an owner decision are collected under "Questions for -the owner" and are marked "(open)" where they appear. +Status: fourth draft, with the owner's rulings to date applied. Nothing has been +built yet. `EVALUATION.md` beside this file explains why no existing tool was +chosen. ## Purpose -One small container that sits between traefik and one application container. The -traefik router for the public hostname points at the sidecar; the sidecar -forwards to the application. The sidecar limits request and byte rates per -client, detects attacks, blocks abusers for a time and bans repeat offenders, -consults IP reputation sources, looks up the AS number and country of each -client, scales its limits for listed AS numbers and countries, and sends alerts. -Everything is configured by environment variables, apart from the attack -detection rules, which are read from a directory of hand-editable text files. +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 upstream application per sidecar. Run one sidecar per app. +- More than one application per `smallwebwaf`. Each app's image carries its own. - Browser challenges (captcha, proof of work). If wanted, chain Anubis between - the sidecar and the app. + `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; the - only files read are the rule files, which hold one regex per line and nothing - more elaborate. -- Sharing ban state between sidecars in the first version (open, see - Questions). +- 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 in one container, running as a non-root user, - read-only root filesystem, one writable volume for state. -- Three listeners, of which only the first is routed by traefik: - - the proxy listener; - - a metrics listener serving Prometheus metrics, which can be switched off; - - an admin listener for health and ban management. +- 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 configured proxy netblocks. - - Lookup: AS number and country from local database files, held in memory. + `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; the hour and day counts are written out so they survive a - restart. - - Attack detection: regex rules read at start from a directory of plain - text rule files (see "Rule files"), Coraza with the OWASP Core Rule Set, - and simple signals (requests for listed trap paths, bursts of error - responses from the app). - - Ban ledger: offences, active bans and ban history, held in memory and - written to disk as JSON (see "Persistent state"). No database of any kind - is used. + 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 or RELP endpoint (see "Request log"). - - Metrics: Prometheus counters and gauges on their own listener (see - "Metrics endpoint"). + 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, with WebSocket upgrade support. + 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` for attack detection; - - `github.com/oschwald/maxminddb-golang` to read `.mmdb` lookup databases; + `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: the standard library's `log/syslog` is frozen and - does not write the current syslog format (RFC 5424), and no widely used Go - RELP client was found, so the library for this is open (see Questions). + - 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. +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 `TRUSTED_PROXIES`, walk `X-Forwarded-For` from - the right and take the first address not inside `TRUSTED_PROXIES`. - Otherwise use the TCP peer address and ignore the header. - - IPv6 clients are grouped by prefix (`IPV6_GROUP_PREFIX`, default 64) for - counting and banning, because one abuser usually controls a whole /64. + - 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 `ALLOW_NETS`: skip every check below and forward. Still counted for + - 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 `DENY_NETS`: refuse. -- Ban ledger. An active ban: refuse (`BAN_RESPONSE`). -- Lookup AS number and country. Unknown is a valid result and changes nothing. + - 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 `BLOCKLIST_ACTION`. - - Cached DNSBL or reputation API result: apply `REPUTATION_ACTION`. + - 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). 100 if none applies. 0 means refuse. - (Lowest-wins versus multiplying is open.) -- Request rate limits. Unless the client is in `RATE_LIMIT_EXEMPT_NETS`, check - the minute, hour and day counters against each configured limit times the - percentage. Over any limit: answer 429 with `Retry-After`, record an offence. + 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 - `RULES_DIR`, in file name order then line order. Each rule that matches takes - its action: record offences, refuse with 403, ban at once, or only log. - Matching stops at the first rule that refuses or bans. -- Core Rule Set inspection of the request (headers, URL, and body up to - `WAF_BODY_LIMIT`). In `block` mode a match at or over the anomaly threshold is - refused with 403 and recorded as an offence; in `detect` mode it is only - logged and alerted. -- Forward to `UPSTREAM_URL`, streaming. Add `X-Forwarded-For` and, if enabled, - `X-Client-ASN` and `X-Client-Country` for the app's own logs. + `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. Once a - client's byte total passes a byte limit times the percentage, following - requests get 429 until the window moves on, and an offence is recorded. A - response already in progress is not cut off. - - Count 401, 403 and 404 responses from the app per client; passing - `ERROR_BURST_THRESHOLD` within a minute records an offence. - - Update anomaly counters and evaluate alert thresholds. -- Offences and bans. - - `BAN_AFTER_OFFENCES` offences within `OFFENCE_WINDOW` creates a ban. - - Ban length follows `BAN_DURATIONS` by the number of earlier bans for that - client within `BAN_HISTORY_WINDOW`: first ban the first value, second ban - the second value, and so on. - - After `PERMANENT_BAN_AFTER` bans within `BAN_HISTORY_WINDOW`, the ban has - no expiry. - - A request for a path in `TRAP_PATHS` counts as `TRAP_OFFENCE_WEIGHT` - offences at once. + - 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 only. The hour and day counters are - written to disk on a timer and at shutdown and loaded again at start (see - "Persistent state"), so a restart does not hand every client a fresh daily - allowance. Minute counters are not written; a restart forgives at most one - minute of counting. Buckets whose time has passed are discarded on load. -- Memory is bounded by `MAX_TRACKED_CLIENTS`; when full, the least recently seen - clients with no recent offences are dropped first. + 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:`, 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; an unset limit means that -limit is off. Every variable may instead be given as `NAME_FILE` pointing at a -file holding the value, for secrets and long lists. Invalid configuration stops -the process at start with a message naming the variable. At start the effective -configuration is logged with secrets masked. +`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 - - `UPSTREAM_URL` (required): the application, for example - `http://gitea:3000`. - - `LISTEN_ADDR` (default `:8080`): proxy listener. - - `ADMIN_LISTEN_ADDR` (default `127.0.0.1:9090`): admin listener. - - `ADMIN_TOKEN`: bearer token required for ban management endpoints. - - `INSTANCE_NAME`: included in every log line, metric and alert, for example - `fsn1app1/gitea`. - - `MODE` (default `enforce`): `enforce`, or `observe` to log and alert on - every decision while refusing nothing. Meant for the first days of a - rollout. - - `TRUSTED_PROXIES` (required): netblocks whose `X-Forwarded-For` is - believed, normally the docker network traefik reaches the sidecar from. - - `IPV6_GROUP_PREFIX` (default `64`). - - `MAX_TRACKED_CLIENTS` (default `500000`). + - `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/`, 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 - - `STATE_DIR` (default `/data`): the JSON state files and downloaded lookup - databases. - - `STATE_WRITE_DELAY` (default `2s`): after a ban or offence changes, wait - this long before writing, so a burst of changes becomes one write. - - `STATE_COUNTER_INTERVAL` (default `60s`): how often the hour and day - counters and the reputation cache are written. + - `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. - - `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. - - `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. - - `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`, `relp://host:2514`, `relp+tls://host:2514`. - - `LOG_REMOTE_TLS_CA_FILE`: optional CA certificate for the `+tls` forms. - - `LOG_REMOTE_BUFFER` (default `10000`): lines held in memory while the - endpoint is unreachable; when full the oldest are dropped and counted. - - `LOG_REMOTE_FACILITY` (default `local0`), `LOG_REMOTE_APP_NAME` (default - `INSTANCE_NAME`): syslog header fields. + - `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 - - `METRICS_ENABLED` (default `true`). - - `METRICS_LISTEN_ADDR` (default `:9100`): its own listener, so it can be - bound to a monitoring network without exposing the admin endpoints. - - `METRICS_PATH` (default `/metrics`). - - `METRICS_TOP_N` (default `50`): how many AS numbers and countries get - their own series; the rest are summed as `other`. - - `METRICS_TOKEN`: optional bearer token; unset means no authentication, - which is the usual arrangement for a scraper on a private network. + - `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 - - `ALLOW_NETS`: bypass everything (monitoring, the owner's own networks). - - `RATE_LIMIT_EXEMPT_NETS`: bypass request and byte limits only; attack - detection and bans still apply. - - `DENY_NETS`: always refused. -- Request rate limits, per client (R1, R2) - - `RATE_LIMIT_PER_MINUTE`, `RATE_LIMIT_PER_HOUR`, `RATE_LIMIT_PER_DAY`. - - `RATE_LIMIT_EXEMPT_PATHS`: path prefixes not counted (static assets, + - `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 - - `BYTES_LIMIT_PER_MINUTE`, `BYTES_LIMIT_PER_HOUR`, `BYTES_LIMIT_PER_DAY`. - - `BYTES_COUNT` (default `both`): `response`, `request` or `both`. -- Lookup of AS number and country (R7) - - `ASN_DB_PATH`, `COUNTRY_DB_PATH`: `.mmdb` files mounted into the - container; or - - `ASN_DB_URL`, `COUNTRY_DB_URL`: downloaded into `STATE_DIR` at start and - every `LOOKUP_DB_REFRESH` (default `7d`). One file may serve both. - - Known free sources in this format: IPinfo Lite (country and AS number in - one file, free account token), DB-IP Lite, MaxMind GeoLite2 (free - account). Choice is open. - - `ADD_LOOKUP_HEADERS` (default `false`): pass `X-Client-ASN` and +- 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 whole request, headers and 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. + - 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. - - Missing or unreadable database: start anyway, treat every client as - unknown, log a warning and send one alert. -- Biased thresholds (R8) - - `ASN_LIMIT_PERCENT`: for example `AS14061:50,AS16276:50,AS45102:25`. - Listed AS numbers get that percentage of every request and byte limit. - `0` refuses outright. - - `COUNTRY_LIMIT_PERCENT`: same form with ISO country codes, for example - `CN:25,RU:50`. - - `ASN_BYTES_PERCENT`, `COUNTRY_BYTES_PERCENT`: optional overrides applied - to byte limits only, when the byte percentage should differ from the - request percentage. - - `UNKNOWN_LIMIT_PERCENT` (default `100`): for clients the lookup cannot - place. - - `ASN_LIMIT_PERCENT_URL`: optional URL of a text file of `AS:percent` - lines, so one abuse-source list can be shared by every sidecar in the - fleet. -- Attack detection (R9) - - `RULES_DIR` (default `/etc/smallwebwaf/rules.d`): directory of rule files - read once at start; format under "Rule files". The image ships a default - file there. Mounting a directory over it replaces the defaults; mounting - single files into it adds to them. - - `RULES_ENABLED` (default `true`): `false` skips rule files entirely. - - `WAF_MODE` (default `detect`): `off`, `detect`, `block`. - - `WAF_PARANOIA_LEVEL` (default `1`), `WAF_ANOMALY_THRESHOLD` (default `5`): - the Core Rule Set's own two tuning values. - - `WAF_DISABLED_RULES`: rule ids to switch off when an app trips a false - positive. - - `WAF_EXEMPT_PATHS`: path prefixes not inspected. - - `WAF_BODY_LIMIT` (default `128K`): bodies are inspected up to this size - and streamed beyond it without buffering, so large uploads and git pushes - are not held in memory. - - `TRAP_PATHS`: paths the app never serves and only scanners ask for, for - example `/.env,/wp-login.php,/.git/config`. `TRAP_OFFENCE_WEIGHT` - (default `3`). This is the env-var short form of a `path` rule with an - `offence` action, for deployments that mount no rule files. - - `ERROR_BURST_THRESHOLD` (default off): 401, 403 and 404 responses per - client per minute that count as an offence. -- Blocks and bans (R5) - - `BAN_AFTER_OFFENCES` (default `3`), `OFFENCE_WINDOW` (default `10m`). - - `BAN_DURATIONS` (default `1h,24h,7d`). - - `PERMANENT_BAN_AFTER` (default `4`; `0` never bans permanently), - `BAN_HISTORY_WINDOW` (default `90d`). - - `BAN_RESPONSE` (default `403`): `403`, `429`, or `close` to drop the - connection without an answer. - - `BAN_SCOPE_V4_PREFIX` (default `32`): widen to for example `24` to ban the - surrounding netblock. -- Reputation (R6) - - `BLOCKLIST_URLS`: text files of addresses and netblocks, for example - Spamhaus DROP, FireHOL level 1, the Tor exit list. Refreshed every - `BLOCKLIST_REFRESH` (default `6h`); the last good copy is kept on failure. - - `BLOCKLIST_ACTION` (default `limit:25`): `deny`, `limit:`, or - `log`. - - `DNSBL_ZONES`: for example `dnsbl.dronebl.org`. Zones meant for mail +- 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:`, or + `log`. `deny` refuses every request from a listed address, which suits + lists of networks that send nothing legitimate, such as DROP. + `limit:` 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. - - `DNSBL_RESOLVER`: optional resolver address, since public resolvers are - refused by several list operators. - - `ABUSEIPDB_KEY`, `ABUSEIPDB_MIN_SCORE` (default `75`), - `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. - - `CROWDSEC_LAPI_URL`, `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 the sidecar depending on CrowdSec. - - `REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:`, or - `log`, for DNSBL and API hits. - - `REPUTATION_CACHE_TTL` (default `24h`), `REPUTATION_TIMEOUT` (default - `2s`). + - `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:`, + 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) - - `ALERT_WEBHOOK_URL`: JSON POST; schema below. - `ALERT_WEBHOOK_HEADERS`: optional `Name:value` pairs for authentication. - - `ALERT_SLACK_WEBHOOK_URL`: Slack incoming webhook, formatted message. - - `ALERT_NTFY_URL` (full topic URL), `ALERT_NTFY_TOKEN`: title, priority - and tags set from the event type. - - `ALERT_EVENTS` (default - `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure`): - which event types are sent. - - `ALERT_COOLDOWN` (default `15m`): the same event type for the same client - or netblock is not repeated within this time; a count of suppressed + - `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. - - `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) - - Per client: `ANOMALY_CLIENT_REQUESTS_PER_MINUTE`, - `ANOMALY_CLIENT_REQUESTS_PER_HOUR`, `ANOMALY_CLIENT_BYTES_PER_MINUTE`, - `ANOMALY_CLIENT_BYTES_PER_HOUR`. - - Per surrounding netblock (`ANOMALY_NET_V4_PREFIX` default `24`, - `ANOMALY_NET_V6_PREFIX` default `48`): `ANOMALY_NET_REQUESTS_PER_MINUTE`, - `..._PER_HOUR`, `ANOMALY_NET_BYTES_PER_MINUTE`, `..._PER_HOUR`. - - Per AS number: `ANOMALY_ASN_REQUESTS_PER_MINUTE`, `..._PER_HOUR`, - `ANOMALY_ASN_BYTES_PER_MINUTE`, `..._PER_HOUR`. - - Whole service: `ANOMALY_TOTAL_REQUESTS_PER_MINUTE`, `..._PER_HOUR`, - `ANOMALY_TOTAL_BYTES_PER_MINUTE`, `..._PER_HOUR`. - - Named netblocks: `WATCH_NETS`, for example + - `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 - `WATCH_REQUESTS_PER_MINUTE`, `..._PER_HOUR`, `WATCH_BYTES_PER_MINUTE`, - `..._PER_HOUR` applied to each named block as a whole. - - Anomaly counting includes clients in `ALLOW_NETS` and - `RATE_LIMIT_EXEMPT_NETS`, since an exempt client misbehaving is worth - knowing about. + `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: at start the sidecar reads every `*.rules` file in -`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. +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: @@ -343,23 +803,27 @@ be turned into a rule in one line. - 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 offence history. + 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. + - `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:`: any one request header. - - Request bodies are not available to rule files; body inspection is the - Core Rule Set's job. + - 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. - - `offence` or `offence:`: record one or `n` offences and forward the - request. Enough offences within `OFFENCE_WINDOW` creates a ban. - - `block` or `block:`: refuse with 403 and record one or `n` offences. - - `ban`: refuse and ban the client at once, at whatever length its ban - history calls for. + - `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 @@ -367,94 +831,190 @@ be turned into a rule in one line. 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 once at start and held in memory. 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. A missing or empty directory is not - an error: the log says that no rules were loaded. Changing rules means - restarting the container; reloading while running is given up for now. -- In `MODE=observe` every action is logged as what would have happened and +- 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 `ALLOW_NETS` are not checked. +- Clients in `SWWAF_ALLOW_NETS` are not checked. -Example file: +The default file the image ships: ``` -# 00-default.rules: probes no real visitor sends +# 00-default.rules: probes no real visitor sends, anchored at the site root -# id target action regex -env-file path offence:3 (?i)/\.env(\.[a-z]+)?$ -git-dir path offence:3 ^/\.git/(config|HEAD|index)$ -wp-probe path offence:3 (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) -php-shell path block:3 (?i)/(shell|c99|r57|wso|alfa)\.php$ -path-traversal uri block (\.\./){2,} -scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b -empty-agent user_agent log ^$ +# 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 ^$ ``` -The image ships one default file of this kind. It is kept short and limited to -patterns that are wrong for every app; anything app-specific (for example -blocking `/wp-login.php` is harmless in front of gitea and fatal in front of -WordPress) belongs in a file the deployer mounts. +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 `///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. No database is used. What must survive a restart is -written to `STATE_DIR` as JSON files; the files are read once at start and never -during a request. The whole state is expected to stay under 10 MiB, and reading -or writing even ten times that as JSON takes well under a second, so whole-file -rewrites are cheap enough to need nothing cleverer. +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. Per entry: client or netblock, start, - expiry (`null` for permanent), reason, which ban this is for that client - (first, second, third), source (`local`, `admin`, `crowdsec`), and when - it was lifted, if it was. - - `offences.json`: offender history. Per entry: client, time, kind (`rate`, - `bytes`, `waf`, `trap`, `error_burst`), short detail. Entries older than - `BAN_HISTORY_WINDOW` are dropped at each write. - - `counters.json`: the hour and day request and byte counters per client. - - `reputation.json`: cached DNSBL and reputation API verdicts with the time - each was fetched. + - `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/` 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. + 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` and `offences.json` are written `STATE_WRITE_DELAY` after a - change; `counters.json` and `reputation.json` every - `STATE_COUNTER_INTERVAL`; all four on orderly shutdown (SIGTERM). -- Reading: - - at start only. Active bans go into an in-memory prefix lookup; everything - else into maps. Expired counters and cache entries 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 - bans 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. `counters.json` and `reputation.json` are the - exception: if unreadable they are logged, set aside with a `.bad` suffix, - and the process starts with them empty. -- Hand edits are made with the container stopped; a running process will - overwrite the file at its next write. Changes to a running process go through - the admin endpoints. Reloading files into a running process is given up for - now. -- `STATE_DIR` not writable at start: the process exits. A write that fails + - `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 `.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, alerted once per cooldown, and retried at the next write. -- What a hard kill can lose: bans and offences from the last - `STATE_WRITE_DELAY`, counters from the last `STATE_COUNTER_INTERVAL`. Losing - the volume loses ban history, not service. + in metrics, sent as a `file_error` alert once per cooldown, and retried at the + next write. +- What a hard kill can lose: ban changes from the last `SWWAF_STATE_WRITE_DELAY` + (10 seconds), and everything else from the last `SWWAF_STATE_COUNTER_INTERVAL` + (15 minutes): counts and history, the counts in ban notes, GeoJS answers, + reputation verdicts and the AbuseIPDB count, anomaly counters and alert + cooldowns. Losing the volume loses ban history, not service. ## Request log One JSON object per line on stdout for every request, including refused ones. -stdout is always on. When `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 +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`, @@ -465,21 +1025,23 @@ handling while `docker logs` keeps working. `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 - `LOG_REQUEST_HEADERS`, `has_authorization` and `has_cookie` as booleans, + `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 the sidecar answered itself), `cache_control`, `location` on + `status` when `smallwebwaf` answered itself), `cache_control`, `location` on redirects, `aborted` when the client went away early. -- Decision: `action` (`forward`, `rate_limited`, `bytes_limited`, `banned`, - `denied`, `waf_blocked`, `upstream_error`), `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 the - sidecar did before forwarding), `duration_waf`, `duration_upstream_connect`, - `duration_upstream_first_byte`, `duration_upstream_total`. +- 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 @@ -487,209 +1049,496 @@ handling while `docker logs` keeps working. - Remote sending: - syslog forms send each line as the message of an RFC 5424 record, with octet-counted framing on TCP and TLS; - - RELP forms send the same record and wait for the receiver's - acknowledgement, so lines are not lost across a receiver restart. This is - the form to use with rsyslog; - sending happens on its own goroutine from a bounded buffer - (`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. + (`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 on `METRICS_LISTEN_ADDR` at `METRICS_PATH`, on by default, -switched off with `METRICS_ENABLED=false`. It has its own listener so it can be -reached by a scraper without exposing ban management. +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), offences - by kind, bans created by ordinal, permanent bans, active bans (gauge). +- 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 `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; age of each blocklist and lookup - database. -- Housekeeping: tracked clients (gauge), state file writes, write failures, - last successful write time and size per file; alerts sent, failed and - suppressed by destination; remote log lines sent, dropped and buffer depth; - the standard Go runtime and process metrics. + 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 /clients/`. + answered by the request log and `GET /_smallwebwaf/clients/`. -## Admin listener +## Admin endpoints -- `GET /healthz`: for the container health check. -- `GET /bans`, `POST /bans` (client or netblock, duration, reason), - `DELETE /bans/`: need `ADMIN_TOKEN`. -- `GET /clients/`: current counters, lookup result, reputation, offences; - for answering "why was this address refused". +`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/`: need + `SWWAF_ADMIN_TOKEN`. Editing `bans.json` does the same without a token. +- `GET /_smallwebwaf/clients/`: 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 `, 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 `ALERT_EVENTS` values) +- `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_ordinal`, `ban_expires` + `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 as a sidecar +## Deployment -docker-compose shape, using gitea as the example; upaas deployments follow the -same shape if upaas can run a second container for a service (open): +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. -```yaml -services: - gitea: - image: gitea/gitea:1 - # no traefik labels, no published ports - networks: [internal] +The image holds: - gitea-guard: - image: /: - read_only: true - user: "65532:65532" - environment: - UPSTREAM_URL: http://gitea:3000 - TRUSTED_PROXIES: 172.18.0.0/16 - INSTANCE_NAME: fsn1app1/gitea - MODE: observe - RATE_LIMIT_PER_MINUTE: "120" - RATE_LIMIT_PER_HOUR: "2000" - RATE_LIMIT_PER_DAY: "10000" - BYTES_LIMIT_PER_HOUR: 2G - ASN_DB_URL: https://example.invalid/asn-country.mmdb - ASN_LIMIT_PERCENT: AS14061:50,AS16276:50 - COUNTRY_LIMIT_PERCENT: CN:25 - WAF_MODE: detect - TRAP_PATHS: /.env,/wp-login.php,/.git/config - BLOCKLIST_URLS: https://www.spamhaus.org/drop/drop.txt - ALERT_NTFY_URL: https://ntfy.example.invalid/fleet-alerts - LOG_REMOTE_URL: relp://logs.example.invalid:2514 - METRICS_LISTEN_ADDR: :9100 - volumes: - - gitea-guard-state:/data - - ./50-gitea.rules:/etc/smallwebwaf/rules.d/50-gitea.rules:ro - networks: [internal, traefik] - labels: - traefik.enable: "true" - traefik.http.routers.gitea.rule: Host(`git.example.invalid`) - traefik.http.services.gitea.loadbalancer.server.port: "8080" +- 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. +- 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. +- runit, from Ubuntu's own `runit` package, and `runsvinit` as the entrypoint. + Neither Ubuntu nor nixpkgs packages `runsvinit`, so the image builds it from + its source (`github.com/peterbourgon/runsvinit`) at a version fixed by hash. + `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` 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`, 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.`; +- 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 /smallwebwaf: + +# 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 ``` -- The application container leaves the traefik network, so the sidecar cannot - be bypassed. -- Traefik routes only port 8080. The metrics port is reached by the scraper - over a docker network it shares with the sidecar; the admin port stays on - loopback inside the container and is used through `docker exec`. -- The state volume holds a few small JSON files; it needs no backup beyond - whatever the host already does. -- SSH access to gitea does not pass through the sidecar and is not protected - by it. -- Rollout per service: start in `MODE=observe` with `WAF_MODE=detect`, read a - few days of logs and alerts, set limits and rule exclusions from what real - traffic looks like, then switch to `enforce` and `block`. -- Notes specific to gitea: clones and pushes over HTTP are large and long, so - byte limits must be sized for a legitimate clone, the proxy sets no overall - response timeout, and `WAF_BODY_LIMIT` keeps pack uploads out of memory. - Archive download and blame or history pages are what scrapers hammer; request - limits do most of the work there. +with `app.run` beside the Dockerfile: + +```bash +#!/usr/bin/env bash +set -euo pipefail +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 +``` + +Packages from nixpkgs: nixpkgs is fixed at one commit of its newest release +branch, `nixos-26.05` today. The image's Dockerfile names the commit and the +hash of its contents, and the build checks that hash. nixpkgs is set up for root +under the name `nixpkgs`, so the app's Dockerfile installs a package with +`nix-env -iA nixpkgs.`, and whatever it installs is on the `PATH` of every +service. Because nixpkgs stays at that commit, an app built on the same +`smallwebwaf` image gets the same packages each time it is built. 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` passes while `smallwebwaf` answers +`GET /_smallwebwaf/healthz` on `127.0.0.1:8080` and the app accepts connections +at the address in `SWWAF_UPSTREAM_URL`, and fails when either does not. The +container therefore shows as healthy only while both processes are up. An app +with a health check of its own can replace the image's `HEALTHCHECK` with one +that checks both. + +Ports: `smallwebwaf` listens on port 8080 on every address and on no other port; +its health check, metrics and ban management are all on that listener, under +`/_smallwebwaf/` (see "Admin endpoints"). The app must leave port 8080 free. It +listens on `127.0.0.1:8081` only, so that nothing outside the container reaches +it except through `smallwebwaf`: an app that listens on every address can be +reached around `smallwebwaf` by anything that reaches the container. + +State: `smallwebwaf` keeps its state files in `/var/lib/smallwebwaf` +(`SWWAF_STATE_DIR`), a directory of its own beside the app's data, which the app +keeps in directories of its own, such as `/var/lib/app`. Without a volume there, +the files live in the container: they survive a restart of the container and are +lost when a deploy replaces it. A volume mounted at `/var/lib/smallwebwaf`, +named or a host directory, keeps them across deploys; the `run` script of +`smallwebwaf` makes it belong to the `smallwebwaf` user, so a host directory +mounted there needs no change of owner. It is a volume of its own, separate from +the app's, holds a few tens of MiB at most with the defaults (see "Persistent +state"), and needs no backup beyond whatever the host already does. The image +declares no volume, since every app image built on it would inherit it. +Milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14) writes no state +files and needs no volume. + +Forwarded headers: the app's TCP peer is `smallwebwaf` on `127.0.0.1`, and the +`X-Forwarded-For` the app receives ends with traefik's address, which +`smallwebwaf` adds after the visitor's. The app must therefore trust `127.0.0.1` +and `::1` for forwarded headers, besides the private address ranges traefik is +on. The usual default for a trusted-proxy setting, the private ranges alone, +does not include loopback: an app left at it ignores the header and sees every +visitor as `127.0.0.1`. A list given replaces that default, so the app's own +trusted-proxy setting names them all, +`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`, as in the +example. + +upaas: an app deployed by upaas takes this shape with no change to upaas. upaas +builds the app's Dockerfile, which now starts `FROM` the `smallwebwaf` image, +and runs the one container as it runs any app, with the app's traefik labels, +environment variables and volumes. The labels route to port 8080 +(`traefik.http.services..loadbalancer.server.port=8080`), any `SWWAF_` +settings go with the app's environment variables, and the volume for +`/var/lib/smallwebwaf` goes beside the app's own. + +- The endpoints of `smallwebwaf` are reached through traefik like any other + request, for example `https://app.example.invalid/_smallwebwaf/metrics` for a + scraper that sends `SWWAF_METRICS_TOKEN` (see "Admin endpoints"). +- Rollout per service: build the app's image on the `smallwebwaf` image, with no + setting, and deploy it as before. It protects the app from the first request, + and nothing needs tuning first. Afterwards the request log and the ban notes + say why each client was refused. A real visitor refused by mistake is let + through with an exclusion (`SWWAF_WAF_DISABLED_RULES`, + `SWWAF_WAF_EXEMPT_PATHS`, `SWWAF_RATE_LIMIT_EXEMPT_PATHS`) or an exemption + (`SWWAF_RATE_LIMIT_EXEMPT_NETS`, `SWWAF_ALLOW_NETS`), and its ban is lifted in + `bans.json`. Additions to consider at any time: an alert destination, country + lists or biased thresholds, reputation sources, the metrics and admin tokens, + a remote log endpoint, and an app-specific rule file. +- Notes specific to gitea, built on the image like any other app: + - SSH access to gitea does not pass through `smallwebwaf` and is not + protected by it. + - A clone is a response, and fits the defaults of 30 minutes and 5 GiB for + all but the largest repositories and slowest links. A push is a request, + and so is every other upload: at the defaults, one larger than 100 MiB or + taking more than 60 seconds is cut off, whether it is a git push, an LFS + object, a container image layer, a package file or a release attachment. + The client is answered `413` for a body that is too large, before anything + reaches gitea when the request announces its size, or `408` for one that + is too slow; the upload fails, and no one is banned for it. A gitea that + takes large uploads needs `SWWAF_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 (`.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 `TRUSTED_PROXIES` being required and by - walking the header from the right. -- Many clients behind one address (mobile carriers, offices, Tor): rate limits - punish them collectively; `RATE_LIMIT_EXEMPT_NETS` and sensible day limits - are the remedy, and bans on such addresses should stay short. +- 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, and `ASN_LIMIT_PERCENT` at a low value or `0` is the - response. A per-AS-number or per-country total limit is open. -- Core Rule Set false positives against real apps (gitea's editor, API - payloads): `detect` by default, exclusions by rule id and path. -- Slow-request attacks: header read timeout and idle timeout on the listener. + per-client limits do not see them. The AS number and netblock anomaly alerts + reveal them once their thresholds are set, and a low `SWWAF_ASN_LIMIT_PERCENT` + for that AS number is the response: it lowers the limits of each of its + clients. There is no shared budget for a whole AS number or country, which one + abuser could use up and so lock out everyone else there. +- Core Rule Set false positives against real apps (a search for code, and any + body once `SWWAF_WAF_BODY_LIMIT` is set): a match refuses only that request + and bans no one by itself; the request log names the rule, and exclusions by + rule id and path fix it. Further gitea requests the Core Rule Set may refuse + at the defaults, found by reading gitea's source rather than a running gitea, + are collected in https://git.eeqj.de/sneak/smallwebwaf/issues/30 and checked + when milestone 1 runs in front of a real gitea. +- Attacks carried in request bodies: not refused by default, since on a code + forge bodies are full of code the Core Rule Set takes for attacks. Most of + what shows in URLs and headers is still refused, the client that sends an + attack is still subject to the rule files, the limits and the bans, and + `SWWAF_WAF_BODY_LIMIT` switches body inspection on for apps whose forms carry + no code. +- An app that uses `path`, `ref` or another of the query parameters left out of + 930120, 932160 and 932260 (see "Configuration surface", attack detection) as a + file on the server, or passes it to a shell: that change holds in front of + every app and no setting restores the three rules, so what only they refuse + reaches the app in those parameters, such as `/etc/passwd`, `|cat /etc/passwd` + or `nc -e /bin/sh …`. Path traversal (`../`) is still refused there. Such an + app has to check those values itself, or refuse what it must never receive in + them with a rule file. So does an app that reads a cookie named `gitea_flash` + or `redirect_to`, which the Core Rule Set does not read, or passes `Referer` + to a shell. +- The admin endpoints can be reached from the internet: all but the health check + need a token and are off while it is unset, and a missing or wrong token + counts toward the error burst, so a client guessing tokens is soon banned. + Tokens are meant to be long random values, and one shorter than 32 characters + stops the start. The app starts with the same environment variables as + `smallwebwaf`, so it can read a token given as one; a token given as a file + that only the `smallwebwaf` user can read (`SWWAF_ADMIN_TOKEN_FILE`, + `SWWAF_METRICS_TOKEN_FILE`) is out of the app's reach. +- GeoJS, the default lookup source: every new visitor's address goes to a third + party, and a swarm of fresh addresses, when lookups peak, is when GeoJS may + slow down or block `smallwebwaf`. Keeping answers for 7 days and asking about + many addresses in one request keep the number of requests low; the file source + has neither risk. +- Slow-request attacks: `SWWAF_CLIENT_REQUEST_TIMEOUT` bounds how long a request + may take to arrive, `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` how large its + headers may be, and `SWWAF_CLIENT_IDLE_TIMEOUT` closes a kept-open connection + that sends nothing more. - Alert floods: cooldown and hourly cap. ## Build order -- First: proxy, client identification, static lists, three-window request - limits, exemptions, `observe` mode, the full request log on stdout, the - metrics endpoint, health. -- Second: rule files, ban ledger with escalation and permanent bans, the JSON - state files, - admin endpoints, alerting to all three destinations, remote log sending. -- Third: AS number and country lookup, biased thresholds, byte limits, anomaly - thresholds. -- Fourth: blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed. -- Fifth: attack detection with Coraza and the Core Rule Set, trap paths, error - bursts. -- Each stage is usable on its own; the first three already cover the traffic - problem the fleet has today. - -## Questions for the owner - -- Combining percentages. A client in a listed AS number (50 percent) and a - listed country (25 percent): should it get the lowest (25 percent of the - normal limit) or the product (12.5 percent)? Lowest is easier to predict when - reading a config; product punishes overlap harder. Recommendation: lowest. -- Meaning of "certain countries getting a percentage rate or byte limit". Read - here as: each client from that country gets that percentage of the normal - per-client limits. The other reading is a cap on the country as a whole, for - example all of one country together may use at most 20 percent of the - service's hourly byte budget. The second needs a configured total budget and - means one heavy client can lock out its compatriots. Recommendation: build - the per-client reading first; add whole-country and whole-AS-number caps only - if distributed scrapers make it necessary. -- Sharing bans across the fleet. As drafted, each sidecar remembers only its - own bans, so an abuser banned at gitea starts fresh at the next service. - Options: keep it per sidecar; or run one CrowdSec engine per host and have - every sidecar read its decision list (already supported above) and also - report its own bans to it. Recommendation: per sidecar for the first version, - decide on CrowdSec after seeing real ban volumes. -- Lookup database source. IPinfo Lite (one file with country and AS number, - free account, attribution required), DB-IP Lite (no account, attribution - required), or MaxMind GeoLite2 (free account, licence terms on - redistribution). Recommendation: IPinfo Lite, fetched once centrally and - served to the fleet from an internal URL so sidecars hold no account token. -- upaas. Whether upaas can deploy a second container beside an app and move - the traefik labels onto it was not examined. If it cannot, that is a feature - request for upaas, not something this sidecar can solve. -- Remote log library. Sending syslog in the current format (RFC 5424) is not - covered by Go's standard library, whose `log/syslog` package is frozen and - writes the older format, and no widely used Go RELP client was found. The - choices are a small third-party package for each, or writing both inside this - project; the syslog record is a few dozen lines, a RELP client with - acknowledgement handling is a few hundred. Recommendation: write the syslog - sender in-project, ship it first, and decide on RELP after looking at the - available packages; say if RELP must be in the first release. -- Ban response. `403` tells the abuser they were noticed; `close` wastes less - and tells them nothing. Recommendation: `403` by default for debuggability, - `close` available. +- 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 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. +- After milestone 2, 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; + - 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.