Files
smallwebwaf/README.md
T
sneak 4f1ad1ffc9
check / check (push) Waiting to run
README rewritten from scratch so a newcomer can follow it (closes #154, closes #155)
The old README opened with project history and one list of a hundred
settings. The new one leads with what smallwebwaf is, what it protects
against, how to build and deploy it, what happens to a request, what a
refused visitor and the operator see, and how to check it works. The
settings follow, grouped by topic, each with its default and what a bad
value does. The rest is reference. Every statement was checked against
the code on next. Short sentences, bulleted lists in place of comma
chains, no tables, identifiers in backticks.

Model: fable-5-1
2026-10-22 20:03:29 +00:00

91 KiB

smallwebwaf

smallwebwaf is an MIT-licensed web application firewall, written in Go by @sneak, for one self-hosted web app. It runs inside the app's own container, in front of the app. It refuses the requests of scrapers, scanners and other abusers before they reach the app.

A web application firewall reads each request on its way to a web app and refuses the ones that look like abuse. smallwebwaf is also a reverse proxy: it accepts requests on the app's behalf, passes them to the app, and passes the app's answers back. A request it does not refuse reaches the app as it came, apart from a few headers (see "How it sits in front of the app" below).

It needs no setting. Every setting has a default chosen for a service on the open internet. The design is in SPEC.md. The survey of existing tools that led to it is in EVALUATION.md.

What it protects against

  • Scrapers that fetch every page of a site, all day. Rate limiting gives each client 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a limit is refused and banned. The first ban lasts an hour. Each repeat within a day makes the next ban three times as long. A ban that would last more than seven days is permanent.
  • Scanners that probe for secrets, such as /.env or /.git/config. The default rule file bans such a client for seven days at its first probe. Its next request during the ban makes the ban permanent. Probes for things your app does not serve, such as a WordPress login, need a rule of your own or an entry in SWWAF_TRAP_PATHS, which does the same.
  • Attacks carried in a request's URL or headers, such as SQL injection and path traversal. The OWASP Core Rule Set is a published set of rules that recognise common web attacks. Coraza, a Go engine that runs those rules, inspects each request the earlier checks let through. 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 or 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:

  • writes one JSON log line for every request, 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 for several apps. Both stay with traefik, the reverse proxy in front of the container.
  • Browser challenges such as captchas.
  • A web console.
  • Defence against floods large enough to fill the host's network link.

Getting started

Build the image 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: the Dockerfile checks Ubuntu's package lists against the hashes of the amd64 archive.

git clone https://git.eeqj.de/sneak/smallwebwaf.git
cd smallwebwaf
make docker

Push the image to a registry your hosts pull from. Then build the app's own image on it. 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 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 smallwebwaf and the app side by side, each as its own user.

Deploy the app's image as you deploy any app. Point traefik's labels at port 8080 (traefik.http.services.<name>.loadbalancer.server.port=8080). Mount a volume at /var/lib/smallwebwaf, so that bans and history survive a deploy. For example, with no setting:

docker run --detach --name app \
    --volume app-smallwebwaf:/var/lib/smallwebwaf \
    <registry>/app@sha256:<digest>

With no setting, the address of every new visitor is sent to GeoJS, a web service, to find its country and network. SWWAF_LOOKUP_SOURCE=off sends nothing. SWWAF_LOOKUP_SOURCE=file looks addresses up in a database file on your own host (see "GeoJS and the lookup database" below).

deploy/example-app is such an app. make example-app builds it and checks it (see "Checking that it works" below).

To work on the code, run smallwebwaf outside the image. make build builds bin/smallwebwaf with the Go on your host. make run builds it and runs it with the settings in your environment. It then listens on port 8080 in front of an app at SWWAF_UPSTREAM_URL, by default http://127.0.0.1:8081. Its state files go in bin/state unless SWWAF_STATE_DIR is set. Its rule files are those of share/rules.d unless SWWAF_RULES_DIR is set.

How it sits in front of the app

The smallwebwaf image is built on Ubuntu 26.04 LTS, pinned by digest. It holds three packages from a dated snapshot of Ubuntu's archive: ca-certificates, nix-bin and runit. It also holds nixpkgs, the package collection Nix installs from, fixed at one commit. An app's Dockerfile installs a package with nix-env -iA nixpkgs.<name>, and gets the same package each time it is built. Such a package lands on the PATH of every process in the container, after Ubuntu's own commands.

  • The container's first process is runsvinit, which starts runit. runit is a service supervisor. It runs the run script of each directory under /etc/service, and runs it again a second after its program exits. The image ships /etc/service/smallwebwaf/run, which starts smallwebwaf as the smallwebwaf user (uid 65532). The app's Dockerfile adds a service directory of its own beside it, named after the app. Its run script starts the app as a user of its own. Neither smallwebwaf nor the app runs as root. The app's user cannot touch the state files or the process of smallwebwaf.
  • smallwebwaf listens on port 8080, on every address. traefik sends the app's requests there. smallwebwaf passes them to the app on 127.0.0.1:8081. The app listens on 127.0.0.1 only, so nothing reaches it except through smallwebwaf. The health check, the metrics and the ban management are on the same port, under /_smallwebwaf/. A request for that prefix never reaches the app.
  • The app sees what it would see from traefik directly: the same Host, the same X-Forwarded-Proto, and X-Forwarded-For with traefik's address added at the end. The app's TCP peer is now smallwebwaf on 127.0.0.1. So the app must trust 127.0.0.1 and ::1 for forwarded headers, besides the private address ranges. An app left at the usual default, the private ranges alone, sees every visitor as 127.0.0.1. Each request also carries its id in X-Request-ID, the same id as in its log line. Any X-Client-ASN or X-Client-Country header a client sent is removed.
  • Both processes start with the container's environment variables. That is why every setting of smallwebwaf starts with SWWAF_. A token given as an environment variable is readable by the app. A token given as a file that only the smallwebwaf user can read is not (see "Settings given as files" below).
  • The state files live in /var/lib/smallwebwaf. Without a volume there, they survive a restart of the container and are lost when a deploy replaces it. At each start, the run script gives that directory and every file in it to the smallwebwaf user. A host directory mounted there needs no change of owner.
  • The image's health check runs smallwebwaf healthcheck. It passes while smallwebwaf answers GET /_smallwebwaf/healthz on 127.0.0.1 and the app accepts connections at SWWAF_UPSTREAM_URL. It runs every second from the container's start until it first passes, for up to a minute, and every 30 seconds after that. traefik sends a container no requests until it is healthy.
  • SWWAF_LISTEN_ADDR can move smallwebwaf to another port. The app must not listen on that port. The health check follows the setting, and traefik's label must name the new port. Keep the address part empty: :9000, never 127.0.0.1:9000. traefik reaches smallwebwaf on the container's address, and the health check on 127.0.0.1.
  • When smallwebwaf or the app exits, runit starts it again a second later. The other process keeps running. While smallwebwaf is down, traefik answers 502. While the app is down, smallwebwaf answers 502. A smallwebwaf that stops at start, as on an invalid setting, is started again every second until the cause is fixed. It logs its message each time.
  • docker stop has runit send SIGTERM to both processes at once. smallwebwaf stops taking requests, gives those in progress five seconds to finish, and writes its state files. Those requests can finish only while the app still answers. An app that exits at once on SIGTERM ends them: the client gets 502, or a cut-off answer. To let them finish, the app must keep answering for five seconds after SIGTERM.

What happens to a request

smallwebwaf handles each request in this order. The first step that refuses it ends the handling. Nothing of a refused request reaches the app.

Two terms recur below. A clear sign of attack is a request that matches a rule whose action is ban, or asks for a trap path (steps 9 and 10). An offence is a request that counts against its client in its history. It is one of:

  • a clear sign of attack;
  • a broken limit;
  • a refusal by a rule, by the Core Rule Set or for a missing or wrong token.
  1. GET /_smallwebwaf/healthz is answered 200 with ok at once, before any check and without asking the app.

  2. The client is worked out. A netblock is a range of addresses, written as an address and a prefix length: 203.0.113.0/24. SWWAF_TRUSTED_PROXIES lists the netblocks traefik comes from, by default the private address ranges.

    • A TCP peer outside those netblocks is the client. Its forwarded headers are replaced.
    • For a peer inside them, X-Forwarded-For is read from the right. The first address outside those netblocks is the client.
    • If every address in the header is inside them, the leftmost is the client. With no header, the peer is.
    • An IPv6 client is the whole netblock around its address, a /64 by default; SWWAF_IPV6_GROUP_PREFIX sets its length. One abuser usually holds a whole /64.
  3. A client in SWWAF_ALLOW_NETS skips every check below. A client in SWWAF_DENY_NETS is refused.

  4. A client under a ban is refused. If the ban was made for a clear sign of attack, this request makes it permanent.

  5. The client's AS number and country are looked up, unless SWWAF_LOOKUP_SOURCE is off. An AS number names the network an address belongs to, such as AS64496. The lookup asks the GeoJS web service by default, or a database file you supply.

  6. The country lists are checked. A client from a country in SWWAF_DENIED_COUNTRIES is refused. When SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set, a client from any other country is refused, and so is a client without a country. Such a refusal is not an offence.

  7. The reputation sources are checked, in this order:

    • the blocklists in SWWAF_BLOCKLIST_URLS;
    • the decision list of the CrowdSec engine at SWWAF_CROWDSEC_LAPI_URL;
    • the DNS blocklists in SWWAF_DNSBL_ZONES;
    • AbuseIPDB, while SWWAF_ABUSEIPDB_KEY is set.

    A source the client is on refuses it, lowers its limits, or only notes it, as SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION say. A CrowdSec decision bans the client until the decision ends. No request waits for a query. DNS blocklists and AbuseIPDB are asked in the background, and their answer applies to the client's later requests.

  8. Rate limiting counts the request, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the path is under SWWAF_RATE_LIMIT_EXEMPT_PATHS. The client's percentage of the limits is the lowest that applies to it, from its AS number, its country or a reputation source; otherwise 100. A request that takes the client over a limit is refused and bans the client.

  9. A request for a path in SWWAF_TRAP_PATHS is refused and bans the client, as a clear sign of attack.

  10. The rule files are checked, in file name order and line order. A log rule notes the match. A block rule refuses the request. A ban rule refuses it and bans the client, as a clear sign of attack.

  11. The Core Rule Set inspects the request's method, URL and headers. It inspects the body too once SWWAF_WAF_BODY_LIMIT is set. A request it takes for an attack is refused in block mode, the default, and let through in detect mode. It bans no one by itself.

  12. A request under /_smallwebwaf/ is answered here by smallwebwaf, after its token is checked. Any other request is passed to the app. The app's answer is streamed back within the size and time limits. A WebSocket, a connection the app switches to another protocol, passes through, and the timeouts do not cut it.

  13. Once the answer has ended:

    • The request's bytes are counted for the byte limits. A client whose bytes pass a limit is banned, so its next request is refused.
    • A refusal by a rule, a trap path, the Core Rule Set or a token check is counted for the error burst. More than SWWAF_ERROR_BURST_THRESHOLD such refusals of one client in a minute ban it.
    • The request goes into the client's history and the anomaly counters, any alert due is raised, and the log line is written.

In observe mode (SWWAF_MODE=observe), smallwebwaf makes no ban of its own. It refuses a request only for a size or time limit, or for a missing or wrong token. A request it would otherwise have refused is passed to the app, and its log line says what enforce mode would have done. Observe mode is for trying a configuration before enforcing it.

What a refused visitor sees

  • SWWAF_BAN_RESPONSE answers most refusals. By default it is 403 with the plain text Forbidden. It can be 429 instead, or close, which closes the connection without an answer. Behind traefik, close still answers the client: traefik sends 502, as it does whenever its backend drops a connection. It answers these refusals:

    • a banned client;
    • a broken rate limit;
    • a ban rule or a trap path;
    • SWWAF_DENY_NETS and the country lists;
    • a reputation source whose action is deny.
  • A block rule and the Core Rule Set always answer 403.

  • The size and time limits answer with their own statuses:

    • 413 for a request body over SWWAF_REQUEST_MAX_BYTES;
    • 408 for a client too slow to send its body;
    • 504 for an app too slow to answer, or too slow to take the request;
    • 502 for an answer over SWWAF_RESPONSE_MAX_BYTES, or an app that cannot be reached.

    Once an answer has started, a limit can only cut the connection.

  • Go's HTTP server answers two cases before smallwebwaf sees the request, and neither gets a log line. A request line and headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES get 431. A client too slow to send them gets an error answer, mostly 400, or no answer.

  • An endpoint of smallwebwaf's own answers 401 to a request without its token. While that token is unset, it answers 404, as does any other path under /_smallwebwaf/.

What the operator sees

  • Every request gets one JSON line on stdout, which docker logs shows. Its action field says what was done, such as forward, banned, rate_limited or waf_blocked. Other fields say why (see "Request log" below):
    • the rules that matched;
    • the Core Rule Set's score;
    • the limit broken;
    • the reputation sources the client is on;
    • when the client's ban ends.
  • Every ban is in bans.json in the state directory, with notes on why it was made. See "Bans" below.
  • These raise an alert, sent to the destinations you name (see "Alerts" below):
    • a ban smallwebwaf makes;
    • a Core Rule Set match;
    • a reputation hit;
    • a count over an anomaly threshold;
    • a failure of a source or a file.
  • With SWWAF_ADMIN_TOKEN set, GET /_smallwebwaf/clients/<ip> answers with everything smallwebwaf knows of a client, and GET /_smallwebwaf/bans with every ban. See "Admin endpoints" below.

Checking that it works

  • docker inspect --format '{{.State.Health.Status}}' <container> says healthy once smallwebwaf answers and the app accepts connections. 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, and its log line has "action":"forward". https://<site>/_smallwebwaf/healthz answers ok.
  • To see a refusal without banning yourself, run the container with SWWAF_MODE=observe and ask for /.env. The request reaches the app. Its log line has "would_action":"banned" and "rule_ids":["env-file"]. In enforce mode the same request bans your address for seven days, and your next request makes the ban permanent. Lift the ban by editing bans.json or through the ban endpoint (see "Bans" below).
  • With SWWAF_METRICS_TOKEN set, curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics answers with the metrics (see "Metrics" below).
  • make example-app builds the image and the example app on it, runs the app, and checks it. It needs network access. It checks:
    • the health check passes;
    • a request reaches the app through smallwebwaf;
    • a second request in a minute bans the client (the check sets the rate limit to one a minute);
    • a probe for /.env bans another client, and its next request makes the ban permanent;
    • sv stop and docker stop stop it in order;
    • a new container on the same volume still refuses the banned client;
    • the state directory can be named by a file, and a missing one is reported.

Settings

Every setting is an environment variable whose name starts with SWWAF_. Every setting has a default, so none has to be set. Settings are read once, at start. To change one, restart the container.

A setting that is set to a value it cannot take stops the start. The process writes one line, "type":"process" with "msg":"invalid setting", naming the variable and what is wrong with the value. It exits with status 1, and runit starts it again every second until the value is fixed. The entries below say which values each setting takes. Any other value stops the start. At start, the settings in effect are logged on one line. Tokens, keys and the paths of webhooks are shown as ********.

The forms of values:

  • A duration is Go's syntax, with d for days: 90s, 15m, 24h, 7d. Zero and negative durations are refused.
  • A size is a number of bytes with an optional K, M or G, which are powers of 1024: 1K is 1024 bytes, 512K, 100M, 5G.
  • A count is a whole number of requests, such as 1000.
  • off switches a setting off, where its entry below says it takes off.
  • A list is comma separated, with spaces around items ignored. An empty item is refused. A list set but empty 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 of ISO 3166-1, in either case: de and DE are the same. xk stands for Kosovo. A code that is not a country is refused, such as nk (North Korea is kp) or the withdrawn su.
  • An AS number is AS and the number, in either case, such as AS64496.
  • A percentage is a whole number from 0 to 100.
  • A path prefix starts with /, such as /assets/.

Where it listens and what it protects

  • SWWAF_LISTEN_ADDR (default :8080): where smallwebwaf listens, an optional host and a port. Keep the host empty.
  • SWWAF_UPSTREAM_URL (default http://127.0.0.1:8081): the app. An http or https URL with a host and an optional port, and nothing more: no path, user or query.
  • SWWAF_INSTANCE_NAME (default: the container's host name, the first 12 characters of the container's id): the instance name in every log line, alert and metric. Set it to a name that survives deploys and tells instances apart, such as fsn1app1/gitea. It must be valid UTF-8. While SWWAF_ALERT_NTFY_URL is set, it may hold no control character. While SWWAF_LOG_REMOTE_URL is set and SWWAF_LOG_REMOTE_APP_NAME is not, it must be 1 to 48 printable ASCII characters without a space.
  • SWWAF_MODE (default enforce): enforce, or observe to ban no one and log what enforce would have done (see "What happens to a request" above).
  • SWWAF_TRUSTED_PROXIES (default 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, the private address ranges): the netblocks whose X-Forwarded-For is believed, normally where traefik comes from. Set but empty, it trusts nothing: every TCP peer is then the client.
  • SWWAF_IPV6_GROUP_PREFIX (default 64): the length of the netblock that is one IPv6 client, from 32 to 128. After a change, each IPv6 client starts afresh. Existing bans keep refusing their netblocks until they end.
  • SWWAF_MAX_TRACKED_CLIENTS (default 20000): the most clients kept in memory and in clients.json, a whole number above zero. Past it, the least recently seen client is dropped first.

Time and size limits

Each request has limits on both sides: between the client and smallwebwaf, and between smallwebwaf and the app. Bodies stream through in both directions, except the part the Core Rule Set reads while SWWAF_WAF_BODY_LIMIT is set.

  • SWWAF_CLIENT_REQUEST_TIMEOUT (default 60s): how long a client may take to send its request line and headers, and then, from the end of the headers, its body. A duration or off.
  • SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES (default 32K): the largest request line and headers. A size over 4K. It cannot be off: Go's HTTP server always has such a limit.
  • SWWAF_CLIENT_IDLE_TIMEOUT (default 120s): how long a kept-open connection may wait for its next request before it is closed. A duration or off. The default is longer than the 90 seconds after which traefik closes an unused connection, so traefik closes first.
  • SWWAF_CLIENT_RESPONSE_TIMEOUT (default 30m): how long the answer may take to reach the client, from the end of the request to the last byte. A duration or off.
  • SWWAF_UPSTREAM_REQUEST_TIMEOUT (default 60s): how long connecting to the app and sending it the whole request may take. A duration or off.
  • SWWAF_UPSTREAM_RESPONSE_TIMEOUT (default 30m): how long the app may take to send its whole answer. A duration or off.
  • SWWAF_REQUEST_MAX_BYTES (default 100M): the largest request body. A size or off. A request that announces a larger body is refused before anything reaches the app.
  • SWWAF_RESPONSE_MAX_BYTES (default 5G): the largest response body. A size or off.

In front of a code forge, a clone is a response and a push is a request. At the defaults, an upload over 100 MiB or slower than 60 seconds is cut off. A forge that takes large uploads needs SWWAF_REQUEST_MAX_BYTES, SWWAF_CLIENT_REQUEST_TIMEOUT and SWWAF_UPSTREAM_REQUEST_TIMEOUT raised to fit.

Netblock lists

  • SWWAF_ALLOW_NETS (default empty): netblocks whose clients skip every check, such as your monitoring or your own network. The size and time limits still apply to them. They are not looked up. They still need a token for the admin endpoints.
  • SWWAF_RATE_LIMIT_EXEMPT_NETS (default empty): netblocks whose clients are exempt from rate limiting and the byte limits, such as a machine that talks to the app all day. Everything else still applies to them.
  • SWWAF_DENY_NETS (default empty): netblocks whose clients are always refused. A client in SWWAF_ALLOW_NETS too is let through.

Rate limiting and byte limits

  • SWWAF_RATE_LIMIT_PER_MINUTE (default 1000), SWWAF_RATE_LIMIT_PER_HOUR (default 10000) and SWWAF_RATE_LIMIT_PER_DAY (default 50000): the most requests a client may make in each window. A count or off. The defaults are several times what one busy person produces. A browser loading a heavy page makes a few hundred requests, and several people often share one address.
  • SWWAF_RATE_LIMIT_EXEMPT_PATHS (default empty): path prefixes exempt from rate limiting, such as /assets/. Their bytes are not counted either. The path is matched exactly as sent, without the query, and is not percent-decoded. /assets/ matches /assets/app.js. It does not match /Assets/app.js, /%61ssets/app.js or /static/assets/app.js. There are no wildcards. A path holding .., a backslash or an encoded slash (%2F) is never exempt, since the app may read it as a path outside the prefix.
  • SWWAF_BYTES_LIMIT_PER_MINUTE (default 10G), SWWAF_BYTES_LIMIT_PER_HOUR (default 20G) and SWWAF_BYTES_LIMIT_PER_DAY (default 50G): the most bytes a client may have counted in each window. A size or off. Bytes are counted once the answer has ended, so a byte limit never cuts an answer short. The client's next request is refused instead. Each default is above the largest request and answer together, so at the defaults no single download breaks a limit.
  • SWWAF_BYTES_COUNT (default both): which body bytes the byte limits count: response, request or both. For a WebSocket, what it carried each way counts once it closes.

Looking up the AS number and country

  • SWWAF_LOOKUP_SOURCE (default geojs): geojs, file or off. geojs asks the GeoJS web service, which is then told the address of every new visitor. file looks clients up in the IPinfo Lite database file at SWWAF_LOOKUP_DB_PATH, and sends nothing off the host. off looks up no client. With off, a setting that needs lookups stops the start:
    • a country list;
    • SWWAF_ADD_LOOKUP_HEADERS;
    • an entry in SWWAF_ASN_LIMIT_PERCENT, SWWAF_COUNTRY_LIMIT_PERCENT, SWWAF_ASN_BYTES_PERCENT or SWWAF_COUNTRY_BYTES_PERCENT;
    • SWWAF_UNKNOWN_LIMIT_PERCENT below 100;
    • SWWAF_ASN_LIMIT_PERCENT_URL;
    • an anomaly threshold per AS number.
  • SWWAF_LOOKUP_DB_PATH (default empty): the IPinfo Lite database file, in its .mmdb form, for SWWAF_LOOKUP_SOURCE=file. file without it stops the start, and so does setting it with any other source. A file that is missing or cannot be read stops the start too.
  • SWWAF_LOOKUP_TIMEOUT (default 1s): how long a request waits for its client's first answer from GeoJS when a setting needs it, and how long a request to GeoJS may take. A duration; it cannot be off.
  • SWWAF_ADD_LOOKUP_HEADERS (default false): true passes the app the client's AS number in X-Client-ASN and its country in X-Client-Country. A header whose value is unknown is left out. Whatever this setting says, any such header the client sent is removed.

See "GeoJS and the lookup database" below for what each source sends where, and how long answers are kept.

Country lists

  • SWWAF_DENIED_COUNTRIES (default empty): countries whose clients are refused, such as cn,ru,kp.
  • SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES (default empty): when set, the only countries whose clients get through, such as us,de. A client whose country cannot be found is refused too. Otherwise every new client would be let in whenever GeoJS stops answering. A client on a private address has no country, so it is refused unless it is in SWWAF_ALLOW_NETS.

A country on both lists stops the start. A refused request is answered as a banned client is, before its body is read. It is not an offence and makes no ban.

Lower limits by AS number and country

Clients of the AS numbers and countries listed here get a percentage of every rate limit and byte limit. The same rules then ban them after fewer requests. A lowered limit is rounded down. 0 is a zero allowance: the client's first request bans it. Each client is counted on its own. An AS number or a country has no shared budget, since one abuser could use it up for everyone in it.

  • SWWAF_ASN_LIMIT_PERCENT (default empty): a list of AS numbers, each with : and a percentage, such as AS14061:50,AS16276:50,AS45102:25. An AS number listed twice is refused.
  • SWWAF_COUNTRY_LIMIT_PERCENT (default empty): the same by country, such as cn:25,ru:50.
  • SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT (default empty): the same for the byte limits alone. For the AS numbers and countries they list, they take the place of the two settings above.
  • SWWAF_UNKNOWN_LIMIT_PERCENT (default 100): the percentage of every limit for a client without a country:
    • one the lookup cannot place;
    • one on a private address;
    • one whose answer from GeoJS has not come in time.
  • SWWAF_ASN_LIMIT_PERCENT_URL (default unset): the http or https URL of a text file of such entries, one to a line, such as AS14061:50. One list can then serve several instances. It is fetched and kept as a blocklist is. Where it and SWWAF_ASN_LIMIT_PERCENT both give a percentage, the lower applies. A URL that is also in SWWAF_BLOCKLIST_URLS stops the start.

A client to which several percentages apply gets the lowest. The log line of each counted request, and the notes of each ban, say which setting lowered the limit.

Reputation sources

None is on by default. A reputation source judges a client by what others saw it do. The defaults judge it by what it does to your service.

  • SWWAF_BLOCKLIST_URLS (default empty): blocklists to fetch, such as https://www.spamhaus.org/drop/drop.txt. Each is an http or https URL without a user or a fragment. A URL listed twice is refused.
  • SWWAF_BLOCKLIST_REFRESH (default 24h): how long a list waits after its last fetch, or failed try, before it is fetched again. A duration of 1h or more; it cannot be off. The Spamhaus lists may be fetched no more than once an hour.
  • SWWAF_BLOCKLIST_ACTION (default deny): what is done with a client on a blocklist. deny refuses its requests. limit:<percent>, such as limit:25, gives it that percentage of every limit. log only notes the list in the log line and raises an alert.
  • SWWAF_DNSBL_ZONES (default empty): the DNS blocklists to ask about each client, such as dnsbl.dronebl.org. A DNS blocklist answers a DNS query for a name made from an address, and so says whether the address is on it. A zone is a DNS name of at most 189 characters. A Spamhaus zone carries its key, as in <key>.xbl.dq.spamhaus.net, and is shown with the key masked. A zone listed twice is refused, whatever its case or key. Do not name a zone meant for mail: such a zone holds the residential addresses ordinary visitors come from.
  • SWWAF_DNSBL_RESOLVER (default unset): the resolver to ask the zones through, such as 192.0.2.53 or [2001:db8::53]:5353. An IP address with an optional port, 53 when none is given. Unset, the host's resolver is used. Several zones refuse queries that come through a public resolver.
  • SWWAF_ABUSEIPDB_KEY (default unset): the key of your AbuseIPDB account. While it is set, a client that has committed an offence is checked. A control character in it is refused.
  • SWWAF_ABUSEIPDB_MIN_SCORE (default 75): the least abuse confidence score, from 0 to 100, that is a hit.
  • SWWAF_ABUSEIPDB_DAILY_BUDGET (default 900): the most checks made in a day, in UTC, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
  • SWWAF_CROWDSEC_LAPI_URL (default unset): the local API of a CrowdSec engine you run, such as http://172.17.0.1:8080. An http or https URL without a user or a fragment. Its decision list is at v1/decisions under that URL, and is fetched every minute. Set without the key, it stops the start. So does a decision list URL that is also in SWWAF_BLOCKLIST_URLS, or that is SWWAF_ASN_LIMIT_PERCENT_URL.
  • SWWAF_CROWDSEC_LAPI_KEY (default unset): the key the engine gave for smallwebwaf, as cscli bouncers add smallwebwaf prints it. Set without the URL, it stops the start.
  • SWWAF_REPUTATION_ACTION (default limit:25): what is done with a client on a DNS blocklist, or one whose AbuseIPDB score is a hit. deny, limit:<percent> or log, as for SWWAF_BLOCKLIST_ACTION. Such a verdict is less certain than the Spamhaus DROP list, so by default the client gets a quarter of every limit.
  • SWWAF_REPUTATION_CACHE_TTL (default 24h): how long a zone's verdict or AbuseIPDB's score is used. A duration; it cannot be off.
  • SWWAF_REPUTATION_TIMEOUT (default 2s): how long a query to a zone or a check with AbuseIPDB may take. A duration; it cannot be off.

See "Blocklists", "DNS blocklists", "AbuseIPDB" and "CrowdSec" below.

Attack detection

  • SWWAF_RULES_DIR (default /etc/smallwebwaf/rules.d): the directory of the rule files (see "Rule files" below). A directory that cannot be read stops the start. An empty one is no error.

  • SWWAF_RULES_ENABLED (default true): false reads no rule file.

  • SWWAF_TRAP_PATHS (default empty): paths the app never serves and only scanners ask for, such as /wp-login.php,/xmlrpc.php in front of a code forge. A request for one bans the client, as a ban rule does. Each starts with / and holds no ?. A trap path is matched whole, without the query, against the path as sent and once percent-decoded. /wp-login.php matches /wp-login.php?x=1 and /%77p-login.php. It does not match /wp-login.php/ or /blog/wp-login.php.

  • SWWAF_WAF_MODE (default block): what the Core Rule Set does with a request it takes for an attack. block refuses it with 403. detect lets it through, logged and alerted. off inspects no request.

  • SWWAF_WAF_PARANOIA_LEVEL (default 1): the Core Rule Set's paranoia level, from 1 to 4. Each level up runs more rules. Those find more attacks, and take more ordinary requests for attacks.

  • SWWAF_WAF_ANOMALY_THRESHOLD (default 5): the score at which a request is a match. Each rule that matches adds to the request's score, and a rule rated critical adds 5. So by default one such rule is enough. A whole number above zero, or off. With off no request is a match, and scores are still logged.

  • SWWAF_WAF_DISABLED_RULES (default 920340,920420,920440,920640,930130,930140): the ids of Core Rule Set rules to switch off. The log line names the rules a request matched, so a rule that refuses your app's ordinary requests can be found there. The default switches off six rules that get in the way of a code forge:

    • 920340, 920420 and 920640 refuse a request for the type of its body, when the type is missing or not on the Core Rule Set's short list;
    • 920440 refuses a request for its file extension, such as .sh or .sql;
    • 930130 and 930140 refuse a file or directory name in the path, such as .git/, .gitignore or package.json.

    In front of a code forge these refuse git over HTTP, uploads and views of ordinary files. The default rule file still bans probes for version control directories and secret files at the site root. A list given replaces the default, so include them. An id must be a whole number above zero. An id from 900000 to 900999 is refused: those are the rules that set the Core Rule Set up, and smallwebwaf's own.

  • SWWAF_WAF_EXEMPT_PATHS (default empty): path prefixes the Core Rule Set does not inspect, matched as SWWAF_RATE_LIMIT_EXEMPT_PATHS is.

  • SWWAF_WAF_BODY_LIMIT (default off): off reads no request body. A size, at most 1G, switches body inspection on. The Core Rule Set then reads a form data or multipart body up to that size, and the rest streams on to the app. It reads a JSON or XML body no larger than that size, since those cannot be read in part. Any other body, such as a git push, reaches the app uninspected. See "The Core Rule Set" below.

  • SWWAF_ERROR_BURST_THRESHOLD (default 30): the most refusals of one client in a minute for a rule match, a trap path, a Core Rule Set match or a failed token check. One more bans the client, as a broken rate limit does. A count or off. The app's own 401 and 404 answers are not counted.

How long bans last and what they cover

  • SWWAF_BAN_RESPONSE (default 403): how a refused client is answered: 403, 429, or close to close the connection without an answer. A block rule and the Core Rule Set always answer 403.
  • SWWAF_LIMIT_BAN_DURATION (default 1h): the ban for a first broken rate limit, byte limit or error burst. A duration; it cannot be off.
  • SWWAF_LIMIT_BAN_REPEAT_WINDOW (default 24h): a client that breaks a limit again within this time after its ban ended is banned three times as long. A duration; it cannot be off.
  • SWWAF_MAX_BAN_DURATION (default 7d): a ban for a broken limit that would be longer is permanent instead. A duration; it cannot be off.
  • SWWAF_ATTACK_BAN_DURATION (default 7d): the ban for a first clear sign of attack. A duration; it cannot be off.
  • SWWAF_MAX_BANS (default 5000): the most bans smallwebwaf made that are kept, whether past, active or permanent. A whole number above zero. Bans whose cause is admin are kept besides and never dropped (see "Bans" below).
  • SWWAF_BAN_SCOPE_V4_PREFIX (default 32): the length of the netblock around an IPv4 client that a ban covers, from 0 to 32. 24 bans the surrounding /24. An IPv6 ban covers the client's group.

Where and how often the state files are written

  • SWWAF_STATE_DIR (default /var/lib/smallwebwaf): the directory of the state files, an absolute path. A directory that does not exist or cannot be written stops the start.
  • SWWAF_STATE_WRITE_DELAY (default 10s): how long bans.json waits to be written after a ban is made, lifted or made permanent. Every change in between goes into the same write. A duration; it cannot be off.
  • SWWAF_STATE_COUNTER_INTERVAL (default 15m): how often every state file is written. A duration; it cannot be off.

Logging

  • SWWAF_LOG_REQUEST_HEADERS (default accept,accept-language,accept-encoding,content-type,origin,range): the request headers to record in the log line, in either case. Authorization, Cookie and Set-Cookie are never logged, even if listed. Host and Transfer-Encoding are refused: Go's HTTP server takes them out of the request. The host is the field host.
  • SWWAF_LOG_LEVEL (default info): the least severe of smallwebwaf's own messages that are written: debug, info, warn or error. No message is at debug. It holds back no request log line. warn and error hold back the line of settings at start.
  • SWWAF_LOG_REMOTE_URL (default unset): a syslog server every line on stdout is also sent to, such as syslog+tls://logs.example:6514. The forms are syslog+udp://, syslog+tcp:// and syslog+tls://, each with a host and a port and nothing more.
  • SWWAF_LOG_REMOTE_TLS_CA_FILE (default unset): a file of PEM certificates that a syslog+tls server's certificate must chain to, instead of the host's own. A file that cannot be read, or holds no certificate, stops the start.
  • SWWAF_LOG_REMOTE_BUFFER (default 10000): the most lines held while they wait to be sent, a whole number above zero.
  • SWWAF_LOG_REMOTE_FACILITY (default local0): the syslog facility: kern, user, mail, daemon, auth, syslog, lpr, news, uucp, cron, authpriv, ftp, or local0 to local7.
  • SWWAF_LOG_REMOTE_APP_NAME (default: SWWAF_INSTANCE_NAME): the app name in each syslog record, 1 to 48 printable ASCII characters without a space.

Tokens and metrics

  • SWWAF_ADMIN_TOKEN (default unset): the token for the ban endpoints and /_smallwebwaf/clients/<ip>. A long random value of at least 32 characters. Unset, those endpoints are off. Give it as a file to keep it from the app (see "Settings given as files" below).
  • SWWAF_METRICS_TOKEN (default unset): the token a scraper sends for /_smallwebwaf/metrics, at least 32 characters. Unset, the metrics are off. It is separate from the admin token, so a scraper cannot manage bans.
  • SWWAF_METRICS_TOP_N (default 50): how many AS numbers and how many countries get series of their own in the metrics. The rest count as other. A whole number above zero.

Alert destinations

  • SWWAF_ALERT_WEBHOOK_URL (default unset): the webhook that receives each alert as a JSON object. An http or https URL without a user or a fragment. The settings logged at start show its path and query as ********, since many webhooks carry their secret there.
  • SWWAF_ALERT_WEBHOOK_HEADERS (default empty): headers sent with each post, such as Authorization:Bearer 0123456789abcdef. A list of name, : and value. A value cannot hold a comma.
  • SWWAF_ALERT_SLACK_WEBHOOK_URL (default unset): the Slack incoming webhook that receives each alert as a message. It is checked and masked as the webhook URL is.
  • SWWAF_ALERT_NTFY_URL (default unset): the ntfy topic that receives each alert, as the topic's full URL, such as https://ntfy.sh/my-alerts. It is checked and masked as the webhook URL is.
  • SWWAF_ALERT_NTFY_TOKEN (default unset): an ntfy access token, sent as Authorization: Bearer <token>. A control character in it is refused.
  • SWWAF_ALERT_EVENTS (default ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error): the events alerts are sent for. Any other name is refused.
  • SWWAF_ALERT_COOLDOWN (default 15m): how long a repeat of an alert is held back. A duration or off.
  • SWWAF_ALERT_MAX_PER_HOUR (default 60): the most alerts sent in an hour. The rest are rolled into one summary. A whole number above zero, or off.

Anomaly thresholds

These only alert. They refuse and ban nothing. Each scope has four thresholds: the most requests and the most bytes counted in a minute and in an hour. Requests are counts, bytes are sizes. Each is off by default. What is unusual depends on each service's normal traffic, which the metrics show. The four thresholds per client are:

  • SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE;
  • SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR;
  • SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE;
  • SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR.

The other scopes' names have another prefix before the same four endings, such as REQUESTS_PER_MINUTE:

  • SWWAF_ANOMALY_NET_...: per netblock around a client. Two settings give the netblock's length:
    • SWWAF_ANOMALY_NET_V4_PREFIX (default 24, from 0 to 32) for an IPv4 client;
    • SWWAF_ANOMALY_NET_V6_PREFIX (default 48, from 0 to 128) for an IPv6 client.
  • SWWAF_ANOMALY_ASN_...: per AS number. Needs lookups on.
  • SWWAF_ANOMALY_TOTAL_...: the whole service.
  • SWWAF_WATCH_...: each netblock in SWWAF_WATCH_NETS, as a whole. SWWAF_WATCH_NETS (default empty) is a list of name, = and netblock, such as office=203.0.113.0/24,scraper-x=198.51.100.0/22. An item without = or a name, or with a bad netblock, is refused. So is a name used twice.

Every request but the health check is counted, whatever was done with it, clients in SWWAF_ALLOW_NETS included. At most 20,000 counters are kept. alerts.json keeps them across a restart.

Settings given as files

Any setting may be given as a file holding its value. Add _FILE to the setting's name: SWWAF_METRICS_TOKEN_FILE names the file that holds SWWAF_METRICS_TOKEN. The file's contents are the value, less one newline at the end. They are checked as the value itself would be. Setting both forms stops the start, and so does naming a file that cannot be read. SWWAF_LOG_REMOTE_TLS_CA_FILE already holds a file name, so it has no _FILE form.

A token given as a file is out of the app's reach only while the smallwebwaf user alone can read the file. Make it on the host, owned by uid 65532 with mode 0400:

mkdir -p /srv/app/tokens
openssl rand -hex 32 > /srv/app/tokens/metrics
chown 65532:65532 /srv/app/tokens/metrics
chmod 0400 /srv/app/tokens/metrics

Mount its directory into the container read-only, and name the file:

docker run \
    -v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
    -e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
    ...

Fixed limits

A few limits are fixed rather than settings:

  • at most 100,000 GeoJS answers are kept, for 7 days each;
  • at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
  • at most 1,000 DNS blocklist queries are under way at once;
  • the CrowdSec decision list is fetched again a minute after the last fetch.

Bans

A ban refuses every request from a netblock until it ends, with SWWAF_BAN_RESPONSE. The netblock is one of:

  • the client's IPv4 address;
  • the netblock around it, SWWAF_BAN_SCOPE_V4_PREFIX long;
  • the client's IPv6 group.

A banned client is not looked up and not counted by rate limiting.

smallwebwaf makes bans for three causes:

  • limit: the client broke a rate limit, a byte limit or the error burst. The first such ban lasts SWWAF_LIMIT_BAN_DURATION, an hour. A client that breaks a limit again within SWWAF_LIMIT_BAN_REPEAT_WINDOW, a day, after its ban ended is banned three times as long: 1, 3, 9, 27 and 81 hours. A ban that would last longer than SWWAF_MAX_BAN_DURATION, seven days, is permanent. Such a ban sets the client's counters back to zero.
  • attack: the client matched a ban rule or asked for a trap path. The first such ban lasts SWWAF_ATTACK_BAN_DURATION, seven days. Any request from the netblock while it lasts makes it permanent. Once it has run out, the netblock is served like any other. Its next clear sign of attack bans it permanently at once.
  • crowdsec: a decision of your CrowdSec engine names the client. The ban lasts until the decision ends.

A block rule and the Core Rule Set ban no one by themselves, since the Core Rule Set takes some ordinary requests for attacks. Their refusals count toward the error burst instead. SWWAF_DENY_NETS, the country lists and a reputation source with the action deny make no ban either.

Each ban carries notes for deciding whether to lift it:

  • the limit broken, what it was on, its window and the count reached; or the rule id and target, or the trap path;
  • the client's percentage of the limit and the setting that gave it, when a setting lowered it;
  • the request that caused the ban;
  • the client's AS number, AS name and country;
  • the reputation sources the client is on;
  • the netblock's requests since it was first seen, and how many this ban has refused;
  • how many earlier bans the netblock had, by cause.

smallwebwaf keeps at most SWWAF_MAX_BANS of the bans it made. Past that, one ban is dropped: the earliest ban of the netblock that has gone longest without a request. Bans whose cause is admin are never dropped. Those are the bans you make, and the bans smallwebwaf made that you keep by setting their cause.

Managing bans by editing bans.json

bans.json in the state directory holds every ban, indented to be read. A permanent ban's expires is null. smallwebwaf watches the file and takes in an edit as soon as it is saved.

To ban a netblock, add an entry with its netblock, start and expires. expires is null for a ban that never ends. reason, notes and cause may be left out; the cause is then admin:

{
    "version": 1,
    "bans": [
        {
            "netblock": "203.0.113.0/24",
            "start": "2026-10-06T12:00:00Z",
            "expires": null,
            "reason": "probes for logins"
        }
    ]
}

To keep a ban smallwebwaf made, so that it is never dropped, set its cause to admin. To lift a ban, add "lifted" with the time you lift it, such as "lifted": "2026-10-06T13:00:00Z". The ban then refuses nothing and does not make the netblock's next ban longer. It is kept with its notes. To forget a ban, delete its entry. A ban whose cause is attack becomes permanent at the first request it refuses. One whose cause is admin or crowdsec does not.

An edit that cannot be read does not stop the running smallwebwaf. It keeps what it holds. At the file's next write it renames your file to bans.json.bad, writes the file again from memory, and logs and alerts the error. Mend the .bad file and move it back. See "State files" below.

Managing bans through the admin endpoints

With SWWAF_ADMIN_TOKEN set, and the token in $TOKEN:

# Every ban.
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans

# Ban 203.0.113.0/24 for seven days.
curl -H "Authorization: Bearer $TOKEN" \
    --json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
    https://app.example/_smallwebwaf/bans

# Lift the bans on 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
    https://app.example/_smallwebwaf/bans/203.0.113.9

# What smallwebwaf knows of 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" \
    https://app.example/_smallwebwaf/clients/203.0.113.9

See "Admin endpoints" below for what each answers.

Rule files

smallwebwaf reads every *.rules file in SWWAF_RULES_DIR, in the order of their names. It checks each request against their rules in that order. A file whose name starts with ., such as an editor's lock file, is not a rule file. A rule is a line of four fields separated by spaces or tabs: an id, a target, an action and a regex. The regex runs to the end of the line. Blank lines and lines starting with # are ignored.

# id           target      action  regex
env-file       path        ban     (?i)^/\.env(\.[a-z]+)?$
scanner-agent  user_agent  ban     (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
  • The id is letters, digits, - and _. No two rules share one. The log line, the metrics and a ban's notes name the rule by it.

  • The target is what the regex is matched against:

    • path: the path without the query;
    • uri: the path and the query together;
    • query: the query as sent;
    • method, host, user_agent and referer;
    • header:<Name>: any one request header but Host and Transfer-Encoding.

    path and uri are matched both as sent and once percent-decoded, so that an encoded probe does not slip past. A header sent more than once is matched with its values joined by , . A header not sent is empty text. No body is read.

  • The action is log, block or ban. log only notes the match in the log line. block refuses the request with 403 and bans no one. ban refuses it and bans the client, as a clear sign of attack. Matching stops at the first rule that refuses. Keep ban for requests no real visitor sends. Anchor a path at the site root with ^/: a file of the same name deeper in a site can be ordinary content.

  • The regex is in Go's syntax, RE2. It has no backreferences or lookaround, and takes time linear in the text it reads. It matches anywhere in the target unless anchored with ^ and $. (?i) at its front makes it ignore case.

These stop the start, with a message naming the file and line:

  • a line that is not a rule;
  • a bad header: name, or a rule for Host or Transfer-Encoding;
  • a regex that does not compile;
  • an id used twice.

While it runs, smallwebwaf watches the directory. It reads the files again once the directory has had no change for 2 seconds, so that a file still being written is read whole. If the files then hold such an error, the rules stay as they were. The log and a file_error alert name the file and line.

The image ships one file, share/rules.d/00-default.rules, installed as /etc/smallwebwaf/rules.d/00-default.rules. Its rules:

  • ban probes for secrets, version control directories, backups, logs and web shells at the site root;
  • ban the user agents of common scanners;
  • block ../ twice in a row in the path or the query;
  • note a request without a user agent.

An app's Dockerfile adds rules of its own in a file named to sort after it, such as 50-gitea.rules:

wp-probe  path  ban  (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules

A directory mounted over /etc/smallwebwaf/rules.d replaces the default file. Single files mounted into it add to it. Many editors save a file by replacing it, and a container keeps seeing the old one when the file alone is mounted. So rules to be edited while smallwebwaf runs belong in a mounted directory. To run without rules, mount an empty directory or set SWWAF_RULES_ENABLED=false.

The Core Rule Set

The OWASP Core Rule Set 4.25.0 runs on each request after the rule files, unless SWWAF_WAF_MODE is off. It reads the method, the URL with its query, and the headers. Once SWWAF_WAF_BODY_LIMIT is set, it also reads a body that is form data, multipart, JSON or XML. No answer is inspected. Each rule that matches adds to the request's score. A score at or over SWWAF_WAF_ANOMALY_THRESHOLD is a match. A match is refused with 403 in block mode, and let through in detect mode. Either way the log line gives the rule ids and the score, and a waf_block alert is sent. A request with more than 1000 query parameters or body fields adds 5 to its score, since Coraza reads only the first 1000. The Core Rule Set inspects only a request that every earlier check let through. It skips a path under SWWAF_WAF_EXEMPT_PATHS and a client in SWWAF_ALLOW_NETS.

smallwebwaf changes the Core Rule Set in six ways, so that a code forge's ordinary requests get through. The changes hold in front of every app. No setting undoes them.

  • PUT, PATCH and DELETE are allowed besides GET, HEAD, POST and OPTIONS.

  • The headers Expect and Content-Encoding are allowed. Once SWWAF_WAF_BODY_LIMIT is set, Content-Encoding is refused again on a body the Core Rule Set reads, since a compressed body cannot be inspected.

  • The query parameter redirect_uri is not checked for a URL naming an IP address or localhost (rules 931100 and 934110). Git credential helpers ask to be sent back to such a URL.

  • Ten query parameters are not checked against three lists: system files (930120), shell paths (932160) and command names (932260). They are:

    • path, files, skip-to and sub_path;
    • ref, sha, branch and workflow;
    • artifactName and redirect_to.

    A file such as .gitignore or a branch such as docker-build then gets through there. So does an attack only those three rules catch, such as |cat /etc/passwd. Path traversal and SQL injection are still refused there. Names are matched without regard to case. An app that uses one of these parameters as a file on the server, or passes it to a shell, has to check it itself.

  • The cookies gitea_flash and redirect_to are not read. Referer is not checked for a Unix command given without arguments (932340), or for Java starting a process (944110).

  • Responses are not inspected.

Body inspection suits apps whose forms carry no code. In front of a code forge it refuses issue and comment text, wiki pages and files saved in the web editor that hold shell commands or code. The log line names the rule ids, which SWWAF_WAF_DISABLED_RULES can switch off. A body the Core Rule Set cannot parse adds 5 to the score. So does a multipart body cut by the limit in the middle of a part's header. The client has until SWWAF_CLIENT_REQUEST_TIMEOUT runs out to send the part that is read.

Admin endpoints

smallwebwaf answers these requests itself, on the app's own address and through traefik like any other request. Apart from the health check, each needs a token, sent as Authorization: Bearer <token>. Each goes through every check any other request goes through. It is answered where another request would be passed to the app: a banned client stays refused, and each request counts toward rate limiting. A missing or wrong token is answered 401, in observe mode too. Such a refusal counts toward the error burst, so a client guessing tokens is soon banned. While a token is unset, its endpoints answer 404.

  • GET /_smallwebwaf/healthz: 200 and ok, to anyone, before any check and without asking the app.
  • GET /_smallwebwaf/metrics: the metrics, for SWWAF_METRICS_TOKEN (see "Metrics" below).
  • GET /_smallwebwaf/bans: every ban held, under bans, each as bans.json lists it. Needs SWWAF_ADMIN_TOKEN, as do the three below.
  • POST /_smallwebwaf/bans: bans a netblock. The body is a JSON object of netblock, duration and, if you like, reason. netblock is a netblock such as 203.0.113.0/24, or a client's address. A client's address bans its IPv4 netblock (SWWAF_BAN_SCOPE_V4_PREFIX) or its IPv6 group. duration is a duration such as 1h or 7d, or permanent. The ban starts at once, with the cause admin. A bad body is answered 400, saying what is wrong:
    • one that is not such an object, or has another field;
    • one longer than 4 KiB;
    • one that names an IPv4-mapped netblock, or an address with a zone.
  • DELETE /_smallwebwaf/bans/<client>: lifts every active ban covering the address <client>. It answers 404 when none is active.
  • GET /_smallwebwaf/clients/<ip>: what smallwebwaf knows of the client. Under client, its counters and history as clients.json holds them, or null when it has never been seen. Under bans, every ban covering the address, with its notes.

An admin whose own address is banned lifts that ban by editing bans.json.

Request log

smallwebwaf writes one JSON object per line on stdout for every request, refused ones included:

{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}

A field that does not apply to a request is left out. Every line has:

  • type;

  • the fields from time to user_agent;

  • request_id, peer_ip and client_group;

  • asn, as_name and country;

  • action and duration_total.

  • time is when the request arrived, in UTC. instance is SWWAF_INSTANCE_NAME. scheme is the X-Forwarded-Proto a trusted proxy sent, otherwise http. path and query are as the client sent them.

  • request_id is the X-Request-ID a trusted proxy sent, or a new random one of 26 letters and digits. It goes to the app in X-Request-ID.

  • peer_ip is the TCP peer, normally traefik. forwarded_for is the X-Forwarded-For header as received. client_group is the client as rate limiting counts it: an IPv4 address as a /32, or an IPv6 group.

  • asn, as_name and country are the client's, as looked up. Each is empty when:

    • lookups are off;
    • the client is not looked up;
    • the source cannot place the client;
    • GeoJS had not answered by the time the request went on.
  • content_type and content_length are the request's own. request_headers holds the headers listed in SWWAF_LOG_REQUEST_HEADERS, by name in lower case. has_authorization and has_cookie are true when the request had those headers; their values are never logged. websocket is true when the app switched the connection to another protocol.

  • status is what the client was sent, 0 if nothing. upstream_status is what the app answered. response_content_type, cache_control and location are the answer's headers. request_bytes and response_bytes count body bytes. aborted is true when the client went away early.

  • action is what was done:

    • forward: passed to the app;
    • denied: refused for SWWAF_DENY_NETS, or for a reputation source with the action deny;
    • banned: refused under a ban, or for a ban rule, a trap path or a CrowdSec decision, each of which bans the client;
    • country_denied, rate_limited, rule_blocked and waf_blocked: refused by that check;
    • too_large and timed_out: a size or time limit was passed;
    • upstream_error: the app could not be reached, or its answer broke off;
    • admin: answered at one of smallwebwaf's own endpoints.
  • In observe mode, would_action names what enforce mode would have done with a request it would have refused. action then names what was done, normally forward.

  • limit_percent and limit_percent_setting give the client's percentage of the rate limits and the setting that lowered it, when one did. bytes_percent and bytes_percent_setting do the same for the byte limits.

  • counts gives the client's requests in the minute, hour and day as rate limiting counts them, this request included. minute_bytes, hour_bytes and day_bytes give its bytes. Each window is counted in two fixed buckets, the earlier one weighted by how much of it the window still covers, so a count can have a fraction. counts is left out when rate limiting did not count the request.

  • rule_ids lists the rule file rules that matched, in order, up to the one that refused. waf_score and waf_rule_ids give the Core Rule Set's score and the rules that matched, for a request it inspected.

  • limit_hit names the limit broken: minute, hour or day for a rate limit; minute_bytes, hour_bytes or day_bytes for a byte limit; or error_burst. offence is then limit. A request whose bytes broke a limit was passed on whole, so its action stays forward.

  • reputation lists the sources the client is on, in this order:

    • blocklist URLs;
    • the CrowdSec decision list's URL;
    • DNS blocklist zones;
    • abuseipdb.
  • ban_expires is when the client's ban ends, for a request that made one or was refused under one: a time, or permanent.

  • The timings are in milliseconds. duration_total runs from the request's headers being read to its log line. duration_checks runs to the end of the checks, and duration_waf is the part of that the Core Rule Set took. The three duration_upstream_ timings run from the hand-over to the app:

    • connect, until there was a connection;
    • first_byte, until the first byte of the answer;
    • total, until the end.

No body is logged, and no header but those above. smallwebwaf's own messages (start, the settings, stop, errors) share the stream as JSON lines marked "type":"process".

Sending the log to a syslog server

While SWWAF_LOG_REMOTE_URL is set, every line on stdout is also sent to that server, as the message of an RFC 5424 syslog record. Over UDP each record is one datagram. Over TCP and TLS each record follows its length in bytes and a space. stdout is unchanged. The record carries:

  • the facility from SWWAF_LOG_REMOTE_FACILITY;
  • the severity informational;
  • the time;
  • the host's name;
  • the app name from SWWAF_LOG_REMOTE_APP_NAME.

Lines wait in a buffer of SWWAF_LOG_REMOTE_BUFFER lines, so a server that is slow or down never holds up a request. When the buffer is full, the oldest line is dropped. A failed connection is tried again a second later, and twice as long after each further failure, up to a minute. A line too long for one UDP datagram is dropped alone. As smallwebwaf stops, it spends at most two seconds sending the lines still waiting, and drops the rest. stdout has carried them.

Alerts

Each alert goes to each destination you name. SWWAF_ALERT_WEBHOOK_URL gets it as one JSON object. SWWAF_ALERT_SLACK_WEBHOOK_URL and SWWAF_ALERT_NTFY_URL get a message made from it. An alert is one of these events, and is sent when its event is in SWWAF_ALERT_EVENTS:

  • ban: a ban smallwebwaf made.
  • permanent_ban: a permanent ban it made, or an existing ban made permanent by a request.
  • waf_block: the Core Rule Set scored a request at or over the threshold. In block mode the request was refused. In detect mode it was not, and the alert's detail gives mode.
  • anomaly: a count over an anomaly threshold.
  • reputation_hit: a request from a client on a blocklist, the CrowdSec decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit. One alert per source.
  • source_failure: one of:
    • GeoJS failing;
    • a list that cannot be fetched;
    • a DNS blocklist or AbuseIPDB failing or refusing a query;
    • the day's AbuseIPDB checks used up.
  • file_error: one of:
    • a rule file or state file edit with an error;
    • a state file that cannot be written;
    • a replacement lookup database that cannot be read.

The bans you make raise no alert. In observe mode, a request that would have made a ban still raises the ban's alert, with mode set to observe in its detail. This is the alert for a ban for a broken rate limit, shown indented; it is sent on one line:

{
    "instance": "fsn1app1/gitea",
    "time": "2026-10-06T12:00:00.123461Z",
    "event": "ban",
    "client": "203.0.113.9",
    "netblock": "203.0.113.9/32",
    "asn": "",
    "as_name": "",
    "country": "",
    "reason": "requests per minute over the limit of 1000",
    "detail": {
        "ban_expires": "2026-10-06T13:00:00.123Z",
        "cause": "limit",
        "notes": {
            "asn": "",
            "as_name": "",
            "country": "",
            "kind": "requests",
            "limit": 1000,
            "window": "minute",
            "count": 1001,
            "request": {
                "time": "2026-10-06T12:00:00.123456789Z",
                "method": "GET",
                "host": "app.example",
                "path": "/owner/repo/commits/branch/main?page=812",
                "status": 403,
                "user_agent": "scraper/1.0"
            },
            "requests": 5210,
            "refused": 0,
            "earlier_bans": {
                "limit": 0,
                "attack": 0,
                "admin": 0,
                "crowdsec": 0
            }
        }
    },
    "suppressed_repeats": 0
}
  • client is the address whose request raised the alert. netblock is the ban's netblock, the netblock an anomaly counted, or the client's group. Both are empty for source_failure and file_error. asn, as_name and country are the client's as known when the alert was raised. They are empty when GeoJS had not answered yet.
  • reason is a short sentence: a ban's reason, what was counted over which threshold, listed by a blocklist, and so on.
  • detail is what is particular to the event:
    • for a ban, its cause, ban_expires and notes;
    • for an anomaly, the scope, window, kind, count and threshold;
    • for a reputation hit, the source and, for AbuseIPDB, the score;
    • for a Core Rule Set match, the rule_ids, score, method and path;
    • for a source failure, the source and error;
    • for a file error, the file and error.
  • suppressed_repeats is how many repeats the cooldown held back before this alert.

Slack and ntfy get a message with a title and a text. The title is the instance and the event, such as fsn1app1/gitea: ban. The text is the reason, then one line for each of these that the alert has:

  • the client, the netblock and the country;
  • the file, the source, the error and the mode from detail;
  • the suppressed repeats, if any.

Slack gets the title in bold and the text below it, with &, < and > escaped. ntfy gets the text, with the title in Title, the token if set, and a priority and a tag by event:

  • ban: priority default, tag no_entry.
  • permanent_ban: priority high, tag no_entry.
  • waf_block: priority default, tag shield.
  • anomaly: priority high, tag chart_with_upwards_trend.
  • reputation_hit: priority low, tag label.
  • source_failure and file_error: priority high, tag warning.
  • summary: priority default, tag bar_chart.

A repeat is an alert for the same event on the same netblock, source, file or anomaly scope, less than SWWAF_ALERT_COOLDOWN after the last. It is held back and counted, and the next alert sent gives the count. Past SWWAF_ALERT_MAX_PER_HOUR alerts in an hour, the rest are held back and counted by event. Once the hour ends, one alert with the event summary gives those counts. It also gives the repeats a cooldown held back that no later alert reported.

Each destination has a queue of at most 1000 alerts, sent one at a time, so a slow destination holds up nothing. A 2xx answer takes an alert. A 4xx other than 408 and 429 refuses it, and it is dropped. Anything else is a failure: the alert is sent again a second later, and twice as long after each further failure, up to a minute. alerts.json keeps the queues and cooldowns across a restart.

Metrics

GET /_smallwebwaf/metrics answers in the Prometheus text format, for a request that sends SWWAF_METRICS_TOKEN. No metric carries a client's address. Every metric carries the label instance, set to SWWAF_INSTANCE_NAME. Prometheus keeps it as exported_instance unless the scrape sets honor_labels: true.

  • Traffic:
    • smallwebwaf_requests_total, smallwebwaf_request_bytes_total and smallwebwaf_response_bytes_total, by status_class such as 2xx and by action;
    • smallwebwaf_request_duration_seconds and smallwebwaf_upstream_duration_seconds, as histograms;
    • smallwebwaf_requests_in_flight.
  • Limits and bans:
    • smallwebwaf_rate_limit_hits_total, by window and by kind (requests, bytes or refusals);
    • smallwebwaf_size_and_time_limit_hits_total, by limit, the setting passed;
    • smallwebwaf_offences_total, by kind;
    • smallwebwaf_bans_made_total, by cause;
    • smallwebwaf_active_bans and smallwebwaf_permanent_bans.
  • Attack detection:
    • smallwebwaf_rule_matches_total, by rule_id and action, and smallwebwaf_rules_loaded;
    • smallwebwaf_waf_matches_total, by mode and rule_id.
  • Countries and AS numbers, for the SWWAF_METRICS_TOP_N busiest of each, the rest as other:
    • smallwebwaf_country_requests_total, smallwebwaf_country_request_bytes_total, smallwebwaf_country_response_bytes_total and smallwebwaf_country_list_refusals_total, by country;
    • smallwebwaf_asn_requests_total, smallwebwaf_asn_request_bytes_total and smallwebwaf_asn_response_bytes_total, by asn.
  • Lookups:
    • smallwebwaf_geojs_requests_total and smallwebwaf_geojs_failures_total;
    • smallwebwaf_geojs_unanswered_total, the requests that needed an answer and went on without it;
    • with the file source, smallwebwaf_lookup_database_last_read_timestamp_seconds and smallwebwaf_lookup_database_read_failures_total.
  • Reputation sources, by source: each list's URL, each zone, and abuseipdb:
    • smallwebwaf_reputation_hits_total, smallwebwaf_reputation_queries_total and smallwebwaf_reputation_failures_total;
    • smallwebwaf_reputation_last_fetch_timestamp_seconds, for a list;
    • smallwebwaf_reputation_daily_budget_remaining, for AbuseIPDB.
  • State files, by file:
    • smallwebwaf_state_file_writes_total, smallwebwaf_state_file_write_failures_total, smallwebwaf_state_file_last_write_timestamp_seconds and smallwebwaf_state_file_size_bytes;
    • smallwebwaf_state_file_edits_taken_in_total and smallwebwaf_state_file_edits_set_aside_total;
    • smallwebwaf_tracked_clients, the clients in the table.
  • The syslog server, while one is set: smallwebwaf_remote_log_lines_sent_total, smallwebwaf_remote_log_lines_dropped_total and smallwebwaf_remote_log_buffer_depth.
  • Alerts, by destination: smallwebwaf_alerts_sent_total, smallwebwaf_alerts_failed_total, smallwebwaf_alerts_suppressed_total and smallwebwaf_alerts_dropped_total.
  • Go's own go_ metrics and the process's process_ metrics.

State files

smallwebwaf keeps its state in memory, and a copy of it in five JSON files in SWWAF_STATE_DIR. Each has a top-level version of 1.

  • bans.json: every ban with its notes, indented to be read (see "Bans" above).
  • clients.json: each client's counters of requests, bytes and refusals, and its history. One client per line, so grep shows everything about one. The history holds:
    • when the client was first and last seen;
    • its AS number, AS name and country;
    • its requests, and how many were forwarded and refused;
    • its bytes each way;
    • its answers by status class;
    • its offences by kind: limit, attack, rule_blocked, waf_blocked and token_refused.
  • lookups.json: GeoJS's answers, one per line.
  • reputation.json:
    • the last good copy of each list fetched from a URL, with when it was last tried;
    • each DNS blocklist verdict still in use;
    • AbuseIPDB's scores, and the checks spent today.
  • alerts.json: the alert cooldowns, the hour under way, the alerts waiting for each destination, and the anomaly counters.

bans.json is written SWWAF_STATE_WRITE_DELAY after a ban is made, lifted or made permanent. Every file is written every SWWAF_STATE_COUNTER_INTERVAL, and when smallwebwaf stops. Each write goes to a temporary file that then replaces the file, so a crash leaves the old file or the new one, whole. A write that fails is logged, raised as a file_error alert, and tried again at the next write. A hard kill loses what changed since the last write.

At start the files are read back. Each client keeps its counts, so a restart gives no one a fresh allowance. Each ban keeps refusing until it ends. A missing file is empty state. These stop the start, with a message naming the file and the place:

  • a file that does not parse, or has another version;
  • an entry without a field it needs;
  • a ban whose cause is not limit, attack, admin or crowdsec;
  • a ban whose expires is not after its start.

While it runs, smallwebwaf watches the directory and takes in your edit of a file as soon as you save it. What the file then holds replaces what it held. It tells its own writes from yours by comparing the file with what it last read or wrote. It takes in your edit before it writes, so your edit is not overwritten. A change it made after you opened the file, such as a new ban, is lost when you save over it. An edit with an error that would stop the start does not stop the running smallwebwaf. It keeps what it holds. At the file's next write it renames your file to <name>.bad, writes the file again from memory, and logs and alerts the file and the place of the error. Mend the .bad file and move it back. A file you remove is written again at its next write.

GeoJS and the lookup database

By default smallwebwaf asks GeoJS, a free web service that needs no account, about every new visitor's address. It asks whether or not a setting uses the answer, since the log line, the history, the ban notes and the metrics carry the AS number and country. These addresses are never sent:

  • one in SWWAF_ALLOW_NETS or SWWAF_DENY_NETS;
  • one under a ban;
  • a private, loopback or link-local address, which no source can place.

For an IPv6 visitor, GeoJS is asked about the first address of its group. Each answer is kept for seven days, in memory and in lookups.json.

A request waits for its client's first answer only when a setting needs the answer before the request goes on. It waits up to SWWAF_LOOKUP_TIMEOUT. The settings that make it wait:

  • a country list;
  • SWWAF_ADD_LOOKUP_HEADERS;
  • any setting under "Lower limits by AS number and country" above.

Otherwise the request goes on at once, logged without the answer. The answer reaches the client's history and ban notes when it comes. Addresses are asked about together, up to 200 in one request, one request at a time. While GeoJS fails, new visitors count as from an unknown country, which SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuses. GeoJS is then left alone for a second, and twice as long after each further failure, up to five minutes.

To keep your visitors' addresses on your own host, set SWWAF_LOOKUP_SOURCE=off, or set it to file. file looks every client up in the free IPinfo Lite database (ipinfo_lite.mmdb) at SWWAF_LOOKUP_DB_PATH. You download the database with an IPinfo account and mount it into the container. It answers at once, with nothing sent off the host. A client whose address it does not hold counts as from an unknown country. smallwebwaf reads the whole file at start. It reads the file again once it has gone 2 seconds without a change after you replace it. A replacement it cannot read is logged and alerted, and the file read before stays in use. Mount the directory that holds the file, not the file itself. Many editors and download tools save a file by replacing it, and a container keeps seeing the old file when only the file is mounted. IPinfo releases the database under the Creative Commons Attribution-ShareAlike 4.0 licence. It asks for a link to IPinfo on the site that uses it, such as "IP address data is powered by IPinfo".

Blocklists

SWWAF_BLOCKLIST_URLS lists text files of addresses and netblocks, one to a line, as the Spamhaus DROP list is written. Anything after a ; or # is left out. An IPv4-mapped address such as ::ffff:192.0.2.0/120 is read as the IPv4 netblock it stands for.

Each list is fetched again SWWAF_BLOCKLIST_REFRESH after its last try, one list at a time. The file of SWWAF_ASN_LIMIT_PERCENT_URL is too. reputation.json keeps when each was last tried, so a restart does not fetch a list again before its time. A fetch fails when:

  • the server answers other than 200;
  • the fetch does not finish within a minute;
  • the list is longer than 16 MiB;
  • a line is not an address or netblock, or, for the AS number file, not AS<number>:<percent>.

The copy fetched before stays in use. The failure is logged and raised as a source_failure alert. The log, the alerts, the ban notes and the metrics name each list by its URL, so keep a secret out of it.

deny suits lists of networks that send nothing legitimate, such as DROP. limit:<percent> suits lists of addresses shared with ordinary visitors, such as Tor exits. The Spamhaus DROP list is The Spamhaus Project's. Its terms ask that a product using it credit The Spamhaus Project and keep the list's date and copyright lines with the data, which the copy in reputation.json does. They also ask that it be fetched no more than once an hour. Each smallwebwaf fetches its own copy, so several on one host, sharing one address, can fetch within the same hour.

DNS blocklists

Each zone in SWWAF_DNSBL_ZONES is asked about a client's own address in the background, the first time the client is seen. The request goes on at once, and so does every request from the client until the zone has answered. The name asked about is the one RFC 5782 gives: the numbers of an IPv4 address reversed, or the 32 hex digits of an IPv6 address reversed. 192.0.2.99 is asked about as 99.2.0.192.dnsbl.dronebl.org. An answer in 127.0.0.0/8 lists the client, but one in 127.255.255.0/24 is Spamhaus refusing the query. A name that does not exist does not list the client. These are failures:

  • an answer in 127.255.255.0/24;
  • an answer outside 127.0.0.0/8;
  • a resolver error;
  • no answer within SWWAF_REPUTATION_TIMEOUT.

A failure is logged and alerted, and the zone is not asked again for a minute. Each verdict is kept for SWWAF_REPUTATION_CACHE_TTL, in memory and in reputation.json.

The key of a Spamhaus zone under dq.spamhaus.net is its first label. smallwebwaf shows ******** in its place wherever it names the zone. Only reputation.json keeps the zone with its key.

AbuseIPDB

While SWWAF_ABUSEIPDB_KEY is set, smallwebwaf asks AbuseIPDB's check endpoint for the abuse confidence score of a client, from 0 to 100. Only a client that has committed an offence is checked. An offence is one of:

  • a broken limit;
  • a ban rule match or a trap path;
  • a refusal by a block rule, the Core Rule Set or a token check.

The check runs in the background, at the client's first request after the offence that no ban refuses, so no request waits. The score applies to the client's later requests. For an IPv6 client, AbuseIPDB is asked about the address of the request that set off the check, and the score is used for the whole group. A score at or over SWWAF_ABUSEIPDB_MIN_SCORE is a hit, and SWWAF_REPUTATION_ACTION says what is done. Each score is kept for SWWAF_REPUTATION_CACHE_TTL.

At most SWWAF_ABUSEIPDB_DAILY_BUDGET checks are made in a day, counted in UTC and kept in reputation.json. A restart does not make the budget whole again. The check that spends the last of the budget is logged and alerted. No client is checked until the day ends. A check fails when:

  • AbuseIPDB answers other than 200;
  • the answer gives no score;
  • no answer comes within SWWAF_REPUTATION_TIMEOUT.

A failure is logged and alerted, and no client is checked for a minute. The key is sent in the Key header and nowhere else, and is never logged.

CrowdSec

While SWWAF_CROWDSEC_LAPI_URL is set to the local API of a CrowdSec engine you run, smallwebwaf fetches its decision list every minute. It sends SWWAF_CROWDSEC_LAPI_KEY in the X-Api-Key header. The list is kept as a blocklist is kept. The decisions of the type ban on an address or a netblock are used. Any other, such as a captcha or a country, is left out. A decision lasts for the duration it had left when the engine answered.

A request is refused when a decision still in force names its client. The client's netblock is banned until the decision ends, with the cause crowdsec. Only a client that sends a request is banned, so a long list does not fill bans.json. Such a ban is never made permanent, and does not lengthen the netblock's next ban for a broken limit. If you lift a ban while the decision is in force, the client's next request makes another. Delete the decision in CrowdSec first, such as with cscli decisions delete --ip 203.0.113.9, and lift the ban once the list has been fetched again. A fetch fails when:

  • the engine answers other than 200, a redirect included;
  • the fetch does not finish within a minute;
  • the answer is longer than 16 MiB;
  • the answer is not a list of decisions.

Rationale

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;
  • 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 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 are banned after half as many requests as anyone else". That is the useful thing to say when nearly all abuse comes from a known list of AS numbers. EVALUATION.md goes through the candidates one by one.

smallwebwaf fills that gap with:

  • per-client limits that ban;
  • lower limits for the networks and countries abuse comes from;
  • bans that grow with repetition;
  • a log that is easy to search and ship;
  • one binary in the app's own container, with no database and no required setting.

Design

  • cmd/smallwebwaf: the binary, which only calls internal/smallwebwaf.
  • internal/smallwebwaf: the process. It reads the settings, the rule files, the lookup database and the state files, listens, and serves until SIGTERM or SIGINT. Then it stops, writing the state files. Run as smallwebwaf healthcheck, it is the image's health check instead.
  • internal/config: reads the settings, the one place they are read.
  • internal/proxy: what happens to each request. It works out the client and runs the checks in check. It passes the request to the app with the standard library's httputil.ReverseProxy, within the timeouts and size limits. It answers its own endpoints in answerAdmin. Once the request has ended, it counts bytes, refusals and anomalies, and writes the log line.
  • internal/ratelimit: the table of clients. It counts requests, bytes and refusals, and keeps each client's history.
  • internal/bans: the ban ledger. It holds each netblock's bans with their notes, decides how long a new ban lasts, and picks the ban to drop past SWWAF_MAX_BANS.
  • internal/rules: reads the rule files, and again as they change, and tells which rules a request matches.
  • internal/waf: the Core Rule Set with the six changes, as Coraza directives.
  • internal/lookup: looks clients up through GeoJS or in the lookup database.
  • internal/reputation: fetches the lists, asks the DNS blocklists and AbuseIPDB in the background, and keeps their answers.
  • internal/anomaly: the anomaly counters and their alerts.
  • internal/alerts: holds back repeats and alerts past the hourly limit, and sends the rest to the webhook, Slack and ntfy, each from a queue of its own.
  • internal/state: reads the state files at start, takes in edits while running, and writes the files when due and at the stop.
  • internal/requestlog: the lines on stdout.
  • internal/remotelog: sends those lines to the syslog server. It is written with the standard library alone, since log/syslog writes only the older syslog format.
  • internal/metrics: the metrics, served in the Prometheus text format.
  • Dockerfile: the lint and test phases, then the image. Its last stage installs Ubuntu's packages, nixpkgs, runsvinit and smallwebwaf, with share/smallwebwaf.run as runit's run script and share/rules.d/00-default.rules as the default rule file.
  • deploy/example-app: an app built on the image, which script/example-app checks.

Besides the standard library, it uses:

  • github.com/hashicorp/golang-lru/v2 for the bounded tables;
  • github.com/prometheus/client_golang for the metrics;
  • github.com/fsnotify/fsnotify to notice edited files;
  • github.com/oschwald/maxminddb-golang/v2 to read the lookup database;
  • github.com/corazawaf/coraza/v3 to run the Core Rule Set, which github.com/corazawaf/coraza-coreruleset/v4 at v4.25.0 carries;
  • github.com/maxmind/mmdbwriter, in the tests, to write lookup databases.

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 what the other scripts need on the host. make bootstrap. They need:
    • 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 on the host, with Go installed. make build.
  • script/run: builds bin/smallwebwaf and runs it. Its state files go in bin/state unless SWWAF_STATE_DIR is set. Its rule files are those of share/rules.d unless SWWAF_RULES_DIR is set. make run.
  • script/example-app: builds the image and the example app on it, runs it with a volume, and checks it as "Checking that it works" above describes. Then it removes the containers, volumes and images. Needs network access. make example-app.
  • script/gitea-requests: builds the image and runs gitea 1.27.3 behind it at the defaults. It sends through it a sample of gitea's ordinary use, and the requests the Core Rule Set is known to refuse. It prints what smallwebwaf did with each and the rule ids that matched, so that those requests can be checked again when gitea or the Core Rule Set changes version. Needs network access, and a docker daemon that pushes to a registry on localhost over plain HTTP. make gitea-requests.

TODO

  • Everything SPEC.md describes is built.
  • Two points SPEC.md defers until there is experience to go on: sharing bans between the instances of several apps, and sending the log over RELP.

Documents

License

MIT. See LICENSE.

Author

@sneak