clawbot 8c8919edee
check / check (push) Failing after 3m37s
README: sections, compose examples, env var table (closes #188)
Rewrite README.md into operator-facing sections: what it does, getting
started, configuration with one table of every setting the code reads and
its default, four docker compose examples each checked with docker compose
config, then design. Each fact stated once.

Model: fable-5-1
2026-11-05 20:18:39 +00:00
2026-10-04 03:07:52 +02:00
2026-10-04 03:07:52 +02:00

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 rproxy is a small reverse proxy in front of one app. traefik routes to it. It asks servewaf about each request, passes the allowed ones to the app, and passes the app's answers back. It keeps nothing and never touches disk.
  • smallwebwaf servewaf decides. It runs the checks and keeps the bans, the counters and each client's history. One servewaf serves 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 /.env or /.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_BACKEND names servewaf and SWRP_UPSTREAM_URL names the app;
  • servewaf, with SMALLWEBWAF_COMMAND=servewaf and a volume at /var/lib/smallwebwaf for 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 run script that starts the app listening on 127.0.0.1:8081, the default of SWRP_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> says healthy for each container of smallwebwaf.
  • 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/healthz answers ok. servewaf's log lines about the request have "action":"forward".
  • To see a refusal without banning yourself, run servewaf with SWWAF_MODE=observe and 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 in enforce mode is lifted as "Clients and bans" below describes.
  • With SWWAF_METRICS_TOKEN set, this answers with the metrics, from a host on servewaf's network: curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics.
  • make example-app builds 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_FILE names the file that holds SWWAF_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_FILE already names a file, so it has no _FILE form, and no SWRP_ setting has one, since rproxy never touches disk.
  • A duration is Go's syntax, with d for days: 90s, 15m, 24h, 7d.
  • A size is a number of bytes with an optional K, M or G, 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 as de. An AS number is AS and the number, such as AS64496. A percentage is a whole number from 0 to 100. A path prefix starts with /.
  • off switches 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;
  • 429 for 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:

  1. The health check, GET /_smallwebwaf/healthz, is answered at once.
  2. The visitor's address is worked out from X-Forwarded-For, believed only from SWRP_TRUSTED_PROXIES. rproxy sends servewaf the request as the visitor sent it, with the first 64 KiB of its body.

servewaf takes the checks:

  1. The static lists, SWWAF_ALLOW_NETS and SWWAF_DENY_NETS.
  2. The bans.
  3. The lookup of the client's AS number and country.
  4. The country lists.
  5. The reputation sources.
  6. The client's limit percentage, from its AS number, its country or a reputation hit.
  7. The rate limits.
  8. The trap paths.
  9. The rule files.
  10. The header limits, and then the Core Rule Set.
  11. A path under /_smallwebwaf/ is answered 404, so that the app never sees that prefix.

rproxy takes the rest, with servewaf counting at the end:

  1. The request is passed to the app, streaming.
  2. After the answer, rproxy sends servewaf a report with the status and the bytes each way. For an allowed request, servewaf counts 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.json or through the ban endpoints (see docs/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_LIMIT is set. In front of a code forge, bodies carry what people write and publish, which the rules take for attacks.
  • smallwebwaf changes 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" in SPEC.md lists them.
  • Rule files are plain text, one rule to a line, edited by hand and read again while servewaf runs. The image ships one file of probes no real visitor sends (see docs/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 Dockerfile installs a package with nix-env -iA nixpkgs.<name> and gets the same package each time it is built (see docs/image.md).
  • runit, a service supervisor, runs smallwebwaf as the smallwebwaf user (uid and gid 65532) and starts it again when it exits. The run script of servewaf gives the state directory to the smallwebwaf user at each start, so a volume needs no change of owner.
  • rproxy and servewaf use 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 stop gives requests in progress five seconds to finish. With rproxy in the app's container, the app must keep answering for five seconds after SIGTERM for 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 servewaf down.

Packages

  • cmd/smallwebwaf: the binary, which only calls internal/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/anomaly and internal/alerts: one check or source each.
  • internal/state, internal/requestlog, internal/remotelog and internal/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.txt and crawl every commit of every repository on a public git server;
  • scanners walking through lists of WordPress and .env paths;
  • 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, with make bootstrap:
    • make, git and curl;
    • Go, for gofmt;
    • node, yarn and prettier.
  • script/setup: readies a fresh clone: script/bootstrap, then script/install-precommit. make setup.
  • script/projectname: prints smallwebwaf, which the other scripts tag their images with.
  • script/test: checks that go.mod and go.sum are tidy, then runs the tests, as the test phase of the Dockerfile. make test.
  • script/tidy: writes go.mod and go.sum as go mod tidy writes them, in the tidy stage of the Dockerfile. make tidy.
  • script/lint: runs golangci-lint, as the lint phase of the Dockerfile. make lint.
  • script/fmt: formats the Go code with gofmt and the Markdown with prettier. make fmt.
  • script/fmt-check: checks the formatting and changes nothing. make fmt-check.
  • script/check: runs script/test, script/lint and script/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; runs script/check.
  • script/install-precommit: installs that hook. make hooks.
  • script/build: builds bin/smallwebwaf with the Go on your host. make build.
  • script/run: builds bin/smallwebwaf and runs servewaf and rproxy on the host, with the settings in your environment. make run. Unless your settings say otherwise:
    • rproxy listens on port 8080, in front of an app at http://127.0.0.1:8081;
    • servewaf listens 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 with rproxy in its container and servewaf in 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 servewaf stopped, the app still answering through rproxy;
    • with servewaf started again, bans applying again;
    • orderly stops with sv stop and docker 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 behind rproxy and servewaf, three containers on one network. Every setting is at its default, but for those that join the containers and SWWAF_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 what servewaf did with each. Needs network access, and a docker daemon that pushes to a registry on localhost over plain HTTP. make gitea-requests.

TODO

  • Planned, as "Planned, not built" in SPEC.md describes: sending the log over RELP.

Documents

License

MIT. See LICENSE.

Author

@sneak

S
Description
No description provided
Readme MIT
3.3 MiB
Languages
Go 96.3%
Shell 2.8%
Dockerfile 0.8%