63 KiB
smallwebwaf
smallwebwaf is an MIT-licensed web application firewall, written in Go by
@sneak, for self-hosted web apps. It refuses the
requests of scrapers, scanners and other abusers before they reach an app.
What it does
A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. smallwebwaf is one binary, shipped in
one image, with two commands that do this together:
smallwebwaf rproxyis a small reverse proxy in front of one app. traefik routes to it. It asksservewafabout each request, passes the allowed ones to the app, and passes the app's answers back. It keeps nothing and never touches disk.smallwebwaf servewafdecides. It runs the checks and keeps the bans, the counters and each client's history. Oneservewafserves many sites: a client banned through one site is refused on all of them, and its requests on all of them count toward one set of limits.
The site keeps answering while servewaf is down, slow or restarting: without
an answer in time, rproxy passes the request to the app.
It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on requests and on bytes per minute, per hour and per day. The defaults leave room for programmatic use of an API, such as an automation fleet, and catch a client firing requests as fast as it can within seconds. A client over a limit is refused and banned: for a while at first, for three times as long at each repeat, and permanently when it keeps at it.
- Scanners that probe for secrets, such as
/.envor/.git/config. The default rule file bans such a client at its first probe, and its next request during the ban makes the ban permanent. Your own rules, or a list of trap paths, ban probes for other apps, such as a WordPress login, the same way. - Attacks carried in a request's URL or headers, such as SQL injection and path traversal. The OWASP Core Rule Set, a published set of rules that recognise common web attacks, runs on every request through Coraza, a Go engine. A request the rules take for an attack is refused.
- Abuse from networks and countries you do not want. You can:
- refuse whole netblocks and countries;
- give the clients of chosen networks and countries a fraction of every limit, so that they are banned sooner;
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high AbuseIPDB score;
- ban clients named by your own CrowdSec engine.
- Slow and oversized requests. Every request and every answer has a time limit and a size limit.
For the operator, it:
- logs each request as JSON lines, saying what it did and why;
- sends alerts to a webhook, Slack or ntfy;
- serves Prometheus metrics;
- keeps its bans, counters and history in JSON files you can read and edit, so that a restart loses nothing and a ban can be lifted with an editor.
Not built, and not planned:
- TLS and routing by host name. Both stay with traefik, the reverse proxy in
front of
rproxy. - Browser challenges such as captchas.
- A web console, or a configuration file.
- Defence against floods large enough to fill the host's network link.
Getting started
The image
No image is published. Build it from a clone. make docker runs the tests and
the linter, then builds the image, tagged smallwebwaf. It builds for amd64, on
an amd64 host only.
git clone https://git.eeqj.de/sneak/smallwebwaf.git
cd smallwebwaf
make docker
Push the image to a registry your hosts pull from, and name it there by digest.
The image runs rproxy by default. To run servewaf from the same image, set
the container's SMALLWEBWAF_COMMAND to servewaf. The image's health check
follows the command: in rproxy's container it passes once rproxy answers and
the app accepts connections, whether or not servewaf answers; in servewaf's
container, once servewaf answers.
The usual setup: three containers
The usual setup is three containers on one private network:
- the app;
rproxy, in front of the app, which traefik routes to:SWRP_WAF_BACKENDnamesservewafandSWRP_UPSTREAM_URLnames the app;servewaf, withSMALLWEBWAF_COMMAND=servewafand a volume at/var/lib/smallwebwaffor its state files.
Point traefik's labels at port 8080 of rproxy
(traefik.http.services.<name>.loadbalancer.server.port=8080). Publish no port
of the app or of servewaf. Anything on that network can reach the app around
rproxy, and can ask servewaf questions, so the network holds only what you
trust. Each further app adds its own container and its own rproxy, whose
SWRP_WAF_BACKEND names the same servewaf. The "Smallest setup" and "In front
of gitea" examples below show this shape.
rproxy in the app's container
An app's image can instead be built on the smallwebwaf image, so that rproxy
runs beside the app in one container. servewaf still runs in a container of
its own. The app's Dockerfile starts FROM the smallwebwaf image, named by
digest, and adds:
- the app's binary;
- the packages the app needs, from the nixpkgs in the image;
- a user for the app;
- a
runscript that starts the app listening on127.0.0.1:8081, the default ofSWRP_UPSTREAM_URL.
# The smallwebwaf image, pinned by digest.
FROM <registry>/smallwebwaf@sha256:<digest>
# Packages the app needs, if any, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.git
# The app's binary, and a user of its own to run it.
COPY app /usr/local/bin/app
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run
app.run sits beside the Dockerfile. --listen and --trusted-proxies stand
for the app's own options:
#!/usr/bin/env bash
set -euo pipefail
main() {
sleep 1
exec chpst -u app:app /usr/local/bin/app \
--listen 127.0.0.1:8081 \
--trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
}
main "$@"
Leave ENTRYPOINT and USER out of the app's Dockerfile. The image's own
entrypoint starts rproxy and the app side by side, each as its own user. Run
the app's image with SWRP_WAF_BACKEND set. deploy/example-app is such an
app, and the "rproxy beside the app" example below runs one.
The app sees the headers traefik sent, with traefik's address added to
X-Forwarded-For, from a TCP peer that is rproxy. So the app must trust
rproxy's address for forwarded headers. In a container of its own, rproxy is
on the private network, which the usual default of a trusted-proxy setting
covers. Beside the app, its address is 127.0.0.1 or ::1, which the private
ranges leave out, so the app's own setting must name them too, as app.run
does.
Checking that it works
docker inspect --format '{{.State.Health.Status}}' <container>sayshealthyfor each container ofsmallwebwaf.- A log line with
"msg":"starting"gives the version, the address and every setting in effect, with secrets masked. - A request to the site through traefik gets the app's answer.
https://<site>/_smallwebwaf/healthzanswersok.servewaf's log lines about the request have"action":"forward". - To see a refusal without banning yourself, run
servewafwithSWWAF_MODE=observeand ask for/.env. The request reaches the app.servewaf's log lines have"would_action":"banned"and"rule_ids":["env-file"]. A ban you get inenforcemode is lifted as "Clients and bans" below describes. - With
SWWAF_METRICS_TOKENset, this answers with the metrics, from a host onservewaf's network:curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics. make example-appbuilds the image and an app on it, runs them, and checks the whole exchange (see "Entrypoints").
Configuration
Every setting is an environment variable, read once at start; changing one means
restarting the container. rproxy reads only the SWRP_ settings, and
servewaf only the SWWAF_ ones. Every setting but SWRP_WAF_BACKEND has a
default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one.
- Any
SWWAF_setting can be given as a file instead, for secrets and long lists:SWWAF_ADMIN_TOKEN_FILEnames the file that holdsSWWAF_ADMIN_TOKEN. The file's contents are the value, less one newline at the end. Setting both forms stops the start.SWWAF_LOG_REMOTE_TLS_CA_FILEalready names a file, so it has no_FILEform, and noSWRP_setting has one, sincerproxynever touches disk. - A duration is Go's syntax, with
dfor days:90s,15m,24h,7d. - A size is a number of bytes with an optional
K,MorG, in powers of 1024:512K,100M,5G. - A count is a whole number of requests, such as
1000. - A time in milliseconds is a whole number above zero, such as
2000. - A list is comma separated. A list set to an empty value is an empty list, and replaces the default.
- A netblock is an address and a prefix length, such as
10.0.0.0/8; a bare address stands for itself alone. A country is a two-letter code in either case, such asde. An AS number isASand the number, such asAS64496. A percentage is a whole number from 0 to 100. A path prefix starts with/. offswitches a limit, a threshold or a timeout off where the table says a setting takes it.- An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as
********.
Every setting the code reads, with its default:
| Variable | Default | What it does |
|---|---|---|
SMALLWEBWAF_COMMAND |
rproxy |
Which command the image runs, and its health check checks: rproxy or servewaf. |
SWRP_WAF_BACKEND |
required | servewaf's URL, such as http://servewaf:8080. |
SWRP_UPSTREAM_URL |
http://127.0.0.1:8081 |
The app's URL: a scheme, a host and an optional port, nothing more. |
SWRP_LISTEN_ADDR |
:8080 |
Where rproxy listens; leave the host part empty. |
SWRP_TRUSTED_PROXIES |
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 |
The netblocks whose X-Forwarded-For rproxy believes; set but empty, it believes none. |
SWRP_TIMEOUT_MS |
2000 |
How long rproxy waits for servewaf's answer to a question and to a report, in milliseconds. |
SWRP_DEFAULT_DISPOSITION |
allow |
What rproxy does with a request when servewaf cannot be reached or does not answer in time: allow passes it to the app, deny answers 503. |
SWRP_MAX_REQUEST_SIZE |
100M |
The largest request body, a size or off; a larger one is answered 413. |
SWRP_MAX_RESPONSE_SIZE |
1G |
The largest response body, a size or off; a larger one is answered 502, or cut off once it has started. |
SWRP_MAX_REQUEST_TIME_MS |
60000 |
How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or off. |
SWRP_MAX_RESPONSE_TIME_MS |
1800000 |
How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or off. |
SWWAF_LISTEN_ADDR |
:8080 |
Where servewaf listens; leave the host part empty. |
SWWAF_INSTANCE_NAME |
the container's host name | The name in every line servewaf logs, and in every alert and metric, such as fsn1/servewaf. |
SWWAF_MODE |
enforce |
observe refuses nothing and makes no ban; each log line says what enforce would have done. |
SWWAF_IPV6_GROUP_PREFIX |
64 |
The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
SWWAF_MAX_TRACKED_CLIENTS |
20000 |
The most clients kept, in memory and in clients.json; the least recently seen is dropped first. |
SWWAF_MAX_BANS |
5000 |
The most bans servewaf keeps of its own; bans an admin made do not count. |
SWWAF_ALLOW_NETS |
empty | Netblocks whose clients skip every check, such as your monitoring. |
SWWAF_RATE_LIMIT_EXEMPT_NETS |
empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
SWWAF_DENY_NETS |
empty | Netblocks whose clients are refused. |
SWWAF_RATE_LIMIT_PER_MINUTE |
500 |
The most requests a client may make in a minute, a count or off; one more bans it. |
SWWAF_RATE_LIMIT_PER_HOUR |
20000 |
The most requests a client may make in an hour, a count or off; one more bans it. |
SWWAF_RATE_LIMIT_PER_DAY |
300000 |
The most requests a client may make in a day, a count or off; one more bans it. |
SWWAF_RATE_LIMIT_EXEMPT_PATHS |
empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as /assets/. |
SWWAF_BYTES_LIMIT_PER_MINUTE |
4G |
The most bytes a client's requests may carry in a minute, a size or off; the request that takes a client past any byte limit bans it. |
SWWAF_BYTES_LIMIT_PER_HOUR |
20G |
The most bytes a client's requests may carry in an hour, a size or off. |
SWWAF_BYTES_LIMIT_PER_DAY |
200G |
The most bytes a client's requests may carry in a day, a size or off. |
SWWAF_BYTES_COUNT |
both |
Which body bytes the byte limits count: response, request or both. |
SWWAF_ERROR_BURST_THRESHOLD |
30 |
The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or off; one more bans it. |
SWWAF_BAN_RESPONSE |
403 |
How a refused client is answered: 403, 429 or close, which drops the connection. |
SWWAF_LIMIT_BAN_DURATION |
1h |
The ban for a first broken limit. |
SWWAF_LIMIT_BAN_REPEAT_WINDOW |
24h |
A limit broken again within this time after the last such ban ended gets a ban three times as long. |
SWWAF_MAX_BAN_DURATION |
7d |
A ban for a broken limit that would be longer than this is permanent instead. |
SWWAF_ATTACK_BAN_DURATION |
7d |
The ban for a first clear sign of attack: a ban rule or a trap path. |
SWWAF_BAN_SCOPE_V4_PREFIX |
32 |
The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
SWWAF_LOOKUP_SOURCE |
geojs |
Where each client's AS number and country come from: geojs, a web service, file, or off. |
SWWAF_LOOKUP_DB_PATH |
empty | The IPinfo Lite database file, in its .mmdb form, read while the source is file. |
SWWAF_LOOKUP_TIMEOUT |
800ms |
How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
SWWAF_ADD_LOOKUP_HEADERS |
false |
true passes the app the client's AS number and country in X-Client-ASN and X-Client-Country. |
SWWAF_DENIED_COUNTRIES |
empty | Countries whose clients are refused, such as cn,ru. |
SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES |
empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
SWWAF_ASN_LIMIT_PERCENT |
empty | AS numbers whose clients get a percentage of every request and byte limit, such as AS14061:50,AS16276:25. |
SWWAF_COUNTRY_LIMIT_PERCENT |
empty | The same by country, such as cn:25. |
SWWAF_ASN_BYTES_PERCENT |
empty | A percentage of the byte limits alone for the AS numbers listed, in place of their SWWAF_ASN_LIMIT_PERCENT. |
SWWAF_COUNTRY_BYTES_PERCENT |
empty | A percentage of the byte limits alone for the countries listed, in place of their SWWAF_COUNTRY_LIMIT_PERCENT. |
SWWAF_UNKNOWN_LIMIT_PERCENT |
100 |
The percentage of every limit a client without a country gets. |
SWWAF_ASN_LIMIT_PERCENT_URL |
unset | The URL of a text file of AS<number>:<percent> lines, fetched like a blocklist; where it and SWWAF_ASN_LIMIT_PERCENT both give a percentage, the lower applies. |
SWWAF_RULES_DIR |
/etc/smallwebwaf/rules.d |
The directory of the rule files, read at start and again when a file there changes. |
SWWAF_RULES_ENABLED |
true |
false reads no rule file. |
SWWAF_TRAP_PATHS |
empty | Paths only scanners ask for, such as /wp-login.php,/xmlrpc.php; a request for one bans the client. |
SWWAF_WAF_MODE |
block |
What the Core Rule Set does with a match: block refuses it with 403, detect only logs and alerts it, off runs no rule. |
SWWAF_WAF_PARANOIA_LEVEL |
1 |
The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
SWWAF_WAF_ANOMALY_THRESHOLD |
5 |
The Core Rule Set score at which a request is a match, a whole number or off; one critical rule scores 5. |
SWWAF_WAF_DISABLED_RULES |
920340,920420,920440,920640,930130,930140 |
Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
SWWAF_WAF_EXEMPT_PATHS |
empty | Path prefixes the Core Rule Set does not inspect. |
SWWAF_WAF_BODY_LIMIT |
off |
How much of a request body the Core Rule Set reads, a size up to 64K; off reads none. |
SWWAF_BLOCKLIST_URLS |
empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as https://www.spamhaus.org/drop/drop.txt. |
SWWAF_BLOCKLIST_REFRESH |
24h |
How long after a list was last tried it is fetched again, 1h or more. |
SWWAF_BLOCKLIST_ACTION |
deny |
What a client on a blocklist gets: deny, limit:<percent> of every limit, or log. |
SWWAF_DNSBL_ZONES |
empty | DNS blocklists asked about each new client in the background, such as dnsbl.dronebl.org. |
SWWAF_DNSBL_RESOLVER |
unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
SWWAF_ABUSEIPDB_KEY |
unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
SWWAF_ABUSEIPDB_MIN_SCORE |
75 |
The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
SWWAF_ABUSEIPDB_DAILY_BUDGET |
900 |
The most AbuseIPDB checks made in a day. |
SWWAF_CROWDSEC_LAPI_URL |
unset | The local API of a CrowdSec engine you run, such as http://172.17.0.1:8080; its decision list is fetched every minute, and a listed client is banned. |
SWWAF_CROWDSEC_LAPI_KEY |
unset | The key the engine gave for smallwebwaf, as cscli bouncers add smallwebwaf prints it; set with the URL, never alone. |
SWWAF_REPUTATION_ACTION |
limit:25 |
What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: deny, limit:<percent> or log. |
SWWAF_REPUTATION_CACHE_TTL |
24h |
How long a DNS blocklist verdict or an AbuseIPDB score is used. |
SWWAF_REPUTATION_TIMEOUT |
2s |
How long a DNS blocklist query or an AbuseIPDB check may take. |
SWWAF_STATE_DIR |
/var/lib/smallwebwaf |
The directory of the state files, an absolute path. |
SWWAF_STATE_WRITE_DELAY |
10s |
How long bans.json waits to be written after a ban is made, lifted or made permanent. |
SWWAF_STATE_COUNTER_INTERVAL |
15m |
How often every state file is written. |
SWWAF_LOG_LEVEL |
info |
The least severe of servewaf's own messages that are written: debug, info, warn or error; request lines are always written. |
SWWAF_LOG_REQUEST_HEADERS |
accept,accept-language,accept-encoding,content-type,origin,range |
The request headers whose values the request log records; Authorization and Cookie are never recorded. |
SWWAF_LOG_REMOTE_URL |
unset | A syslog server that also gets every line servewaf writes: syslog+udp://, syslog+tcp:// or syslog+tls:// with a host and a port. |
SWWAF_LOG_REMOTE_TLS_CA_FILE |
unset | A file of PEM certificates a syslog+tls server's certificate must chain to, instead of the host's own. |
SWWAF_LOG_REMOTE_BUFFER |
10000 |
The most lines held while they wait to be sent; past it, the oldest is dropped. |
SWWAF_LOG_REMOTE_FACILITY |
local0 |
The syslog facility of each record sent. |
SWWAF_LOG_REMOTE_APP_NAME |
SWWAF_INSTANCE_NAME |
The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
SWWAF_ADMIN_TOKEN |
unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer 404. |
SWWAF_METRICS_TOKEN |
unset | The bearer token for /_smallwebwaf/metrics, at least 32 characters; unset, the metrics are off. |
SWWAF_METRICS_TOP_N |
50 |
How many AS numbers and how many countries get metric series of their own; the rest are summed as other. |
SWWAF_ALERT_WEBHOOK_URL |
unset | A webhook each alert is posted to as a JSON object. |
SWWAF_ALERT_WEBHOOK_HEADERS |
empty | Headers sent with each post, a list of Name:value, such as Authorization:Bearer <token>. |
SWWAF_ALERT_SLACK_WEBHOOK_URL |
unset | A Slack incoming webhook each alert is posted to as a message. |
SWWAF_ALERT_NTFY_URL |
unset | An ntfy topic each alert is published to, as its full URL, such as https://ntfy.sh/my-alerts. |
SWWAF_ALERT_NTFY_TOKEN |
unset | An ntfy access token sent with each alert. |
SWWAF_ALERT_EVENTS |
ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error |
The events alerts are sent for; the hourly summary is sent whatever it says. |
SWWAF_ALERT_COOLDOWN |
15m |
How long after an alert its repeats are held back and counted, a duration or off. |
SWWAF_ALERT_MAX_PER_HOUR |
60 |
The most alerts sent in an hour, a number or off; the rest are counted in the hour's summary. |
SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE |
off |
Alert when one client makes more requests than this in a minute, a count. |
SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR |
off |
Alert when one client makes more requests than this in an hour, a count. |
SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE |
off |
Alert when one client's requests carry more bytes than this in a minute, a size. |
SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR |
off |
Alert when one client's requests carry more bytes than this in an hour, a size. |
SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE |
off |
Alert when the netblock around a client makes more requests than this in a minute, a count. |
SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR |
off |
Alert when the netblock around a client makes more requests than this in an hour, a count. |
SWWAF_ANOMALY_NET_BYTES_PER_MINUTE |
off |
Alert when the netblock around a client carries more bytes than this in a minute, a size. |
SWWAF_ANOMALY_NET_BYTES_PER_HOUR |
off |
Alert when the netblock around a client carries more bytes than this in an hour, a size. |
SWWAF_ANOMALY_NET_V4_PREFIX |
24 |
The prefix length of the netblock around an IPv4 client that the SWWAF_ANOMALY_NET_ thresholds count, from 0 to 32. |
SWWAF_ANOMALY_NET_V6_PREFIX |
48 |
The prefix length of the netblock around an IPv6 client that the SWWAF_ANOMALY_NET_ thresholds count, from 0 to 128. |
SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE |
off |
Alert when one AS number makes more requests than this in a minute, a count. |
SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR |
off |
Alert when one AS number makes more requests than this in an hour, a count. |
SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE |
off |
Alert when one AS number carries more bytes than this in a minute, a size. |
SWWAF_ANOMALY_ASN_BYTES_PER_HOUR |
off |
Alert when one AS number carries more bytes than this in an hour, a size. |
SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE |
off |
Alert when the whole service gets more requests than this in a minute, a count. |
SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR |
off |
Alert when the whole service gets more requests than this in an hour, a count. |
SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE |
off |
Alert when the whole service carries more bytes than this in a minute, a size. |
SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR |
off |
Alert when the whole service carries more bytes than this in an hour, a size. |
SWWAF_WATCH_NETS |
empty | Named netblocks the SWWAF_WATCH_ thresholds count each as a whole, such as office=203.0.113.0/24,scraper-x=198.51.100.0/22. |
SWWAF_WATCH_REQUESTS_PER_MINUTE |
off |
Alert when a watched netblock makes more requests than this in a minute, a count. |
SWWAF_WATCH_REQUESTS_PER_HOUR |
off |
Alert when a watched netblock makes more requests than this in an hour, a count. |
SWWAF_WATCH_BYTES_PER_MINUTE |
off |
Alert when a watched netblock carries more bytes than this in a minute, a size. |
SWWAF_WATCH_BYTES_PER_HOUR |
off |
Alert when a watched netblock carries more bytes than this in an hour, a size. |
docs/settings.md gives the values each setting takes in
full, and the limits that are fixed rather than settings.
docker-compose examples
Each example is a complete compose.yaml. smallwebwaf is the tag
make docker gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Only "In front of gitea"
uses traefik. The other three publish rproxy's port on the host, for trying it
out.
Smallest setup
One app, rproxy in front of it, and servewaf with a volume for its state
files, every setting at its default.
services:
app:
# Your app, which listens on port 8081.
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
Custom per-address limits
For a site with no API:
- tighter limits for everyone;
- no limits for the office and the automation host;
- no checks for monitoring;
- a refused netblock;
- a smaller share of the limits for two hosting networks and one country;
- trap paths;
429for a refused client.
services:
app:
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
# Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a site with no API.
SWWAF_RATE_LIMIT_PER_MINUTE: "120"
SWWAF_RATE_LIMIT_PER_HOUR: "3000"
SWWAF_RATE_LIMIT_PER_DAY: "30000"
# Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
SWWAF_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
In front of gitea
gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time;
- the response size limit and the byte limits raised, for clones over 1 GiB;
- the Actions runners exempt from counting;
- the default request limits kept, since they already leave room for API clients.
traefik is the network traefik is on, made outside this file.
services:
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example/
# The host and the port of the SSH clone URLs gitea shows.
GITEA__server__SSH_DOMAIN: git.example
GITEA__server__SSH_PORT: "2222"
# gitea's TCP peer is rproxy, on the private network.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
volumes:
- gitea-data:/data
networks:
- site
ports:
# SSH does not pass through smallwebwaf.
- "2222:22"
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects and image layers over 100 MiB, or slower
# than a minute, are cut off at the defaults.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "600000"
# A clone larger than 1 GiB is cut off at the default.
SWRP_MAX_RESPONSE_SIZE: 4G
networks:
- site
- traefik
labels:
traefik.enable: "true"
# rproxy is on two networks; traefik reaches it on this one.
traefik.docker.network: traefik
traefik.http.routers.git.rule: Host(`git.example`)
traefik.http.routers.git.entrypoints: websecure
traefik.http.routers.git.tls.certresolver: letsencrypt
traefik.http.services.git.loadbalancer.server.port: "8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# Raised with rproxy's response limit, to four times the default
# each, so that one clone of that size breaks no byte limit.
SWWAF_BYTES_LIMIT_PER_MINUTE: 16G
SWWAF_BYTES_LIMIT_PER_HOUR: 80G
SWWAF_BYTES_LIMIT_PER_DAY: 800G
# gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- site
networks:
site:
traefik:
external: true
volumes:
gitea-data:
servewaf-state:
docs/gitea.md records what the defaults do in front of gitea,
request by request.
rproxy beside the app
The app is built on the smallwebwaf image, with rproxy inside its container.
app/ holds the Dockerfile and app.run from "rproxy in the app's
container". rproxy answers 503 to every request while servewaf is down,
and servewaf:
- observes before it enforces;
- looks addresses up in a local database rather than GeoJS;
- sends alerts to ntfy;
- reads its ntfy token from a file.
services:
app:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
# Answer 503 rather than pass a request on while servewaf is down.
SWRP_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
SWWAF_LOOKUP_SOURCE: file
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes:
- servewaf-state:/var/lib/smallwebwaf
- /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
volumes:
servewaf-state:
The database is mounted by its directory, not by file, so that a replaced
database is seen. A token file belongs to uid 65532, the smallwebwaf user,
with mode 0400. This one holds an access token the ntfy server issued, which
ntfy token add <user> prints there:
mkdir -p /srv/smallwebwaf/tokens
# <token> is the access token that ntfy printed.
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
chmod 0400 /srv/smallwebwaf/tokens/ntfy
Design
How it works inside. SPEC.md gives every rule in full, and why.
What happens to a request
A request goes through these steps, in this order. Once servewaf refuses a
request, the steps after that are skipped, apart from the report, and nothing of
the request reaches the app.
rproxy takes the first two:
- The health check,
GET /_smallwebwaf/healthz, is answered at once. - The visitor's address is worked out from
X-Forwarded-For, believed only fromSWRP_TRUSTED_PROXIES.rproxysendsservewafthe request as the visitor sent it, with the first 64 KiB of its body.
servewaf takes the checks:
- The static lists,
SWWAF_ALLOW_NETSandSWWAF_DENY_NETS. - The bans.
- The lookup of the client's AS number and country.
- The country lists.
- The reputation sources.
- The client's limit percentage, from its AS number, its country or a reputation hit.
- The rate limits.
- The trap paths.
- The rule files.
- The header limits, and then the Core Rule Set.
- A path under
/_smallwebwaf/is answered404, so that the app never sees that prefix.
rproxy takes the rest, with servewaf counting at the end:
- The request is passed to the app, streaming.
- After the answer,
rproxysendsservewafa report with the status and the bytes each way. For an allowed request,servewafcounts the bytes toward the byte limits and adds the request to the client's history.
A refused visitor is answered with SWWAF_BAN_RESPONSE. A block rule or a
Core Rule Set match answers 403 and bans nobody, since the Core Rule Set has
false positives; such refusals count toward SWWAF_ERROR_BURST_THRESHOLD
instead. A request over the header limits of step 12 is answered 431, and bans
nobody either. rproxy itself answers 405 to a method it does not pass on,
and the status for a size or time limit, such as 413 for a body too large.
Clients and bans
- A client is one IPv4 address or one IPv6 netblock of
SWWAF_IPV6_GROUP_PREFIX, since one abuser usually controls a whole /64. - Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound
(
SWWAF_MAX_TRACKED_CLIENTS,SWWAF_MAX_BANS), so memory is bounded too. - A ban refuses every request from its netblock until it ends. Requests refused under a ban count toward no limit. A permanent ban ends only when an admin lifts it.
- Every ban carries notes: the client's AS number and country, the limit it
broke or the rule it matched, the request that caused it, and its earlier
bans. An admin adds, keeps or lifts a ban by editing
bans.jsonor through the ban endpoints (seedocs/admin-endpoints.md).
Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless
SWWAF_WAF_BODY_LIMITis set. In front of a code forge, bodies carry what people write and publish, which the rules take for attacks. smallwebwafchanges the Core Rule Set in six ways so that it lets ordinary requests to a code forge through, such as git over HTTP and sign-in through Git Credential Manager. No setting undoes them; "Attack detection" inSPEC.mdlists them.- Rule files are plain text, one rule to a line, edited by hand and read again
while
servewafruns. The image ships one file of probes no real visitor sends (seedocs/rules.md).
State files
servewaf keeps everything in memory, and the five JSON files in
SWWAF_STATE_DIR hold a copy: bans.json, clients.json, lookups.json,
reputation.json and alerts.json. Each is written whole, to a temporary file
renamed over the real one, so a crash leaves a complete file. servewaf notices
a file edited on disk and takes the edit in; an edit that does not parse is set
aside as <name>.bad, and never stops the running process (see
docs/state-files.md).
The image and its processes
- The image is built on Ubuntu 26.04 LTS, pinned by digest, with its packages
from a snapshot of Ubuntu's archive and nixpkgs fixed at one release. An app's
Dockerfileinstalls a package withnix-env -iA nixpkgs.<name>and gets the same package each time it is built (seedocs/image.md). - runit, a service supervisor, runs
smallwebwafas thesmallwebwafuser (uid and gid 65532) and starts it again when it exits. Therunscript ofservewafgives the state directory to thesmallwebwafuser at each start, so a volume needs no change of owner. rproxyandservewafuse no token between them.servewaf's own endpoints, under/_smallwebwaf/, are for your admin tools and your metrics collector, at its own address on the private network; nothing routes the internet there.docker stopgives requests in progress five seconds to finish. Withrproxyin the app's container, the app must keep answering for five seconds afterSIGTERMfor that to help them.- Only faults found at start stop a process: an invalid setting, a rule file or
state file that does not parse, a lookup database that cannot be read, or an
address it cannot listen on. Once running, a failing lookup service,
reputation source or alert destination never takes
servewafdown.
Packages
cmd/smallwebwaf: the binary, which only callsinternal/smallwebwaf.internal/smallwebwaf: the process, as the command it is given, and the health check.internal/config: reads the settings, the one place they are read.internal/rproxy: the reverse proxy in front of one app.internal/proxy:servewaf's server, which runs the checks and answers its own endpoints.internal/ratelimit,internal/bans,internal/rules,internal/waf,internal/lookup,internal/reputation,internal/anomalyandinternal/alerts: one check or source each.internal/state,internal/requestlog,internal/remotelogandinternal/metrics: the state files, the log lines, the syslog sender and the metrics.
Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore
robots.txtand crawl every commit of every repository on a public git server; - scanners walking through lists of WordPress and
.envpaths; - 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 its services 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 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".
EVALUATION.md goes through the candidates one by one.
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard. The scripts in script/ are the entrypoints for working on it, and
the Makefile targets are thin shims that call them. The scripts are POSIX sh.
The tests and the linter run in Docker, as phases of the Dockerfile, and never
on the host.
script/bootstrap: installs on the host what the other scripts need, withmake bootstrap:make,gitandcurl;- Go, for
gofmt; - node, yarn and prettier.
script/setup: readies a fresh clone:script/bootstrap, thenscript/install-precommit.make setup.script/projectname: printssmallwebwaf, which the other scripts tag their images with.script/test: checks thatgo.modandgo.sumare tidy, then runs the tests, as thetestphase of theDockerfile.make test.script/tidy: writesgo.modandgo.sumasgo mod tidywrites them, in thetidystage of theDockerfile.make tidy.script/lint: runs golangci-lint, as thelintphase of theDockerfile.make lint.script/fmt: formats the Go code withgofmtand the Markdown with prettier.make fmt.script/fmt-check: checks the formatting and changes nothing.make fmt-check.script/check: runsscript/test,script/lintandscript/fmt-check.make check.script/docker: builds the image, whose build runs the tests and the linter first.make docker.script/cibuild: what CI runs:script/bootstrap,script/check, then the image build.script/precommit: run by the git pre-commit hook; runsscript/check.script/install-precommit: installs that hook.make hooks.script/build: buildsbin/smallwebwafwith the Go on your host.make build.script/run: buildsbin/smallwebwafand runsservewafandrproxyon the host, with the settings in your environment.make run. Unless your settings say otherwise:rproxylistens on port 8080, in front of an app athttp://127.0.0.1:8081;servewaflistens on port 8090;- its state files go in
bin/state; - its rule files are those of
share/rules.d.
script/example-app: builds the image and the example app (deploy/example-app) on it, runs the app withrproxyin its container andservewafin another, and checks them. Then it removes the containers, the network, the volumes and the images. Needs network access.make example-app. It checks:- the health checks;
- a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent;
- that only
servewaf's container changes the state directory's owner; - with
servewafstopped, the app still answering throughrproxy; - with
servewafstarted again, bans applying again; - orderly stops with
sv stopanddocker stop; - bans surviving a new container on the same volume;
- a setting given as a file.
script/gitea-requests: builds the image and runs gitea 1.27.3 behindrproxyandservewaf, three containers on one network. Every setting is at its default, but for those that join the containers andSWWAF_LOG_REQUEST_HEADERS, which adds the header that labels each request. It sends through them a sample of gitea's ordinary use and the requests the Core Rule Set is known to refuse, and prints whatservewafdid with each. Needs network access, and a docker daemon that pushes to a registry onlocalhostover plain HTTP.make gitea-requests.
TODO
- Planned, as "Planned, not built" in
SPEC.mddescribes: sending the log over RELP.
Documents
SPEC.md: howsmallwebwafbehaves, and why.EVALUATION.md: what already exists, what each tool covers and misses, and why none was adopted.REPO_POLICIES.md: the policies this repository follows.docs/settings.md: every setting and the values it takes.docs/rules.md: the rule file format and the default file.docs/admin-endpoints.md: the endpoints under/_smallwebwaf/, with examples.docs/request-log.md: the request log's fields.docs/alerts.md: the alert events and what each destination gets.docs/metrics.md: the metrics.docs/state-files.md: the state files and what each holds.docs/image.md: what the image holds and how it is pinned.docs/gitea.md:smallwebwafin front of gitea.
License
MIT. See LICENSE.