Files
smallwebwaf/SPEC.md
T
clawbot 2e35528c62
check / check (push) Successful in 2m47s
Settle the open points of the Ubuntu and nixpkgs image (closes #38)
ca-certificates, nix-bin and runit come from a dated Ubuntu snapshot no
older than the pinned Ubuntu image. The Dockerfile names the SHA-256 hash
of each snapshot InRelease file apt uses, and the build checks them before
apt-get install, so every package is checked against hashed files. That
install uses the Go image's CA certificate file. ca-certificates is
installed by name. The image writes build-users-group = to
/etc/nix/nix.conf so root can build without a daemon. nixpkgs comes from
its release file on releases.nixos.org, checked by SHA-256, and takes about
500 MiB of disk. runsvinit is archived upstream and is built at a fixed
commit with a go.mod written for the build. The example run scripts put
their code in a main function.

Model: opus-5-5
2026-10-04 02:42:56 +02:00

100 KiB

smallwebwaf SPEC (draft): protective reverse proxy for one app

Status: fourth draft, with the owner's rulings to date applied. Milestone 1 of the build order is built. EVALUATION.md beside this file explains why no existing tool was chosen.

Purpose

A reverse proxy that runs inside the container of the one application it protects, between traefik and the app. The app's Dockerfile builds FROM the smallwebwaf image; in the container, smallwebwaf listens on port 8080, where traefik sends the app's requests, and forwards them to the app on 127.0.0.1:8081 (see "Deployment"). It limits request and byte rates per client, bounds the size and duration of every request and response, detects attacks, bans abusers (briefly at first, for seven days on a clear sign of attack, permanently when they keep at it), consults IP reputation sources, looks up the AS number and country of each client, can refuse whole countries, lowers its limits for listed AS numbers and countries, and sends alerts.

It is meant to protect an app on the open internet from the first request with no setting at all: every setting has a default chosen for a service facing the internet in 2026. Everything is configured by environment variables, each named with the SWWAF_ prefix, apart from the attack detection rules, which are read from a directory of hand-editable text files.

Non-goals

  • TLS termination, certificates, hostname routing: traefik's job.
  • More than one application per smallwebwaf. Each app's image carries its own.
  • Browser challenges (captcha, proof of work). If wanted, chain Anubis between smallwebwaf and the app.
  • Defence against traffic floods that saturate the host's network link. That needs help upstream of the host.
  • A web UI or a configuration file. Settings are environment variables. Apart from settings given as files (the _FILE form of any setting, such as SWWAF_ADMIN_TOKEN_FILE, and SWWAF_LOG_REMOTE_TLS_CA_FILE), its own state files and the lookup database, the only files read are the rule files, which hold one regex per line and nothing more elaborate.
  • Sharing bans between the smallwebwaf instances of several apps in the first version: each instance keeps its own. Running one CrowdSec engine per host, which every instance would report its bans to, is reconsidered once real ban volumes are known. Reading a CrowdSec decision list is supported (see Reputation).

Architecture

  • One statically linked Go binary, shipped in an image built on Ubuntu 26.04 LTS with nixpkgs installed, which is the base of the app's own image, so that smallwebwaf and the app run in one container (see "Deployment"). There runit starts smallwebwaf as its own non-root user, and smallwebwaf writes only to its state directory.
  • One listener, on port 8080, which traefik routes to. It forwards requests to the app on 127.0.0.1:8081, and it answers the endpoints of smallwebwaf itself for health, metrics and ban management, under /_smallwebwaf/, which never reach the app (see "Admin endpoints").
  • Components inside the process:
    • Client identification: works out the real client IP from X-Forwarded-For, trusting only the proxy netblocks in SWWAF_TRUSTED_PROXIES, by default the private address ranges.
    • Lookup: AS number and country, by default from the GeoJS web service, whose answers are kept for seven days, or instead from a database file the operator supplies, held in memory.
    • Reputation: static blocklists fetched on a schedule; DNSBL and reputation API queries made in the background and cached.
    • Counters: per-client request and byte counts per minute, hour and day, held in memory and written out with the rest of the state, so they survive a restart.
    • Attack detection: regex rules read from a directory of plain text rule files and read again whenever the files change (see "Rule files"), Coraza with the OWASP Core Rule Set, and simple signals (requests for listed trap paths, bursts of requests refused by the rule files or the Core Rule Set).
    • Ban ledger: active and past bans with their notes, and each client's history, held in memory and kept in JSON files that smallwebwaf watches for an admin's edits (see "Bans" and "Persistent state"). No database of any kind is used.
    • Request log: one JSON object per request on stdout, optionally also sent to a remote syslog server (see "Request log").
    • Metrics: Prometheus counters and gauges (see "Metrics endpoint").
    • Alerting: a queue with de-duplication feeding webhook, Slack and ntfy senders.
    • Proxy: the standard library's net/http/httputil.ReverseProxy, streaming in both directions within the size and time limits, with WebSocket upgrade support.
  • Proposed libraries (to be checked against the owner's Go dependency defaults before any are added):
    • standard library for the proxy, HTTP clients, DNS, logging (log/slog), state files (encoding/json, os.Rename);
    • github.com/corazawaf/coraza/v3 and github.com/corazawaf/coraza-coreruleset/v4 at v4.25.0, which carries the Core Rule Set 4.25.0, for attack detection. The changes smallwebwaf makes to the Core Rule Set and the default SWWAF_WAF_DISABLED_RULES (see "Configuration surface", attack detection) are built for that version, since rule ids and what each rule matches change between versions, and are checked again before the version changes;
    • github.com/oschwald/maxminddb-golang/v2, the current major version, to read the lookup database;
    • github.com/fsnotify/fsnotify to notice files that are edited or added while smallwebwaf runs;
    • github.com/prometheus/client_golang for metrics;
    • remote log sending is written in this project: the standard library's log/syslog is frozen and writes only the older syslog format, not the current one (RFC 5424). RELP is not in the first release; it is revisited against the available packages afterwards.

Data flow for one request

Steps run in this order; the first step that produces a final answer ends processing. The size and time limits (see "Configuration surface") apply to the whole exchange.

  • A GET /_smallwebwaf/healthz is answered at once (see "Admin endpoints").
  • Identify the client.
    • If the TCP peer is inside SWWAF_TRUSTED_PROXIES, walk X-Forwarded-For from the right and take the first address not inside SWWAF_TRUSTED_PROXIES. If every address in the header is inside SWWAF_TRUSTED_PROXIES, take the leftmost, so a visitor on a private network who comes through traefik is known by its own address, not traefik's. If there is no header, as when another container calls smallwebwaf directly, the TCP peer is the client.
    • If the TCP peer is outside SWWAF_TRUSTED_PROXIES, it is the client, and the header is ignored.
    • IPv6 clients are grouped by prefix (SWWAF_IPV6_GROUP_PREFIX, default 64) for counting and banning, because one abuser usually controls a whole /64. A client is therefore one IPv4 address or one IPv6 group, a /64 by default.
  • Static lists.
    • In SWWAF_ALLOW_NETS: skip every check below and forward, or answer a request under /_smallwebwaf/ (see "Admin endpoints"). Still counted for anomaly alerts.
    • In SWWAF_DENY_NETS: refuse.
  • Ban ledger. An active ban on the client's netblock: refuse with SWWAF_BAN_RESPONSE. If a clear sign of attack caused the ban, the ban becomes permanent (see "Bans").
  • Look up the AS number and country, unless SWWAF_LOOKUP_SOURCE is off. With GeoJS, the default, a request waits for its client's first answer, up to SWWAF_LOOKUP_TIMEOUT, only when a setting needs it before the request goes on: the country lists, the biased thresholds or SWWAF_ADD_LOOKUP_HEADERS. Otherwise the request goes on at once; the answer is added to the client's history and ban notes when it comes, and a request that ends before then is logged without it. A client the lookup cannot place, which includes every private, loopback and link-local address, has an unknown country and AS number: the exclusive country list refuses it, the biased thresholds give it SWWAF_UNKNOWN_LIMIT_PERCENT, and nothing else treats it differently.
  • Country lists. A client whose country is in SWWAF_DENIED_COUNTRIES, or, when SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set, is not in it, is refused with SWWAF_BAN_RESPONSE. Every step up to here needs only the client's address, so the request body has not been read yet, and the refusal skips everything below, the rule files and the Core Rule Set included. It is counted in the metrics, but it is not an offence and makes no ban.
  • Reputation.
    • Address inside a fetched blocklist: apply SWWAF_BLOCKLIST_ACTION.
    • Cached DNSBL or reputation API result: apply SWWAF_REPUTATION_ACTION.
    • No cached result: queue a background query and carry on. A first request is never delayed by a reputation query.
  • Work out the client's limit percentage: the lowest of the percentages that apply (AS number, country, reputation), or 100 if none applies.
  • Request rate limits. Unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS, check the minute, hour and day counters against each limit times the percentage. A request over any of them breaks that limit: it is refused with SWWAF_BAN_RESPONSE and the client is banned (see "Bans").
  • Rule files. The request is checked against the rules loaded from SWWAF_RULES_DIR, in file name order then line order. Each rule that matches takes its action: only log, refuse with 403, or refuse and ban. Matching stops at the first rule that refuses or bans.
  • Core Rule Set inspection of the request: its method, its URL with the query string, and its headers. By default no body is read. When SWWAF_WAF_BODY_LIMIT is set to a size, a body is read up to that size if it is form data, multipart, JSON or XML, the kinds the Core Rule Set can read. Any other body, such as a git push or a container image layer, reaches the app uninspected, and so does a JSON or XML body larger than SWWAF_WAF_BODY_LIMIT, which cannot be read in part. Responses are not inspected. In block mode, the default, a match at or over the anomaly threshold is refused with 403; in detect mode it is only logged and alerted.
  • A request under /_smallwebwaf/ is answered by smallwebwaf here and goes no further (see "Admin endpoints").
  • Forward to SWWAF_UPSTREAM_URL, streaming. Add X-Forwarded-For and, if enabled, X-Client-ASN and X-Client-Country for the app's own logs.
  • After the response.
    • Add response bytes (and request body bytes) to the byte counters. A client whose byte total passes a byte limit times the percentage has broken that limit and is banned. The response in progress is not cut off.
    • Count the client's requests that smallwebwaf refused after a rule file or Core Rule Set match, or for a missing or wrong token (see "Admin endpoints"); answers the app gave are not counted. More than SWWAF_ERROR_BURST_THRESHOLD of them within a minute breaks a limit and bans the client.
    • Update the client's history and the anomaly counters, and evaluate alert thresholds.

Counting method

  • Each window (minute, hour, day) uses two adjacent fixed buckets per client, with the previous bucket weighted by how much of it still overlaps the sliding window. This costs a few integers per client per window and avoids the burst at bucket boundaries that a single fixed bucket allows.
  • Counters are read and updated in memory; a request never waits on the disk. They are written to clients.json every SWWAF_STATE_COUNTER_INTERVAL and at shutdown and loaded again at start (see "Persistent state"), so a restart does not hand every client a fresh allowance. Buckets whose time has passed are discarded on load.
  • Memory is bounded by SWWAF_MAX_TRACKED_CLIENTS, SWWAF_MAX_BANS and the number of GeoJS answers kept (see "Configuration surface"). When the table of clients is full, the least recently seen client is dropped first, with its history. When SWWAF_MAX_BANS bans smallwebwaf made are held, the one that has gone longest without a request from its netblock is dropped first, whether it is past, active or permanent. Bans an admin made are never dropped, and there are as many as the admin adds (see "Bans").

Bans

A ban refuses every request from a netblock, answering with SWWAF_BAN_RESPONSE (403 by default), until the ban ends. A permanent ban does not run out: it ends when an admin lifts it or, if smallwebwaf made it, when a new ban is made while SWWAF_MAX_BANS bans smallwebwaf made are held and it is the one that has gone longest without a request. A scanner whose ban was dropped that way is refused and banned again by its next probe. A ban an admin made, whose cause is admin, is never dropped and does not count toward SWWAF_MAX_BANS, so such bans can never fill the table, however many there are. An admin who wants to keep a ban smallwebwaf made sets its cause to admin.

The netblock a ban covers is the client: its IPv4 address, or its IPv6 group of SWWAF_IPV6_GROUP_PREFIX (a /64 by default). SWWAF_BAN_SCOPE_V4_PREFIX can widen an IPv4 ban to the surrounding netblock. At the defaults a ban covers one address or one /64, so a permanent ban does not reach neighbours who did nothing.

An offence is a request smallwebwaf holds against the client: one that carries a clear sign of attack, breaks a limit, or is refused by a rule file or the Core Rule Set. Offences are counted by kind in the client's history. Two kinds lead to a ban, each by its own rule:

  • A clear sign of attack: a request that matches a rule file rule whose action is ban, or asks for a path in SWWAF_TRAP_PATHS. Examples are a probe for a .env file or a .git directory, and a known scanner's user agent.
    • The first one bans the netblock for SWWAF_ATTACK_BAN_DURATION (default 7d).
    • Any further request from the netblock while that ban lasts shows it is malicious: the ban becomes permanent.
    • Once that ban has run out, the netblock is served like any other, but its next clear sign of attack bans it permanently at once.
  • A broken limit: a request over a request limit, a response that takes the client's byte total past a byte limit, or more than SWWAF_ERROR_BURST_THRESHOLD requests within a minute refused after a rule file or Core Rule Set match, or for a missing or wrong token.
    • The first such ban, or one that comes more than SWWAF_LIMIT_BAN_REPEAT_WINDOW (default 24h) after the last such ban ended, lasts SWWAF_LIMIT_BAN_DURATION (default 1h).
    • Breaking a limit again within that window makes the new ban three times as long as the last one: one hour, then 3, 9, 27 and 81 hours.
    • A ban that would be longer than SWWAF_MAX_BAN_DURATION (default 7d) is permanent instead; with the defaults, that is the sixth ban in a row.
    • Each such ban sets the client's counters for every limit back to zero, so once it ends only new requests can break a limit again; the client's history keeps its totals.

A Core Rule Set match refuses only the request it matched, without a ban, because the Core Rule Set has false positives; a block rule does the same. Those refusals count toward the error burst, so a client that keeps setting them off is banned under the second rule. Requests refused under a ban are not counted against limits, but they are counted in the client's history and in the ban's notes.

Every ban carries notes with what an admin needs to decide whether to lift it (see "Persistent state"). An admin can add or lift a ban at any time by editing bans.json, or through the ban endpoints when SWWAF_ADMIN_TOKEN is set (see "Admin endpoints"). A ban lifted before it ends is kept, marked as lifted, and does not count toward a longer ban; deleting its entry from bans.json forgets it entirely.

Clients of the AS numbers and countries listed in the biased thresholds, and clients listed by a reputation source whose action is limit:<percent>, get lower limits (see "Biased thresholds"), so the same rules ban them after fewer requests. For them the result is always a ban, first a temporary one and then, for repeated abuse, a permanent one.

Some refusals make no ban at all. SWWAF_DENY_NETS, a blocklist or reputation hit whose action is deny, and the country lists (SWWAF_DENIED_COUNTRIES, SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES) refuse each request they cover and record nothing in bans.json.

Configuration surface

Conventions: lists are comma separated; netblocks are CIDR (a bare address means /32 or /128); durations use Go syntax plus d for days (90s, 15m, 24h, 7d); byte sizes accept K, M, G suffixes, which are powers of 1024: 1K is 1024 bytes, 1M is 1024K and 1G is 1024M. Countries are two-letter ISO codes in either case (de and DE are the same); a code that is not a country code, such as nk (North Korea is kp), stops the start with a message naming it.

  • No setting is required. Every setting has a default chosen for a service facing the internet in 2026, or stays off until the operator supplies what it needs: an alert destination, an account key, a token.
  • Every setting's name starts with SWWAF_, since smallwebwaf shares its container, and so its environment variables, with the app it protects.
  • Any limit or threshold can be switched off with the value off.
  • A list set to an empty value is an empty list, and replaces the default.
  • Every setting may instead be given as a file holding the value, named by the setting's name with _FILE added, such as SWWAF_ADMIN_TOKEN_FILE, for secrets and long lists.
  • Settings, including those given as files, are read once at start; changing one means restarting the container. The files smallwebwaf watches while it runs are its state files, its rule files and the lookup database.
  • Invalid configuration stops the process at start with a message naming the variable. At start the effective configuration is logged with secrets masked.

The settings, by group:

  • Core
    • SWWAF_UPSTREAM_URL (default http://127.0.0.1:8081): the application, which listens there in the same container (see "Deployment").
    • SWWAF_LISTEN_ADDR (default :8080): the one listener, for the requests smallwebwaf forwards and for its own endpoints (see "Admin endpoints").
    • SWWAF_ADMIN_TOKEN: bearer token for the ban endpoints and /_smallwebwaf/clients/<ip>, a long random value, since they can be reached from the internet. A token shorter than 32 characters stops the start with a message naming the variable. Unset by default, which switches them off; bans are then managed by editing bans.json.
    • SWWAF_INSTANCE_NAME (default: the container's host name, which docker sets to the first 12 characters of the container's id unless the deployment names one): included in every log line, metric and alert. Set it, for example to fsn1app1/gitea, for a name that stays the same when a deploy replaces the container and that tells instances apart when several report to one place.
    • SWWAF_MODE (default enforce): enforce, or observe to log and alert on every decision while refusing nothing.
    • SWWAF_TRUSTED_PROXIES (default 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, the private address ranges): netblocks whose X-Forwarded-For is believed, normally where traefik reaches smallwebwaf from. The header is used only when the TCP peer is inside this list; otherwise the peer's own address is the client, so a client that connects directly cannot claim another address. A list given replaces the default; set but empty, it trusts nothing.
    • SWWAF_IPV6_GROUP_PREFIX (default 64).
    • SWWAF_MAX_TRACKED_CLIENTS (default 20000): clients held in memory and in clients.json (see "Persistent state").
    • SWWAF_MAX_BANS (default 5000): bans smallwebwaf made, past, active and permanent, held in memory and in bans.json. Bans an admin made are kept besides these and never dropped (see "Bans").
  • Persistent state
    • SWWAF_STATE_DIR (default /var/lib/smallwebwaf): the JSON state files, in a directory of their own beside the app's data, belonging to the smallwebwaf user. A volume mounted there keeps the state when a deploy replaces the container (see "Deployment").
    • SWWAF_STATE_WRITE_DELAY (default 10s): after a ban is made, lifted or made permanent, bans.json is written this long later with every change made in between, so a burst of changes becomes one write.
    • SWWAF_STATE_COUNTER_INTERVAL (default 15m): how often clients.json, lookups.json, reputation.json and alerts.json are written, and bans.json when only the counts in its notes changed.
  • Logging
    • The request log on stdout is always on and has no switch.
    • SWWAF_LOG_LEVEL (default info): for the process's own messages (start-up, fetch failures, state writes), which are JSON lines on stdout too, marked "type":"process". It does not filter the request log.
    • SWWAF_LOG_REQUEST_HEADERS (default accept,accept-language,accept-encoding,content-type,origin,range): extra request headers to record. Authorization, Cookie and Set-Cookie values are never logged, only whether they were present.
    • SWWAF_LOG_REMOTE_URL: when set, every log line is also sent to this endpoint. Forms: syslog+udp://host:514, syslog+tcp://host:514, syslog+tls://host:6514.
    • SWWAF_LOG_REMOTE_TLS_CA_FILE: optional CA certificate for the +tls forms.
    • SWWAF_LOG_REMOTE_BUFFER (default 10000): lines held in memory while the endpoint is unreachable; when full the oldest are dropped and counted.
    • SWWAF_LOG_REMOTE_FACILITY (default local0), SWWAF_LOG_REMOTE_APP_NAME (default SWWAF_INSTANCE_NAME): syslog header fields.
  • Metrics
    • SWWAF_METRICS_TOKEN: bearer token a scraper sends for /_smallwebwaf/metrics, a long random value. A token shorter than 32 characters stops the start with a message naming the variable. Unset by default, which switches the metrics off, since they would otherwise be open to anyone on the internet.
    • SWWAF_METRICS_TOP_N (default 50): how many AS numbers and countries get their own series; the rest are summed as other.
  • Static lists
    • SWWAF_ALLOW_NETS: bypass everything (monitoring, the owner's own networks).
    • SWWAF_RATE_LIMIT_EXEMPT_NETS: bypass request and byte limits only; the error burst threshold, attack detection and bans still apply.
    • SWWAF_DENY_NETS: always refused.
  • Request rate limits, per client (R1, R2). Breaking one bans the client (see "Bans"), so the defaults sit several times above what one busy person produces: a browser loading a heavy page makes a few hundred requests, a git clone a handful, and several people often share one address. A machine that talks to the app all day, such as a gitea Actions runner (see the notes specific to gitea under "Deployment"), can still pass the day limit; its address belongs in SWWAF_RATE_LIMIT_EXEMPT_NETS.
    • SWWAF_RATE_LIMIT_PER_MINUTE (default 1000), SWWAF_RATE_LIMIT_PER_HOUR (default 10000), SWWAF_RATE_LIMIT_PER_DAY (default 50000).
    • SWWAF_RATE_LIMIT_EXEMPT_PATHS: path prefixes not counted (static assets, health checks).
  • Byte limits, per client. A response's bytes are counted when it ends, so every default sits above the largest response allowed (SWWAF_RESPONSE_MAX_BYTES, 5 GiB) and no single download breaks one.
    • SWWAF_BYTES_LIMIT_PER_MINUTE (default 10G), SWWAF_BYTES_LIMIT_PER_HOUR (default 20G), SWWAF_BYTES_LIMIT_PER_DAY (default 50G).
    • SWWAF_BYTES_COUNT (default both): response, request or both.
  • Size and time limits, per request, in both directions. The client-facing timeouts apply between the client and smallwebwaf, the app-facing ones between smallwebwaf and the app, since a slow client and a slow app are separate problems. There is one size limit for request bodies and one for response bodies: smallwebwaf passes bodies through unchanged, so a limit on each side would bound the same bytes and the lower one would always decide. Bodies stream straight through, so a request body reaches the app while the client is still sending it.
    • SWWAF_CLIENT_REQUEST_TIMEOUT (default 60s): how long a client may take to send its request line and headers, and then, from the end of the headers, its body.
    • SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES (default 32K): the largest request line and headers a client may send. Over it, smallwebwaf answers 431 and closes the connection, and nothing reaches the app.
    • SWWAF_CLIENT_IDLE_TIMEOUT (default 120s): how long a kept-open connection may wait for its next request before smallwebwaf closes it. It is longer than the 90 seconds after which traefik, by default, closes a connection it is not using, so traefik closes first and never sends a request on a connection smallwebwaf is closing.
    • SWWAF_CLIENT_RESPONSE_TIMEOUT (default 30m): how long smallwebwaf may take to deliver one response to the client, from the end of the request to the last byte.
    • SWWAF_UPSTREAM_REQUEST_TIMEOUT (default 60s): how long smallwebwaf may take to connect to the app and send it the whole request.
    • SWWAF_UPSTREAM_RESPONSE_TIMEOUT (default 30m): how long the app may take to send one whole response, from the end of the request to the last byte.
    • SWWAF_REQUEST_MAX_BYTES (default 100M): the largest request body, as the client sends it and the app receives it.
    • SWWAF_RESPONSE_MAX_BYTES (default 5G): the largest response body, as the app sends it and the client receives it.
    • When a limit is passed before the response has started, smallwebwaf answers itself: 413 for a request body that is too large, 408 for a client that is too slow, 502 for a response that is too large, 504 for an app that is too slow. A request that announces a body larger than its limit is refused before anything reaches the app. Once the response has started it can only be cut off, and the connection is closed.
    • Go's HTTP server, on which smallwebwaf is built, reads a request's line and headers before smallwebwaf sees the request. A client that takes longer than SWWAF_CLIENT_REQUEST_TIMEOUT to send them gets no answer: the server closes its connection. Headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES are answered 431 by the server itself. Neither request gets a line in the request log.
    • A WebSocket connection leaves these limits behind once it is upgraded: it stays open until either side closes it.
  • Lookup of AS number and country (R7). On by default through GeoJS, which needs no account or file, so that country lookup works with no setup. The file is the alternative for a service that keeps its visitors' addresses on its own host.
    • SWWAF_LOOKUP_SOURCE (default geojs): geojs, file or off.
    • SWWAF_LOOKUP_DB_PATH: the file for file, the IPinfo Lite database in its .mmdb form (ipinfo_lite.mmdb), one file that carries both country and AS number; smallwebwaf reads its asn, as_name and country_code fields. The operator downloads it with a free IPinfo account and mounts it read-only. smallwebwaf never fetches it and holds no account or token for it. A file that is missing or unreadable at start stops the start.
    • Refreshing the file is the operator's business; IPinfo updates it daily. smallwebwaf notices when the file is replaced and reads it again; a replacement it cannot read is ignored, logged and sent as one file_error alert, and the file it has stays in use. Mount the directory that holds the file rather than the file itself: docker does not show a single mounted file being replaced on the host.
    • geojs asks the free GeoJS web service, which needs no account or key, about up to 200 addresses in one request (https://get.geojs.io/v1/ip/geo.json?ip=a,b,c), and reads each answer's country_code, asn and organization_name (the AS name). An answer without a country_code, which GeoJS sends for ranges it cannot place, and an asn of 64512, which it gives when it knows none, count as unknown. GeoJS is told the address of every new visitor, whether or not a setting uses the answer, since the request log, the metrics, the client's history and ban notes carry the AS number and country. Private, loopback and link-local addresses, which no source can place, are never sent.
    • Each GeoJS answer is kept in memory for 7 days, apart from the table of clients, so it outlasts a client dropped from that table; after 7 days the client's next request asks again. Up to 100,000 answers are kept, about 15 MiB; when that many are held, the answer used longest ago goes first. Writing the answers to lookups.json and reading them back at start, so that they survive a restart, comes in milestone 3 or later (#17); until then a restart loses them.
    • SWWAF_LOOKUP_TIMEOUT (default 1s): how long a request waits for its client's first answer when a setting needs it (see "Data flow for one request"); GeoJS normally answers in a fraction of that. At most one request to GeoJS is under way at a time, the addresses that arrive meanwhile are asked about together in the next one, up to 200 per request with the rest in the requests after it, and a request that takes longer than SWWAF_LOOKUP_TIMEOUT is abandoned. A client whose answer has not come in time counts as unknown until it comes: its later requests do not wait, and its address is asked about again in the background.
    • GeoJS publishes no rate limit, but its terms forbid "an excessive amount of API requests", judged by GeoJS alone, and let it block a caller. While GeoJS is slow, down or refusing smallwebwaf, clients with a kept answer are unaffected and new clients count as unknown, so SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES, when set, refuses them. smallwebwaf keeps asking with backoff and sends one source_failure alert per cooldown. A service that cannot accept this uses the file.
    • One source at a time: SWWAF_LOOKUP_SOURCE=file without SWWAF_LOOKUP_DB_PATH, or SWWAF_LOOKUP_DB_PATH with any other SWWAF_LOOKUP_SOURCE, the default geojs included, stops the start with a message naming both. So does a setting that needs lookups while SWWAF_LOOKUP_SOURCE is off: the country lists and the biased thresholds below, SWWAF_ADD_LOOKUP_HEADERS, and the per-AS-number anomaly thresholds.
    • SWWAF_ADD_LOOKUP_HEADERS (default false): pass X-Client-ASN and X-Client-Country to the app.
  • Country lists. Both are empty by default: the defaults judge a client by what it does, not by where it comes from. Clients in SWWAF_ALLOW_NETS are not checked.
    • SWWAF_DENIED_COUNTRIES: for example cn,ru,kp,ir,ua,by. Every request from a listed country is refused.
    • SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES: for example us,de. Only clients placed in a listed country get through. Every request from any other country is refused, and so is every request from a client the lookup cannot place: one whose address is missing from the database, one GeoJS has not answered for in time, and any client on a private address, such as a visitor on the local network, another container or internal monitoring. Those that should reach the app go in SWWAF_ALLOW_NETS. A list that let unplaced clients through would let every new client in whenever GeoJS stops answering.
    • Both may be set. SWWAF_DENIED_COUNTRIES then adds nothing, since the exclusive list already refuses every other country, and a code on both lists stops the start with a message naming it.
    • A refused request is answered with SWWAF_BAN_RESPONSE, as a banned client is, as soon as the client's address has been looked up, before its body is read. It skips the reputation checks, the limits, the rule files and the Core Rule Set, is logged with the action country_denied, and is counted in the metrics. It is a refusal, not a ban: it is not an offence and makes no ban record.
  • Biased thresholds (R8). The lists are empty by default, for the same reason as the country lists; the request log and the metrics show which AS numbers and countries a service's abuse comes from.
    • SWWAF_ASN_LIMIT_PERCENT: for example AS14061:50,AS16276:50,AS45102:25. Clients in a listed AS number get that percentage of every request and byte limit, so the rules in "Bans" ban them after fewer requests than others. 0 is a zero allowance: the client's first request breaks a limit and bans it.
    • SWWAF_COUNTRY_LIMIT_PERCENT: same form with ISO country codes, for example CN:25,RU:50. Each client from a listed country gets that percentage of the normal per-client limits.
    • No budget is shared by a whole country or AS number: one abuser could use it up and lock out everyone else there, a denial of service nobody chose and the one this tool exists to prevent. Refusing a whole country is left to the operator's choice, through the country lists.
    • When more than one percentage applies to a client (its AS number, its country, a reputation hit), the lowest applies.
    • SWWAF_ASN_BYTES_PERCENT, SWWAF_COUNTRY_BYTES_PERCENT: optional overrides applied to byte limits only, when the byte percentage should differ from the request percentage.
    • SWWAF_UNKNOWN_LIMIT_PERCENT (default 100): for clients the lookup cannot place.
    • SWWAF_ASN_LIMIT_PERCENT_URL: optional URL of a text file of AS:percent lines, so one abuse-source list can be shared by every smallwebwaf in the fleet. It is fetched and refreshed like the blocklists.
  • Attack detection (R9). By default the Core Rule Set reads the method, the URL with its query string, and the headers of every request, which is where automated attacks show: injection in query strings, path traversal, attacks carried in headers, scanners' user agents. It reads no request body. On a code forge, the example deployment, bodies carry what people write and publish, such as issue and comment text, wiki pages, files saved in the web editor and package descriptions, and when these hold shell commands or code the Core Rule Set takes them for attacks. The default gives up refusing an attack carried in a body; the rule files, the limits and the bans still apply to the client that sends it, and SWWAF_WAF_BODY_LIMIT switches body inspection on.
    • SWWAF_RULES_DIR (default /etc/smallwebwaf/rules.d): directory of rule files, read at start and again whenever a file in it changes; format under "Rule files".
    • SWWAF_RULES_ENABLED (default true): false skips rule files entirely.
    • SWWAF_WAF_MODE (default block): off, detect (log and alert only), or block.
    • SWWAF_WAF_PARANOIA_LEVEL (default 1), SWWAF_WAF_ANOMALY_THRESHOLD (default 5): the Core Rule Set's own two tuning values, at the Core Rule Set's own defaults.
    • smallwebwaf also changes the Core Rule Set 4.25.0 in six ways, since in front of gitea it would otherwise refuse ordinary requests. The changes hold in front of every app, and no setting undoes them:
      • PUT, PATCH and DELETE are allowed methods besides GET, HEAD, POST and OPTIONS; APIs, container image pushes and package uploads use them. Other methods stay refused.
      • The request header Expect is allowed, since curl and git send it before some large uploads. Content-Encoding is allowed on a request whose body the Core Rule Set does not read, which by default is every request: git compresses a fetch request over 1 KiB and says so in that header. On a body the Core Rule Set does read, the header stays refused, since a compressed body cannot be inspected. The other headers the Core Rule Set refuses, such as Proxy, stay refused.
      • The redirect_uri parameter is not checked for a URL naming an IP address or localhost (931100, 934110). Git Credential Manager, git-credential-oauth and tea, which gitea registers for OAuth sign-in out of the box, ask to be sent back to http://127.0.0.1 on the user's own machine, and the server never fetches that address.
      • The query parameters in which gitea sends file paths, branch and workflow names and the page to return to after signing in (path, files, skip-to, sub_path, ref, sha, branch, workflow, artifactName and redirect_to) are not checked against the Core Rule Set's lists of system files (930120), shell paths (932160) and command names (932260). In a repository any name on those lists can be an ordinary file or branch, such as .gitignore, package.json, docker-compose.yml, bin/docker-entrypoint or a branch named docker-build, and gitea reads these values as names within a repository or its own records, or as a page of its own site. Like the other changes, this one holds in front of every app, not only gitea, and no setting restores the three rules in those parameters. What only these three rules refuse is let through there, and that is more than a name such as /etc/passwd or whoami on its own: commands such as |cat /etc/passwd, wget http://… and nc -e /bin/sh …, and file:///etc/passwd, pass as well. Path traversal (../), SQL and script injection and PHP, Java and Node.js code are still refused there, and every other parameter keeps all three rules. An app that uses one of these parameters as a file on the server, or passes it to a shell, gets no help from the three rules there (see "Risks the design has to handle").
      • The Core Rule Set reads the request without the gitea_flash and redirect_to cookies, and does not check Referer for a Unix command given without arguments (932340) or for Java starting a process (944110). Gitea writes into gitea_flash a message for the next page naming what was just done, such as a file deleted in the web editor or a branch, milestone or project created, and into redirect_to the page to return to after signing in, often the one the visitor clicked "Sign in" on; Referer is the address of the page a request comes from. In these cookies the Core Rule Set takes names such as package.json, document.write.js or Get-ChildItem.ps1, and titles that hold them, for attacks. In Referer it takes the address of a search for one word, such as env, set or last, when the address ends in it, and the address of OpenJDK's src/java.base/share/classes/java/lang/Runtime.java, which holds both runtime and java.. A browser sends such a cookie with every request until gitea replaces or removes it, which gitea cannot do while smallwebwaf refuses those requests, so one refusal would keep the browser out of the whole site. A browser names a page in Referer on everything the page loads and on every link followed from it, so all of those would be refused. Gitea shows the message with any script removed and returns only to a page of its own site. Like the other changes, this one holds in front of every app: an attack sent in a cookie of either name is not refused by the Core Rule Set, nor is a Unix command without arguments or Java starting a process sent in Referer. Every other cookie is read in full, and Referer keeps the other rules, script and SQL injection among them.
      • Responses are not inspected. A raw file from a repository, such as a shell script, looks to the response rules like source code leaking from the server.
    • SWWAF_WAF_DISABLED_RULES (default 920340,920420,920440,920640,930130,930140): rule ids to switch off when an app trips a false positive. The default switches off the rules that refuse a request for the type of its body, when it is missing or not on the Core Rule Set's short list (920340, 920420, 920640); for its file extension, such as .sh or .sql (920440); and for a file or directory name in its path, such as .git/, .gitignore, Dockerfile, package.json or an editor's settings directory (930130, 930140). In front of a code forge these refuse git over HTTP, container image and package uploads, and views of ordinary files in a repository. At the site root, where no app serves such files, the default rule file bans the common probes these rules caught, such as /.env, /.git/config, /.aws/credentials, /.ssh/id_rsa, /.htpasswd and /wp-config.php.bak (see "Rule files"). A list given replaces the default, so include them in it.
    • SWWAF_WAF_EXEMPT_PATHS: path prefixes not inspected.
    • SWWAF_WAF_BODY_LIMIT (default off): off reads no request body. A size, such as 128K, has the Core Rule Set read form data and multipart bodies up to that size, streaming the rest of a longer one on without holding it in memory, and JSON and XML bodies no larger than it, since those cannot be read in part. Any other body is not read, since the Core Rule Set would read it as form data, where binary content such as a git push trips rules written for text. Body inspection suits apps whose forms carry no code. In front of gitea it refuses issue and comment text, wiki pages and files saved in the web editor that hold shell commands or code (932125, 932235, 932250 and others), package descriptions that show code, PyPI uploads (922130), and attachments named like debug.log or config.yml (932180), until the rule ids the request log names are added to SWWAF_WAF_DISABLED_RULES.
    • SWWAF_TRAP_PATHS: paths the app never serves and only scanners ask for, for example /wp-login.php,/xmlrpc.php in front of gitea. A request for one is a clear sign of attack. This is the env-var short form of a path rule with the ban action, for deployments that mount no rule files.
    • SWWAF_ERROR_BURST_THRESHOLD (default 30): requests per client per minute that smallwebwaf refused after a rule file or Core Rule Set match, or for a missing or wrong token; more than this breaks a limit (see "Bans"). A client trying one attack or token after another is refused many times a minute; a person rarely more than a few times. Answers the app gives are not counted, since they do not tell a scanner from an ordinary client: in front of gitea, container image and package clients are answered 404 by design, for each layer a push checks and each package a lookup asks about, often hundreds of times a minute, and git and registry clients are answered 401 at the start of every push, every fetch from a private repository and every image pull.
  • Bans (R5), following the rules under "Bans"
    • SWWAF_ATTACK_BAN_DURATION (default 7d): the ban for a first clear sign of attack.
    • SWWAF_LIMIT_BAN_DURATION (default 1h): the ban for a first broken limit.
    • SWWAF_LIMIT_BAN_REPEAT_WINDOW (default 24h): breaking a limit again within this time after a ban for a broken limit ended makes the next ban three times as long.
    • SWWAF_MAX_BAN_DURATION (default 7d): a ban that would be longer is permanent instead.
    • SWWAF_BAN_RESPONSE (default 403): 403, 429, or close to drop the connection without an answer. 403 makes a ban easy to recognise when debugging. Behind traefik, close does not leave the client unanswered: traefik answers 502, as it does whenever its backend drops a connection.
    • SWWAF_BAN_SCOPE_V4_PREFIX (default 32): widen to for example 24 to ban the surrounding netblock.
  • Reputation (R6). No source is on by default, for the reason the country lists and the biased thresholds are empty: a source refuses the clients that a list kept elsewhere names, or lowers their limits, while the defaults judge a client only by what it does to this service. AbuseIPDB and CrowdSec also need an account or an engine of the operator's own. Besides, the list best suited to be on by default, Spamhaus DROP, must not be downloaded automatically more than once an hour, and Spamhaus may block an address that downloads it too often. Each smallwebwaf fetches its own copy, so on a host that runs several apps with it, which share one address, downloads would often come less than an hour apart.
    • SWWAF_BLOCKLIST_URLS: text files of addresses and netblocks, one per line; anything after a ; or # on a line is ignored. For example the Spamhaus DROP list, https://www.spamhaus.org/drop/drop.txt. Spamhaus's terms for it, on its DROP page: automated downloads must be at least one hour apart, and once a day is more than enough in most cases; a product that uses it must credit The Spamhaus Project and keep the list's date and copyright lines with the data. Refreshed every SWWAF_BLOCKLIST_REFRESH (default 24h); the last good copy is kept whole, comment lines included, on failure and across restarts.
    • SWWAF_BLOCKLIST_ACTION (default deny): deny, limit:<percent>, or log. deny refuses every request from a listed address, which suits lists of networks that send nothing legitimate, such as DROP. limit:<percent> gives listed clients that percentage of every limit, so they are banned after fewer requests; it suits a list of addresses shared with ordinary visitors, such as Tor exits.
    • SWWAF_DNSBL_ZONES: for example dnsbl.dronebl.org. Zones meant for mail (lists of residential ranges) will block ordinary visitors and should not be used; Spamhaus zones need their keyed query service, given as a zone name containing the key.
    • SWWAF_DNSBL_RESOLVER: optional resolver address, since public resolvers are refused by several list operators.
    • SWWAF_ABUSEIPDB_KEY, SWWAF_ABUSEIPDB_MIN_SCORE (default 75), SWWAF_ABUSEIPDB_DAILY_BUDGET (default 900; the free tier allows 1000 checks a day). Only clients that have already committed one offence are queried, so the budget is spent on suspects.
    • SWWAF_CROWDSEC_LAPI_URL, SWWAF_CROWDSEC_LAPI_KEY: optional. If a CrowdSec engine exists on the host, pull its decision list on a schedule and treat listed addresses as banned. This is how a fleet-wide blocklist can arrive without smallwebwaf depending on CrowdSec. The list is kept like a fetched blocklist, and a listed client's request makes a ban with the cause crowdsec that lasts as long as CrowdSec's decision, so a long list does not fill bans.json.
    • SWWAF_REPUTATION_ACTION (default limit:25): deny, limit:<percent>, or log, for DNSBL and API hits. Such verdicts are less certain than a blocklist, so by default a listed client gets a quarter of every limit and is banned after a quarter of the requests.
    • SWWAF_REPUTATION_CACHE_TTL (default 24h), SWWAF_REPUTATION_TIMEOUT (default 2s).
  • Alerting (R3)
    • SWWAF_ALERT_WEBHOOK_URL: JSON POST; schema below. SWWAF_ALERT_WEBHOOK_HEADERS: optional Name:value pairs for authentication.
    • SWWAF_ALERT_SLACK_WEBHOOK_URL: Slack incoming webhook, formatted message.
    • SWWAF_ALERT_NTFY_URL (full topic URL), SWWAF_ALERT_NTFY_TOKEN: title, priority and tags set from the event type.
    • SWWAF_ALERT_EVENTS (default ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error): which event types are sent. source_failure is a reputation source or GeoJS failing or refusing smallwebwaf. file_error is a rule file or state file edited while running that does not parse, a replacement lookup database that cannot be read, or a state file that cannot be written.
    • SWWAF_ALERT_COOLDOWN (default 15m): the same event type for the same client or netblock is not repeated within this time; a count of suppressed repeats is included in the next one.
    • SWWAF_ALERT_MAX_PER_HOUR (default 60): beyond this, alerts are rolled into one summary per hour so a wide attack cannot flood the channel.
  • Anomaly thresholds, alert only, nothing is blocked (R4). None is set by default, and a threshold left unset sends no alert: these only alert, an alert needs a destination only the operator can supply, and what counts as unusual depends on each service's normal traffic, which the metrics show.
    • Per client: SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE, SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR, SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE, SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR.
    • Per surrounding netblock (SWWAF_ANOMALY_NET_V4_PREFIX default 24, SWWAF_ANOMALY_NET_V6_PREFIX default 48): SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE, ..._PER_HOUR, SWWAF_ANOMALY_NET_BYTES_PER_MINUTE, ..._PER_HOUR.
    • Per AS number, which needs a lookup source: SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE, ..._PER_HOUR, SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE, ..._PER_HOUR.
    • Whole service: SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE, ..._PER_HOUR, SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE, ..._PER_HOUR.
    • Named netblocks: SWWAF_WATCH_NETS, for example office=203.0.113.0/24,scraper-x=198.51.100.0/22, with SWWAF_WATCH_REQUESTS_PER_MINUTE, ..._PER_HOUR, SWWAF_WATCH_BYTES_PER_MINUTE, ..._PER_HOUR applied to each named block as a whole.
    • Anomaly counting includes clients in SWWAF_ALLOW_NETS and SWWAF_RATE_LIMIT_EXEMPT_NETS, since an exempt client misbehaving is worth knowing about.

Rule files

A required feature: smallwebwaf reads every *.rules file in SWWAF_RULES_DIR and checks each request against them. The files are plain text meant to be edited by hand, so that a new scanning pattern seen in the request log can be turned into a rule in one line. smallwebwaf watches the directory: a file edited, added or removed there takes effect while it runs.

  • One rule per line, four fields separated by spaces or tabs; the fourth field runs to the end of the line:

    <id>  <target>  <action>  <regex>
    
  • Blank lines and lines starting with # are ignored.

  • id: a short name of letters, digits, - and _, unique across all files. It appears in the request log, metrics, alerts and ban notes.

  • target: what the regex is matched against.

    • path: the URL path as received, before any decoding.
    • query: the raw query string.
    • uri: path and query together, both as received and once percent-decoded, so an encoded probe cannot slip past.
    • method, host, user_agent, referer.
    • header:<Name>: any one request header.
    • Request bodies are not available to rule files; reading them is the Core Rule Set's job, once SWWAF_WAF_BODY_LIMIT switches it on.
  • action:

    • log: note the match in the request log and do nothing else.
    • block: refuse the request with 403. The client is not banned for it, but the refusal counts toward the error burst (see "Bans").
    • ban: the request is a clear sign of attack. Refuse it and ban the client's netblock for seven days (SWWAF_ATTACK_BAN_DURATION); any further request during those days, or a later clear sign of attack, makes the ban permanent (see "Bans"). Use it only for requests no real visitor sends, and anchor a path at the site root (^/): a file of the same name deeper in a site can be ordinary content, such as a file in a gitea repository.
  • regex: Go regular expression syntax (RE2). It has no backreferences or lookaround, and in exchange matching time is linear in the input, so no rule can be made to stall the proxy. (?i) at the front makes a rule case-insensitive. A rule matches if the regex matches anywhere in the target; anchor with ^ and $ when that is not wanted.

  • Files are read in name order (00-default.rules before 50-gitea.rules), rules in line order.

  • Rules are compiled when their file is read and held in memory. At start, a line that does not parse, a regex that does not compile, or a duplicate id stops the process with a message naming the file and line. While running, the same faults leave the rules as they were, including the earlier version of that file, and the log and one file_error alert name the file and line; once the file is fixed, it is read again. A SWWAF_RULES_DIR that does not exist stops the start with a message naming it. An empty directory is not an error: the log says that no rules were loaded. To run without rule files, mount an empty directory or set SWWAF_RULES_ENABLED=false.

  • The image ships a default file in SWWAF_RULES_DIR. The app's Dockerfile can copy files of its own into it, beside the default. Mounting a directory over it replaces the defaults; mounting single files into it adds to them. Docker does not show a single mounted file being replaced on the host, which is how many editors save, so rules meant to be edited while smallwebwaf runs belong in a mounted directory, with a copy of the default file if the defaults are to stay.

  • In SWWAF_MODE=observe every action is logged as what would have happened and nothing is refused.

  • Clients in SWWAF_ALLOW_NETS are not checked.

The default file the image ships:

# 00-default.rules: probes no real visitor sends, anchored at the site root

# id             target      action  regex
env-file         path        ban     (?i)^/\.env(\.[a-z]+)?$
vcs-dir          path        ban     (?i)^/\.(git|svn|hg|bzr)(/|$)
secrets-dir      path        ban     (?i)^/\.(aws|ssh|docker|kube)/
secret-file      path        ban     (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
editor-dir       path        ban     (?i)^/\.(vscode|idea)/
backup-file      path        ban     (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
log-file         path        ban     (?i)^/(debug|error|access)\.log$
compose-file     path        ban     (?i)^/(docker-)?compose\.ya?ml$
php-shell        path        ban     (?i)^/(shell|c99|r57|wso|alfa)\.php$
scanner-agent    user_agent  ban     (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
path-traversal   uri         block   (\.\./){2,}
empty-agent      user_agent  log     ^$

It is kept short and limited to patterns that are wrong for every app, and its path rules are anchored at the site root: in front of gitea, /.env is a probe, while /<owner>/<repo>/src/branch/main/.env.example is a file in a repository that any visitor or search crawler may open. Its path rules ban the common probes for secrets, version control directories, backups and logs. The Core Rule Set's rules for file names and extensions (930130, 920440) refuse such names anywhere in a path, which is why the default SWWAF_WAF_DISABLED_RULES switches them off: a code forge serves such files deeper in its paths. Anything app-specific belongs in a file the app's Dockerfile adds or the deployer mounts: a request for /wp-login.php, for example, is a clear sign of attack in front of gitea and an ordinary login in front of WordPress.

# 50-gitea.rules: WordPress probes, which gitea never serves
wp-probe         path        ban     (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)

Persistent state

All state lives in memory, and the files in SWWAF_STATE_DIR hold a copy of all of it, so an orderly stop loses nothing. The one exception is log lines still waiting to be sent to SWWAF_LOG_REMOTE_URL, which stdout has already carried. No database is used, and nothing is read from disk while serving a request. The files are meant for people as well: an admin can read or edit them at any time, and the running smallwebwaf takes the edit in.

  • Files, each holding one kind of state:
    • bans.json: active and past bans, up to SWWAF_MAX_BANS that smallwebwaf made and every ban an admin made. Per entry: the netblock, start, expiry (null for permanent), what caused it (attack, limit, admin or crowdsec), a short reason, when it was lifted if an admin lifted it, and a notes field (below).
    • clients.json: per client, its minute, hour and day counters and its history since it was first seen: first and last time seen, the AS number, AS name and country last looked up and when, total requests and bytes in each direction, how many requests were forwarded and how many refused, responses by status class, and offences by kind. Clients that were never banned are kept too, so every client's history survives a restart; GET /_smallwebwaf/clients/<ip> shows it.
    • lookups.json: GeoJS answers, one per client: the AS number, AS name and country, when GeoJS was asked and when the answer was last used; up to 100,000, each for 7 days. This file comes in milestone 3 or later; until then the answers are kept in memory only.
    • reputation.json: the last good copy of each list fetched from a URL, cached DNSBL and reputation API verdicts, each with the time it was fetched, and the AbuseIPDB checks spent today, so a restart does not reset the daily budget.
    • alerts.json: the anomaly counters for surrounding netblocks, AS numbers, named netblocks and the whole service; for each event type and client or netblock, when it was last sent and how many repeats the cooldown has held back since; the alerts sent this hour; and alerts still waiting to be sent.
  • Ban notes. The notes on a ban hold what an admin needs to decide whether to lift it, drawn from what smallwebwaf already knows; nothing is looked up to fill them:
    • the AS number, AS name and country, added when the lookup answers (empty when lookups are off);
    • what was broken: the rule ids and target that matched, or the limit, its window, the count reached and the client's limit percentage with what set it; and any reputation sources that listed the client;
    • the requests that caused the ban, up to the last ten: time, method, host, path with its query string, status and user agent, each text cut to 256 bytes;
    • how many requests counted toward the ban, and the time span over which they came;
    • the netblock's total requests since it was first seen, and the requests refused under this ban so far, kept up to date while the ban lasts;
    • how many earlier bans of each kind the netblock has had.
  • Size and disk writes. Each file is rewritten whole, so each has a bound:
    • clients.json takes about 1 KiB per client. Clients are dropped only when the table is full, so on a public service the file grows to the default SWWAF_MAX_TRACKED_CLIENTS of 20,000, about 20 MiB. Written every 15 minutes, that is under 2 GiB of disk writes a day.
    • bans.json takes about 2 KiB per ban and at most about 8 KiB, since the texts in the notes are cut short. At the default SWWAF_MAX_BANS of 5,000 it is about 10 MiB, and never more than about 40 MiB, plus whatever bans an admin made. It is written when a ban is made, lifted or made permanent, at most once every 10 seconds, and otherwise with the 15-minute write, so its writes follow the bans made: with a full file, a hundred new bans a day come to about 1 GiB of disk writes.
    • lookups.json takes about 150 bytes per answer, about 15 MiB when full. Written every 15 minutes, that is under 1.5 GiB of disk writes a day.
    • reputation.json and alerts.json are usually a few MiB or less.
    • Writing a file of these sizes takes well under a second on an ordinary disk, in the background, so rewriting whole files needs nothing cleverer.
  • Format: indented JSON with a top-level version number, entries sorted by client address, times in RFC 3339 UTC, durations and sizes as plain numbers with the unit in the field name. The aim is that a person can open bans.json in an editor, find an address, and remove or add an entry. clients.json and lookups.json put each entry on one line instead, which halves their size and lets grep show everything about one client.
  • Writing:
    • serialise from a snapshot taken under the lock, so requests are not held up while the file is written;
    • write to a temporary file in the same directory, sync it, rename it over the real name, sync the directory. A crash at any point leaves either the old complete file or the new complete file, never a partial one;
    • bans.json is written SWWAF_STATE_WRITE_DELAY after a ban is made, lifted or made permanent. Changes that only update the counts in its notes wait for the write every SWWAF_STATE_COUNTER_INTERVAL, as all of clients.json, lookups.json, reputation.json and alerts.json do. All five are written on orderly shutdown (SIGTERM);
    • before writing a file, smallwebwaf checks whether it changed on disk since it last read or wrote it; if it did, smallwebwaf takes that edit in first (below), so an admin's edit is never overwritten.
  • Reading at start:
    • Active bans go into an in-memory prefix lookup; everything else into maps. Counter buckets and cache entries whose time has passed are discarded.
    • A missing file means empty state and is normal on first run.
    • A file that does not parse, or has an unknown version, stops the process with a message naming the file and position. Starting with empty state would silently forgive every repeat offender, and since writes are atomic a broken file can only come from a hand edit, which the editor should hear about.
  • Edits while running:
    • smallwebwaf watches SWWAF_STATE_DIR and notices a file that is edited, replaced or added. It tells its own writes from an admin's by comparing the file with what it last wrote.
    • An edit that parses is taken in at once: what the file says replaces what smallwebwaf held for that file. Removing a ban's entry from bans.json lifts the ban; adding an entry bans. Changes smallwebwaf made after the admin opened the file, such as a new ban, are lost when the admin saves over them; most editors warn when a file changed on disk while it was open.
    • An edit that does not parse does not stop the running smallwebwaf. It keeps the state it has, renames the edited file to <name>.bad (for example bans.json.bad) so the edit is kept, writes the file again from memory, logs the file and the position of the error, and sends them in one file_error alert. The admin fixes the .bad file and moves it back.
  • SWWAF_STATE_DIR not writable at start: the process exits. A write that fails while running: state stays correct in memory, the failure is logged, counted in metrics, sent as a file_error alert once per cooldown, and retried at the next write.
  • What a hard kill can lose: ban changes from the last SWWAF_STATE_WRITE_DELAY (10 seconds), and everything else from the last SWWAF_STATE_COUNTER_INTERVAL (15 minutes): counts and history, the counts in ban notes, GeoJS answers, reputation verdicts and the AbuseIPDB count, anomaly counters and alert cooldowns. Losing the volume loses ban history, not service.

Request log

One JSON object per line on stdout for every request, including refused ones, apart from those Go's HTTP server ends before smallwebwaf sees them (see "Configuration surface", size and time limits). stdout is always on. When SWWAF_LOG_REMOTE_URL is set the same lines are also sent to the remote endpoint, so a deployment can stop depending on docker's log handling while docker logs keeps working.

  • Standard web log fields: time (RFC 3339 with milliseconds), instance, client_ip, method, scheme, host, path, query, protocol, status, request_bytes, response_bytes, referer, user_agent.
  • Request detail: request_id (generated if traefik did not supply one, and passed to the app), peer_ip (the TCP peer, normally traefik), forwarded_for (the header as received), client_group (the /64 or configured prefix used for counting), asn, as_name, country, content_type, content_length, the headers named in SWWAF_LOG_REQUEST_HEADERS, has_authorization and has_cookie as booleans, websocket when the connection was upgraded.
  • Response detail: response_content_type, upstream_status (differs from status when smallwebwaf answered itself), cache_control, location on redirects, aborted when the client went away early.
  • Decision: action (forward, banned, denied, country_denied, rate_limited, rule_blocked, waf_blocked, too_large, timed_out, upstream_error, and admin for a request smallwebwaf answered at one of its own endpoints), would_action in observe mode, limit_percent and which rule set it, counts (the client's minute, hour and day request and byte totals after this request), limit_hit (which window), rule_ids (rule file rules that matched), waf_rule_ids, waf_score, reputation (sources that listed the client), offence when one was recorded, ban_expires.
  • Timings in milliseconds: duration_total, duration_checks (everything smallwebwaf did before forwarding), duration_waf, duration_upstream_connect, duration_upstream_first_byte, duration_upstream_total.
  • Bodies are never logged. Query strings are logged as received; an app that carries secrets in query strings needs that fixed in the app.
  • The process's own messages share the stream as JSON lines with "type":"process"; request lines carry "type":"request".
  • Remote sending:
    • syslog forms send each line as the message of an RFC 5424 record, with octet-counted framing on TCP and TLS;
    • sending happens on its own goroutine from a bounded buffer (SWWAF_LOG_REMOTE_BUFFER). An unreachable or slow endpoint never delays a request and never stops stdout; it reconnects with backoff, drops the oldest lines when the buffer is full, and counts the drops in metrics. UDP gives no delivery signal at all and is offered only for compatibility.

Metrics endpoint

Prometheus text format at /_smallwebwaf/metrics, for a request carrying SWWAF_METRICS_TOKEN (see "Admin endpoints"). While the token is unset, the metrics are off. The token is separate from SWWAF_ADMIN_TOKEN, so a scraper that holds it cannot manage bans.

  • Traffic: requests and bytes in and out, by status class and action; request duration and upstream duration histograms; requests in flight.
  • Limits and bans: limit hits by window and kind (requests or bytes), size and time limit hits by limit, offences by kind, bans created by cause, permanent bans, active bans (gauge); requests refused by the country lists, by country.
  • Attack detection: rule file matches by rule id and action, and the number of rules loaded; Core Rule Set matches by mode and rule id (label limited to the rules that actually fired).
  • Lookup and reputation: requests and bytes by AS number and by country, limited to the SWWAF_METRICS_TOP_N (default 50) busiest of each with the rest summed as other, so the label set stays bounded; reputation queries, hits, failures and remaining daily budget by source; GeoJS requests and failures, and clients that counted as unknown because it did not answer in time; age of each blocklist and lookup database.
  • Housekeeping: tracked clients (gauge), state file writes, write failures, last successful write time and size per file; files read again after an edit, and edits set aside because they did not parse; alerts sent, failed and suppressed by destination; remote log lines sent, dropped and buffer depth; the standard Go runtime and process metrics.
  • No metric carries a client IP address as a label; per-address questions are answered by the request log and GET /_smallwebwaf/clients/<ip>.

Admin endpoints

smallwebwaf has one listener, on port 8080. A request whose path starts with /_smallwebwaf/ is for smallwebwaf itself: it answers it and never passes it to the app. The prefix carries the tool's name, so it takes no path an app uses. Admins and scrapers reach these endpoints through traefik, like any other request.

  • GET /_smallwebwaf/healthz: answers 200 with ok to anyone, without a token, while smallwebwaf is running; it does not ask the app, which the container's health check does (see "Deployment"). It is answered before any check, so a health checker never needs an exemption and is never refused, for example by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
  • GET /_smallwebwaf/metrics: the metrics (see "Metrics endpoint"); needs SWWAF_METRICS_TOKEN.
  • GET /_smallwebwaf/bans, POST /_smallwebwaf/bans (client or netblock, duration, reason), DELETE /_smallwebwaf/bans/<client>: need SWWAF_ADMIN_TOKEN. Editing bans.json does the same without a token.
  • GET /_smallwebwaf/clients/<ip>: needs SWWAF_ADMIN_TOKEN. Current counters, history, lookup result, reputation, offences, and bans with their notes; for answering "why was this address refused" and "what has this netblock been doing".
  • A token is sent as Authorization: Bearer <token>, and one set shorter than 32 characters stops the start (see "Configuration surface"). While a token is unset, the endpoints that need it answer 404, as does any other path under the prefix.
  • Apart from the health check, these requests go through every check that any other request goes through, and are answered at the point where another request would be forwarded to the app: a banned or refused client stays refused, and each request counts toward the client's limits. A missing or wrong token is answered 401, in SWWAF_MODE=observe too, and counts toward the error burst, so a client guessing tokens is banned once it passes SWWAF_ERROR_BURST_THRESHOLD guesses in a minute. A client in SWWAF_ALLOW_NETS skips the checks but still needs the token.
  • An admin whose own address is banned lifts the ban by editing bans.json.

Alert webhook schema

One JSON object per alert:

  • instance, time, event (one of the SWWAF_ALERT_EVENTS values)
  • client, netblock, asn, as_name, country
  • reason: short human-readable sentence
  • detail: event-specific fields, for example window, count, limit, limit_percent, rule_ids, path, ban_expires, for a ban its notes, and for file_error the file and the position of the error
  • suppressed_repeats: number of identical alerts held back by the cooldown

Deployment

The recommended deployment puts smallwebwaf inside the app's own container: the app's Dockerfile builds FROM the smallwebwaf image. Each app remains one container, deployed as before, and an app is hardened just by changing its base image and perhaps installing on top of it, from nixpkgs, the packages it needs. In the container, smallwebwaf listens on port 8080, which traefik routes to, and forwards each request to the app on 127.0.0.1:8081, its default backend (SWWAF_UPSTREAM_URL). No setting is required.

The image holds:

  • Ubuntu 26.04 LTS, the newest long-term support release of Ubuntu, pinned by digest. The image moves to the next LTS release when that ships.
  • Three packages from Ubuntu, ca-certificates, nix-bin and runit, installed from a dated snapshot of Ubuntu's archive and checked by hash (see "Packages from Ubuntu" below). The Ubuntu image has no CA certificates, and they come with nix-bin only because a library it uses recommends them, so ca-certificates is installed by name. Without it Nix cannot download packages, and smallwebwaf, unable to reach GeoJS, would count every visitor as coming from an unknown country.
  • Nix, the package manager, from Ubuntu's own nix-bin package, and nixpkgs, the collection of packages Nix installs from, fixed at one commit (see "Packages from nixpkgs" below). Root uses Nix directly, and no Nix daemon runs in the container. Nix run by root expects a group of build users, nixbld, which nix-bin does not create, so the image writes build-users-group = to /etc/nix/nix.conf, and root's builds then run without build users.
  • runit, from Ubuntu's own runit package, and runsvinit as the entrypoint, as the owner's code style guide asks for service containers. Neither Ubuntu nor nixpkgs packages runsvinit, so the image builds it from its source (github.com/peterbourgon/runsvinit) at a fixed commit hash. Its repository is archived and has not changed since 2015, and its last tag is v2.0.0. It has no go.mod, and go build of its directory needs one, so the build writes one; since runsvinit uses only Go's standard library, that file names nothing else. runsvinit starts runit's runsvdir, which starts a runsv for each directory under /etc/service; each runsv runs the run script in its directory, and runs it again whenever it exits. Ubuntu's runit looks for services in /etc/service too, so when docker stop has runsvinit stop each service with runit's sv, sv finds it.
  • The smallwebwaf binary, and a user of its own, smallwebwaf (uid and gid 65532).
  • The service directory /etc/service/smallwebwaf, whose run script waits one second (sleep 1), makes SWWAF_STATE_DIR and every file in it belong to the smallwebwaf user, and starts smallwebwaf as that user with runit's chpst.
  • The directory /var/lib/smallwebwaf for the state files, and /etc/smallwebwaf/rules.d with the default rule file (see "Rule files").
  • Port 8080 declared (EXPOSE 8080), and the health check described below (HEALTHCHECK).

Every run script, the app's included, is a bash script that starts with #!/usr/bin/env bash and set -euo pipefail and puts its code in a main function, called on its last line, as the owner's code style guide asks; Ubuntu ships bash.

Beyond its FROM line, the app's Dockerfile adds:

  • its binary;
  • any packages it needs, from nixpkgs, with nix-env -iA nixpkgs.<name>;
  • its runit service: a directory under /etc/service named after the app, never smallwebwaf, whose run script waits one second (sleep 1) and then starts the app with chpst, listening on 127.0.0.1:8081, as a user of its own that the Dockerfile creates with useradd. That user is neither root nor smallwebwaf, so that the app cannot touch the state files or the process of smallwebwaf.

It sets no ENTRYPOINT or USER of its own: the container must start runsvinit, as root, so that it can start each service as its own user.

An example, for an app whose binary is app and which takes the address it listens on and the proxies it trusts as options of its own:

# The smallwebwaf image, pinned by digest.
FROM <registry>/smallwebwaf:<pinned digest>

# Packages the app needs, if any, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.git

# The app's binary, and a user of its own to run it.
COPY app /usr/local/bin/app
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app

# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run

with app.run beside the Dockerfile:

#!/usr/bin/env bash
set -euo pipefail

main() {
    sleep 1
    exec chpst -u app:app /usr/local/bin/app \
        --listen 127.0.0.1:8081 \
        --trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
}

main "$@"

Packages from Ubuntu: the image installs ca-certificates, nix-bin and runit from Ubuntu's snapshot service, which serves the archive as it was at a given moment, rather than from the archive itself, whose packages change with every update. The image's Dockerfile names that moment, in apt's --snapshot 20261001T000000Z form, on both apt-get update and apt-get install. That moment is never earlier than the date of the pinned Ubuntu image, since packages from an older snapshot can need older versions of packages the Ubuntu image already holds, and it moves forward whenever that image's digest does. apt-get update keeps the snapshot's InRelease files, which apt checks against the archive's signature, in /var/lib/apt/lists/; each lists the SHA-256 hash of the package lists it covers, and each package list the hash of every package in it. The Dockerfile also names the SHA-256 hash of each InRelease file apt uses, and the build checks them after apt-get update and before apt-get install, so every package apt installs is checked, through those files, against hashes the Dockerfile names. The snapshot service is reached over HTTPS, and the Ubuntu image has no CA certificates of its own, so this one install uses those of the Go image that smallwebwaf is built in, which is pinned by digest too: apt's Acquire::https::CaInfo option names that image's CA certificate file, /etc/ssl/certs/ca-certificates.crt, copied into the build.

Packages from nixpkgs: nixpkgs is fixed at one commit of its newest release branch, nixos-26.05 today. For each commit of the branch that has passed its tests, the Nix project publishes a release on releases.nixos.org, such as nixos-26.05.11045.774debe7a0d1, and the image takes nixpkgs from that release's file nixexprs.tar.xz, not from a GitHub archive of the commit, whose bytes can change. The image's Dockerfile names the release and the SHA-256 hash of that file, which the release's page lists, and the build checks the hash before unpacking it. nixpkgs is set up for root under the name nixpkgs, so the app's Dockerfile installs a package with nix-env -iA nixpkgs.<name>, and whatever it installs is on the PATH of every service. Because nixpkgs stays at that commit, an app built on the same smallwebwaf image gets the same packages each time it is built. Unpacked, nixpkgs takes about 500 MiB of disk, more on some filesystems such as ZFS, and each package an app installs from it adds its own size, with everything it depends on. A newer commit of the branch, with its security fixes, comes with a newer smallwebwaf image, as do Ubuntu's own fixes; an app takes them by changing the digest in its FROM line. When nixpkgs makes its next release, every six months, the image moves to that release's branch.

The two processes:

  • runsvinit is the container's first process and runs as root, as do runit's runsvdir and the runsv that watches each service. Each run script starts as root and hands over to its program's user with chpst: smallwebwaf runs as smallwebwaf, the app as its own user.
  • Both start with the container's environment variables, which is why every setting of smallwebwaf carries the SWWAF_ prefix. Both write to the container's output, which docker logs shows: the JSON lines of smallwebwaf (see "Request log") and whatever the app writes.
  • When smallwebwaf or the app exits, for whatever reason, runit runs its run script again, which waits one second before starting it. The other process keeps running, and the container does not stop. While smallwebwaf is down, nothing answers on port 8080 and traefik answers 502; while the app is down, smallwebwaf answers 502 (see "Failure behaviour").
  • A smallwebwaf that stops at start, for example on invalid configuration, is started again every second and stops again, logging its message each time, until the cause is fixed. The health check fails meanwhile.
  • docker stop sends SIGTERM to runsvinit, which has runit stop both services, each with SIGTERM, and exits once they have stopped. smallwebwaf writes its state files as it stops (see "Persistent state").
  • The container's root filesystem stays writable: runit writes each service's status into its directory under /etc/service.

The health check: the image's HEALTHCHECK passes while smallwebwaf answers GET /_smallwebwaf/healthz on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR, and the app accepts connections at the address in SWWAF_UPSTREAM_URL, and fails when either does not. The container therefore shows as healthy only while both processes are up. An app with a health check of its own can replace the image's HEALTHCHECK with one that checks both.

Ports: smallwebwaf listens on port 8080 on every address and on no other port; its health check, metrics and ban management are all on that listener, under /_smallwebwaf/ (see "Admin endpoints"). SWWAF_LISTEN_ADDR may set another port: the image's health check takes its port from that setting, and traefik's port label (traefik.http.services.<name>.loadbalancer.server.port) must name the same port, and the app must leave that port free. The address part of SWWAF_LISTEN_ADDR stays empty (for example :9000, never 127.0.0.1:9000), so smallwebwaf keeps listening on every address: traefik reaches it on the container's address, and the health check on 127.0.0.1. The app listens on 127.0.0.1:8081 only, so that nothing outside the container reaches it except through smallwebwaf: an app that listens on every address can be reached around smallwebwaf by anything that reaches the container.

State: smallwebwaf keeps its state files in /var/lib/smallwebwaf (SWWAF_STATE_DIR), a directory of its own beside the app's data, which the app keeps in directories of its own, such as /var/lib/app. Without a volume there, the files live in the container: they survive a restart of the container and are lost when a deploy replaces it. A volume mounted at /var/lib/smallwebwaf, named or a host directory, keeps them across deploys; the run script of smallwebwaf makes it and every file in it belong to the smallwebwaf user, so a host directory mounted there needs no change of owner, and files an earlier owner left in it can be read and replaced. It is a volume of its own, separate from the app's, holds a few tens of MiB at most with the defaults (see "Persistent state"), and needs no backup beyond whatever the host already does. The image declares no volume, since every app image built on it would inherit it. Milestone 2 (#14) writes no state files and needs no volume.

Tokens: a token given as a file (SWWAF_ADMIN_TOKEN_FILE, SWWAF_METRICS_TOKEN_FILE) is out of the app's reach only while the smallwebwaf user alone can read the file. The operator makes the file on the host, owned by uid 65532, the smallwebwaf user, with mode 0400, and mounts the directory that holds it into the container read-only; the container sees the same owner and mode.

Forwarded headers: the app's TCP peer is smallwebwaf on 127.0.0.1, and the X-Forwarded-For the app receives ends with traefik's address, which smallwebwaf adds after the visitor's. The app must therefore trust 127.0.0.1 and ::1 for forwarded headers, besides the private address ranges traefik is on. The usual default for a trusted-proxy setting, the private ranges alone, does not include loopback: an app left at it ignores the header and sees every visitor as 127.0.0.1. A list given replaces that default, so the app's own trusted-proxy setting names them all, 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128, as in the example.

upaas: an app deployed by upaas takes this shape with no change to upaas. upaas builds the app's Dockerfile, which now starts FROM the smallwebwaf image, and runs the one container as it runs any app, with the app's traefik labels, environment variables and volumes. The labels route to port 8080 (traefik.http.services.<name>.loadbalancer.server.port=8080), any SWWAF_ settings go with the app's environment variables, and the volume for /var/lib/smallwebwaf goes beside the app's own, as does the directory that holds any token file.

  • The endpoints of smallwebwaf are reached through traefik like any other request, for example https://app.example.invalid/_smallwebwaf/metrics for a scraper that sends SWWAF_METRICS_TOKEN (see "Admin endpoints").
  • Rollout per service: build the app's image on the smallwebwaf image, with no setting, and deploy it as before. It protects the app from the first request, and nothing needs tuning first. Afterwards the request log and the ban notes say why each client was refused. A real visitor refused by mistake is let through with an exclusion (SWWAF_WAF_DISABLED_RULES, SWWAF_WAF_EXEMPT_PATHS, SWWAF_RATE_LIMIT_EXEMPT_PATHS) or an exemption (SWWAF_RATE_LIMIT_EXEMPT_NETS, SWWAF_ALLOW_NETS), and its ban is lifted in bans.json. Additions to consider at any time: an alert destination, country lists or biased thresholds, reputation sources, the metrics and admin tokens, a remote log endpoint, and an app-specific rule file.
  • Notes specific to gitea, built on the image like any other app:
    • SSH access to gitea does not pass through smallwebwaf and is not protected by it.
    • A clone is a response, and fits the defaults of 30 minutes and 5 GiB for all but the largest repositories and slowest links. A push is a request, and so is every other upload: at the defaults, one larger than 100 MiB or taking more than 60 seconds is cut off, whether it is a git push, an LFS object, a container image layer, a package file or a release attachment. The client is answered 413 for a body that is too large, before anything reaches gitea when the request announces its size, or 408 for one that is too slow; the upload fails, and no one is banned for it. A gitea that takes large uploads needs SWWAF_REQUEST_MAX_BYTES, SWWAF_CLIENT_REQUEST_TIMEOUT and SWWAF_UPSTREAM_REQUEST_TIMEOUT raised to fit. The Core Rule Set does not read an upload's body, which streams through without being held in memory.
    • At the defaults (see "Configuration surface", attack detection), the Core Rule Set lets gitea's ordinary use through, apart from the refusals in the next note: browsing and views of files in a repository, with their history, blame and the file tree; diffs, including their hidden lines and large files, and pull request review; git's clone, fetch and push over HTTP; signing in, including the return to the page a visitor came from and sign-in with Git Credential Manager, git-credential-oauth or tea; the API's calls for a file and its commits; pushing and pulling container images and packages; Actions runners, and artifacts uploaded with actions/upload-artifact@v4; and posting issues, pull requests, comments, wiki pages and files saved in the web editor, code included, since no body is read, and the page gitea shows after a change with its message naming what was done, such as a file deleted or a branch, milestone or project created.
    • The Core Rule Set can still refuse the requests below. Each refusal answers only that request, with 403, and bans no one by itself: it counts toward the error burst, which a person does not reach this way. The request log names the rule in waf_rule_ids, which SWWAF_WAF_DISABLED_RULES can switch off.
      • A query string that reads to it as an attack, most often a search: one for a name on its lists of system files and commands, such as package.json, .gitignore or docker-compose.yml, or for text that starts with a command name, or whose first word has one right after a /, such as python3, ssh key or feat/docker-support; or one holding a shell command with its options or a system path (ls -la, sed -i, /bin/sh), a command in backticks, script code (fetch(, ${VAR}, process.env), HTML (<img src=), an SQL statement (SELECT * FROM users WHERE), or a URL naming an IP address or localhost. The lists refuse such a name in any other query parameter too, such as a release attachment uploaded through the API as docker-compose.yml.
      • A branch, tag or file name that starts with docker-, python3, ansible, base64, whoami or another entry on the Core Rule Set's list of commands (932260), or that has one of these right after a /, such as feat/docker-support, fix/docker-build or renovate/docker-build-push-action-6.x, where gitea's own pages send it in a query parameter that 930120, 932160 and 932260 still check: refSubUrl, the branch or tag of a directory listing, sent when the listing asks for its entries' last commits in a second request, as it does whenever gitea takes more than a second to work them out; name, when a branch is deleted or restored on the branches page; tag, when a release is started from a tag; template, the file of an issue template, when an issue is opened from it; and rule_name, when a branch protection rule is opened for editing in the repository's settings or has just been saved. For a branch named docker-build, a directory listing that makes that second request leaves those last commits out and shows an error, and the branches page shows an error instead of deleting or restoring the branch. A release started from such a tag, an issue from such a template or such a rule opened for editing gets the 403 answer of smallwebwaf in place of its form. Saving such a rule stores it, but the branch settings page gitea then returns to gets the 403 answer of smallwebwaf, so the save looks as if it failed.
      • A path: a file name ending in ~, or an .xhtml file whose path holds a space.
    • Artifact uploads from actions/upload-artifact@v3 send the header Content-Range, which the Core Rule Set refuses (920450). The action sends two files at a time, does not retry a 403 and sends no further file once one is refused, so an upload is refused once or twice, however many files it holds, and the step fails. One upload does not ban the runner; more than 30 such refusals from its address within a minute, as when many jobs upload at once, ban it through the error burst. actions/upload-artifact@v4 does not send the header.
    • An Actions runner that reaches gitea through smallwebwaf sends requests all day. One older than version 0.4 (April 2026) asks for work every 2 seconds, 43,200 requests a day, and reports a running job's log and state every second, so it passes the day limit of 50,000 after about an hour and a quarter of jobs: it is banned, the job it is running fails, and each repeat within a day makes the ban longer. Newer runners ask less often, but a busy one, or several on one address, can still pass it. Put the runners' addresses in SWWAF_RATE_LIMIT_EXEMPT_NETS, which takes them out of the request and byte limits while the error burst, attack detection and bans still apply.
    • Archive download and blame or history pages are what scrapers hammer; request limits do most of the work there.

Failure behaviour

  • Lookup database: a configured file that is missing or unreadable at start stops the start. A replacement that cannot be read while running is ignored: the file already loaded stays in use, the problem is logged, one file_error alert.
  • GeoJS slow, down or refusing smallwebwaf: clients with a kept answer are unaffected, new clients count as unknown, smallwebwaf keeps asking with backoff, one source_failure alert per cooldown.
  • Reputation source down or over quota: no verdict, service continues, one source_failure alert per cooldown.
  • Alert destination down: retried with backoff from a bounded queue, oldest dropped first, drops counted in metrics.
  • App down or restarting: 502 from smallwebwaf, not counted as client offences.
  • smallwebwaf or the app exiting: runit starts it again a second later, and the other keeps running (see "Deployment").
  • Attack detection engine error on a request: request is forwarded, error logged and counted.
  • Remote log endpoint down: stdout continues, lines are buffered then dropped oldest first, drops counted in metrics.
  • State file write fails while running: memory stays authoritative, logged, counted, one file_error alert per cooldown, retried.
  • A state file or rule file edited while running that does not parse: smallwebwaf keeps running on what it has. The state file is set aside as <name>.bad and written again from memory; the rule file is left as it is. Logged, one file_error alert.
  • In short: the only things that stop the process happen at start: invalid configuration, a configured lookup database that is missing or unreadable, a rule file or state file that does not parse, and an unwritable SWWAF_STATE_DIR. It is then started again every second by runit, and stops again until the cause is fixed. Once running, a broken helper or a broken edit never takes the protected service down. The nearest it comes is GeoJS failing while SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set: new clients then cannot be placed, and that list refuses them, as it refuses every client it cannot place.

Risks the design has to handle

  • Forged X-Forwarded-For: handled by believing the header only from a peer inside SWWAF_TRUSTED_PROXIES and by walking it from the right. The default trusts every private address, which in the intended deployment means traefik; where other containers can reach smallwebwaf directly, or visitors on a private network reach traefik, they can name another address in the header, and setting SWWAF_TRUSTED_PROXIES to traefik's own network closes that gap.
  • Many clients behind one address (mobile carriers, offices, Tor): they share its limits and its bans. The default limits sit several times above what one busy person produces, SWWAF_RATE_LIMIT_EXEMPT_NETS and SWWAF_ALLOW_NETS take known shared addresses out, and the ban notes show an admin what happened before a ban is lifted.
  • A ban rule that matches a real visitor bans it for seven days, and the visitor's next request during those days makes the ban permanent. So the default rule file keeps to requests no real visitor sends, with its paths anchored at the site root, where no app serves secrets, version control directories, backups or web shells. A visitor banned by mistake is let back in by lifting the ban in bans.json.
  • IPv6 address rotation inside a /64: handled by grouping.
  • Widely distributed scrapers using thousands of addresses at low rates each: per-client limits do not see them. The AS number and netblock anomaly alerts reveal them once their thresholds are set, and a low SWWAF_ASN_LIMIT_PERCENT for that AS number is the response: it lowers the limits of each of its clients. There is no shared budget for a whole AS number or country, which one abuser could use up and so lock out everyone else there.
  • Core Rule Set false positives against real apps (a search for code, and any body once SWWAF_WAF_BODY_LIMIT is set): a match refuses only that request and bans no one by itself; the request log names the rule, and exclusions by rule id and path fix it. Further gitea requests the Core Rule Set may refuse at the defaults, found by reading gitea's source rather than a running gitea, are collected in #30 and checked when milestone 1 runs in front of a real gitea.
  • Attacks carried in request bodies: not refused by default, since on a code forge bodies are full of code the Core Rule Set takes for attacks. Most of what shows in URLs and headers is still refused, the client that sends an attack is still subject to the rule files, the limits and the bans, and SWWAF_WAF_BODY_LIMIT switches body inspection on for apps whose forms carry no code.
  • An app that uses path, ref or another of the query parameters left out of 930120, 932160 and 932260 (see "Configuration surface", attack detection) as a file on the server, or passes it to a shell: that change holds in front of every app and no setting restores the three rules, so what only they refuse reaches the app in those parameters, such as /etc/passwd, |cat /etc/passwd or nc -e /bin/sh …. Path traversal (../) is still refused there. Such an app has to check those values itself, or refuse what it must never receive in them with a rule file. So does an app that reads a cookie named gitea_flash or redirect_to, which the Core Rule Set does not read, or passes Referer to a shell.
  • The admin endpoints can be reached from the internet: all but the health check need a token and are off while it is unset, and a missing or wrong token counts toward the error burst, so a client guessing tokens is soon banned. Tokens are meant to be long random values, and one shorter than 32 characters stops the start. The app starts with the same environment variables as smallwebwaf, so it can read a token given as one; a token given as a file that only the smallwebwaf user can read (SWWAF_ADMIN_TOKEN_FILE, SWWAF_METRICS_TOKEN_FILE) is out of the app's reach (see "Deployment").
  • GeoJS, the default lookup source: every new visitor's address goes to a third party, and a swarm of fresh addresses, when lookups peak, is when GeoJS may slow down or block smallwebwaf. Keeping answers for 7 days and asking about many addresses in one request keep the number of requests low; the file source has neither risk.
  • Slow-request attacks: SWWAF_CLIENT_REQUEST_TIMEOUT bounds how long a request may take to arrive, SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES how large its headers may be, and SWWAF_CLIENT_IDLE_TIMEOUT closes a kept-open connection that sends nothing more.
  • Alert floods: cooldown and hourly cap.

Build order

  • Milestone 1 (#13): a pass-through proxy with read and write timeouts, size limits and a request log. It identifies the client and passes requests and answers through unchanged within the four timeouts and the two size limits; the header size and the idle time are fixed at their defaults, not settings. Each request gets a line in the request log, with part of its fields. It writes nothing to disk.
  • Milestone 2 (#14): per-IP rate limits and country allow and deny lists, ready for production.
    • The request rate limits per client, over a minute, an hour and a day. A client over one is refused with 429 until it is back under every limit; bans come later.
    • The country lists, with each client's country looked up through GeoJS, only while a list is set. A client on a private, loopback or link-local address has no country, and neither list checks it.
    • Like milestone 1, it writes nothing to disk: the GeoJS answers and the rate counters are kept in memory only, and a restart loses them.
    • The container image described under "Deployment", with runit and the container's health check. The health check calls /_smallwebwaf/healthz, so milestone 2 answers that path, although the other admin endpoints come later.
  • After milestone 2, the rest of the design, in this order:
    • static lists, the bans that broken request limits lead to, the ban ledger and the JSON state files with edits taken in while running, exemptions, observe mode, the rest of the request log's fields, the metrics endpoint;
    • rule files, the other admin endpoints, alerting to all three destinations, remote log sending;
    • AS number and country lookup for every client, from the file or GeoJS, biased thresholds, byte limits, anomaly thresholds;
    • blocklists, DNSBL, AbuseIPDB, optional CrowdSec decision feed;
    • attack detection with Coraza and the Core Rule Set, trap paths, error bursts.
  • Each stage is usable on its own; milestone 2 is the first to go to production.