Every *.rules file in SWWAF_RULES_DIR not named with a leading dot is read at start, and again 2 seconds after the directory's last change. Each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or attack. path, query and uri are matched as the request line sent them; header:Host and header:Transfer-Encoding are refused. Bans gain a cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
smallwebwaf
smallwebwaf is a simple, fast, logging web application firewall, MIT-licensed
and written in Go by @sneak, for people who host their
own services. It runs inside the container of the one application it protects,
between your reverse proxy (traefik) and the app: the app's Dockerfile builds
FROM the smallwebwaf image, traefik sends the app's requests to
smallwebwaf on port 8080, and smallwebwaf passes them on to the app on
127.0.0.1:8081. It needs no setting, and protects the app from the first
request with defaults chosen for a service on the open internet. It keeps its
state in memory and in JSON files you can read and edit, and writes a detailed
JSON log line for every request.
Status: the first two milestones are built
(#13 and
#14), and so are eight parts of
milestone 3: the static lists, the bans that broken rate limits lead to, the
JSON state files with your edits taken in while it runs and the paths the rate
limits do not count, which come next in the build order, observe mode and the
rest of the request log's fields, which come a little later, and the metrics
endpoint and the header size and the idle time as settings, which come last in
it. So are the rule files, the first part of the stage after it, with the bans
for a clear sign of attack. smallwebwaf passes each request to the app and the
app's answer back, unchanged, within its timeouts and size limits, works out
each client's address, bans a client that sends too many requests, not counting
those for the paths you choose, refuses a client that comes from a country you
refuse or from a network you refuse, lets the networks you choose through,
checks each request against the rule files and bans a client whose request is a
clear sign of attack, keeps its bans, each client's counters and history, and
GeoJS's answers in JSON files across restarts, takes in your edits of those
files and of the rule files while it runs, writes a JSON log line for every
request, serves Prometheus metrics to a scraper that holds the metrics token,
and in observe mode passes on the requests it would refuse, logging what it
would have done with them. It comes as the image the app's own image is built
on. The rest of the design comes after that, in the order of the build order in
SPEC.md. The survey of existing tools that led to the design is in
EVALUATION.md.
Getting started
Build the smallwebwaf image from a clone:
git clone https://git.eeqj.de/sneak/smallwebwaf.git
cd smallwebwaf
make docker
make docker runs the tests and the linter, then builds the image, tagged
smallwebwaf, for amd64 and only on an amd64 host: the hashes the Dockerfile
checks Ubuntu's package lists against are those of Ubuntu's amd64 archive. Push
it to a registry your hosts pull from, and build each app's image on it, pinned
by digest, as "How it works, in short" below shows. make example-app builds a
small app on the image, the one in deploy/example-app, and checks that it
works.
To work on the code, make build builds the binary alone, with Go installed,
and make run builds and runs it, listening on port 8080 in front of an app at
SWWAF_UPSTREAM_URL, by default http://127.0.0.1:8081, with its state files
in bin/state unless SWWAF_STATE_DIR is set, and the default rule file of
share/rules.d unless SWWAF_RULES_DIR is set.
What it does so far
- Passes each request to the app and the app's answer back unchanged: method, path, query, headers, body and status. Bodies stream through in both directions and are never held whole in memory. A WebSocket, or any other upgraded connection, passes through, and the timeouts do not cut it.
- Works out the client's address. A TCP peer outside
SWWAF_TRUSTED_PROXIESis the client, and the forwarded headers it sends are replaced, not passed on. For a peer inside it,X-Forwarded-Foris read from the right, and the first address outsideSWWAF_TRUSTED_PROXIESis the client; if every address in it is inside, the leftmost is, and with no header the peer is. The app sees what it would see from traefik directly: the sameHost, the sameX-Forwarded-Proto, andX-Forwarded-Forwith the peer added at the end. It also gets the request's id inX-Request-ID, the same id as in the request's log line (seerequest_idin "Request log" below). - Enforces the timeouts and the size limits below. A limit passed before the
response has started gets
smallwebwaf's own answer:408for a client too slow to send its request,413for a request body that is too large,504for an app too slow to answer, and502for a response that is too large or an app that cannot be reached. A request that announces a body over the limit is refused before anything reaches the app. While a request body is still on its way, a request timeout that runs out answers408ifsmallwebwafwas waiting for the client to send more, and504if it was waiting for the app to take what it had. Once the response has started, a limit can only cut the connection. - Counts each client's requests over a minute, an hour and a day. A request that
takes the client over one of the rate limits below is refused with
SWWAF_BAN_RESPONSE,403by default, before anything reaches the app, and bans the client. A request whose path starts with one ofSWWAF_RATE_LIMIT_EXEMPT_PATHS, as that setting below describes, is neither counted nor refused by the rate limits; the static lists, bans, the country lists and the rule files still apply to it. A client is one IPv4 address, or one IPv6 /64, since one abuser usually holds a whole /64. Each window is counted in two fixed buckets, the earlier one weighted by how much of it the window still covers. At most 20,000 clients are kept, the least recently seen dropped first, with their history, and a restart gives no client a fresh allowance (see "State files" below). - Bans a client that breaks a rate limit, as "Bans" in
SPEC.mddescribes: the first ban lasts an hour, and a limit broken again within a day of a ban ending bans for three times as long as that ban, so 1, 3, 9, 27 and 81 hours; a ban that would last longer than seven days is permanent instead. A ban covers the client's netblock: its IPv4 address, or the netblock around it thatSWWAF_BAN_SCOPE_V4_PREFIXsets, or its IPv6 /64. While it lasts, every request from the netblock is refused withSWWAF_BAN_RESPONSEafter the static lists and before the country lists, so the client is not looked up, and is not counted for the rate limits. A ban sets the client's counters back to zero. Each ban carries notes for deciding whether to lift it: the limit, its window and the requests counted in it, the request that broke it, the client's country when it was looked up, the netblock's requests since it was first seen, how many of them the ban has refused, and how many bans the netblock had before, for a broken limit, for a clear sign of attack and without a cause. At mostSWWAF_MAX_BANSbans are kept, past, active and permanent; past that, the earliest ban of the netblock that has gone longest without a request is dropped first.bans.jsonshows the bans and their notes, a restart lifts none, and you add or lift a ban by editing it (see "State files" below). - Checks each request against the rules of the rule files (see "Rule files"
below) after the rate limits, and before its body is read. A
logrule that matches is noted in the log line; ablockrule refuses the request with403, and bans no one; abanrule refuses it withSWWAF_BAN_RESPONSEand bans the client's netblock for a clear sign of attack. Matching stops at the first rule that refuses. A client inSWWAF_ALLOW_NETSis not checked. - Bans a client for a clear sign of attack, as "Bans" in
SPEC.mddescribes: the first such ban lastsSWWAF_ATTACK_BAN_DURATION, seven days by default, and any request from the netblock while it lasts makes it permanent. Once it has run out, the netblock is served like any other, but its next clear sign of attack bans it permanently at once. Such a ban covers the same netblock as a ban for a broken rate limit, does not set the client's counters back to zero, and does not make the netblock's next ban for a broken limit longer. Its notes give the id and the target of the rule that matched in place of the limit. - Refuses a request from a country you refuse with
SWWAF_BAN_RESPONSE, as soon as the client's country is known and before its body is read; such a request is not counted for the rate limits. While one of the country lists below is set, each client's country is looked up through GeoJS (see "Country and AS number lookup" below); with neither set, no visitor's address leaves the host. A client on a private, loopback or link-local address has no country and is never looked up:SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESrefuses it unless it is inSWWAF_ALLOW_NETS, andSWWAF_DENIED_COUNTRIESdoes not refuse it. - Checks the client's own address against the static lists, the three netblock
settings below, before anything else, its country included. A client in
SWWAF_ALLOW_NETSskips bans, the country lists, the rate limits and the rule files, and is not looked up; the timeouts and size limits still apply. A client inSWWAF_DENY_NETSis refused withSWWAF_BAN_RESPONSEbefore its body is read, and the request is not counted for the rate limits; an address inSWWAF_ALLOW_NETStoo is let through. A client inSWWAF_RATE_LIMIT_EXEMPT_NETSis neither counted nor refused by the rate limits; the country lists, the rule files and bans still apply to it. - In
observemode, withSWWAF_MODE=observe, refuses none of the requests thatSWWAF_DENY_NETS, a ban, the country lists, a rate limit or a rule would refuse: it passes them to the app, and their log lines name whatenforcemode would have done (seewould_actionin "Request log" below). The checks run, and requests are counted, as inenforcemode, with three differences: neither a broken rate limit nor abanrule makes a ban; a broken rate limit does not set the client's counters back to zero, so each request over the limit is logged as one that would be refused; and a request under a ban does not make it permanent. The bans inbans.jsonare kept, and refuse requests again whensmallwebwafnext runs inenforcemode, as long as they last. The timeouts and size limits still apply, since they protectsmallwebwafand the app themselves, and a request for the metrics without the token is still answered401. It is for trying a configuration before enforcing it. - Answers
GET /_smallwebwaf/healthzitself with200andok, before any check and without asking the app, for the image's health check. - Answers
GET /_smallwebwaf/metricswith its metrics (see "Metrics" below) for a request that carriesSWWAF_METRICS_TOKENasAuthorization: Bearer <token>, and with401for one that does not. While the token is unset the metrics answer404, as does any other request under/_smallwebwaf/. Unlike the health check, such a request goes through every check any other request goes through, and is answered where another would be passed to the app: a banned client stays refused, and each counts toward the client's rate limits. None of them reaches the app. - Writes a line in the request log for each request (see "Request log" below).
Settings
Each setting is an environment variable, and each has a default, so none has to be set. A setting that is set but invalid stops the start with a message naming it, and the effective settings are logged at start.
SWWAF_LISTEN_ADDR(default:8080): wheresmallwebwaflistens.SWWAF_UPSTREAM_URL(defaulthttp://127.0.0.1:8081): the app, ashttporhttps, a host and an optional port, and nothing more.SWWAF_INSTANCE_NAME(default: the host's name, which docker sets to the first 12 characters of the container's id unless the deployment names one): the name each request log line gives asinstance. Set it, for example tofsn1app1/gitea, for a name that stays the same when a deploy replaces the container, and that tells instances apart when several log to one place.SWWAF_MODE(defaultenforce):enforce, orobserveto pass on the requestssmallwebwafwould refuse and log what it would have done (see "What it does so far" above).SWWAF_TRUSTED_PROXIES(default10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, the private address ranges): the netblocks whoseX-Forwarded-Foris believed. A list given replaces the default; set but empty, it trusts nothing.SWWAF_CLIENT_REQUEST_TIMEOUT(default60s): 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(default32K): the largest request line and headers a client may send. Over it, the answer is431and nothing reaches the app. It must be more than4K, and cannot beoff: Go's HTTP server always has such a limit, and reads 4 KiB past the one it is given before it refuses.SWWAF_CLIENT_IDLE_TIMEOUT(default120s): how long a kept-open connection may wait for its next request beforesmallwebwafcloses it. The default is longer than the 90 seconds after which traefik closes a connection it is not using, so traefik never sends a request on a connectionsmallwebwafis closing.SWWAF_CLIENT_RESPONSE_TIMEOUT(default30m): how long the response may take to reach the client, from the end of the request to the last byte.SWWAF_UPSTREAM_REQUEST_TIMEOUT(default60s): how long connecting to the app and sending it the whole request may take.SWWAF_UPSTREAM_RESPONSE_TIMEOUT(default30m): how long the app may take to send its whole answer, from the end of the request to the last byte.SWWAF_REQUEST_MAX_BYTES(default100M): the largest request body.SWWAF_RESPONSE_MAX_BYTES(default5G): the largest response body.SWWAF_ALLOW_NETS(default empty): netblocks whose clients skip bans, the country lists, the rate limits and the rule files, such as your monitoring or your own networks.SWWAF_RATE_LIMIT_EXEMPT_NETS(default empty): netblocks whose clients the rate limits do not apply to, such as a machine that talks to the app all day.SWWAF_DENY_NETS(default empty): netblocks whose clients are always refused.SWWAF_RATE_LIMIT_PER_MINUTE(default1000),SWWAF_RATE_LIMIT_PER_HOUR(default10000) andSWWAF_RATE_LIMIT_PER_DAY(default50000): the most requests a client may make in a minute, an hour and a day. The defaults are several times what one busy person produces, since a browser loading a heavy page makes a few hundred requests and several people often share one address.SWWAF_RATE_LIMIT_EXEMPT_PATHS(default empty): path prefixes whose requests the rate limits neither count nor refuse, such as/assets/for static assets; each starts with/. A request whose path, percent-decoded, contains..anywhere or a backslash, or whose path as sent holds an encoded slash (%2For%2f), is never exempt, since the app may act on it as a path outside every prefix:/assets/..%2Floginas/login. Any other request is exempt when its path as sent, the path the app receives, before any query string and not percent-decoded, starts with a prefix, character for character./assets/matches/assets/app.jsand/assets/, but not/assets,/Assets/app.js,/%61ssets/app.js,/static/assets/app.js,/static/../assets/app.jsor/assets%2Fapp.js. A character the client sends percent-encoded, such as a space, is written percent-encoded in a prefix, as in/my%20files/, and there are no wildcards:*is a character like any other.SWWAF_DENIED_COUNTRIES(default empty): countries whose clients are refused, for examplecn,ru,kp.SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES(default empty): when set, the only countries whose clients get through, for exampleus,de. A client whose country cannot be found is refused too, so that new clients are not let in whenever GeoJS stops answering.SWWAF_BAN_RESPONSE(default403): how a refused client is answered, one that is banned, breaks a rate limit, matches abanrule, is inSWWAF_DENY_NETSor comes from a refused country:403,429, orcloseto close the connection without an answer. Behind traefik,closedoes not leave the client unanswered: traefik answers502, as it does whenever its backend drops a connection. Ablockrule always answers403.SWWAF_LIMIT_BAN_DURATION(default1h): the ban for a first broken rate limit.SWWAF_LIMIT_BAN_REPEAT_WINDOW(default24h): a rate limit broken again within this time after a ban ended, other than one for a clear sign of attack, bans for three times as long as that ban.SWWAF_MAX_BAN_DURATION(default7d): a ban for a broken rate limit that would be longer is permanent instead.SWWAF_ATTACK_BAN_DURATION(default7d): the ban for a first clear sign of attack.SWWAF_MAX_BANS(default5000): the most bans kept, past, active and permanent.SWWAF_BAN_SCOPE_V4_PREFIX(default32): the length of the netblock around an IPv4 client that a ban covers, such as24to ban the surrounding /24. An IPv6 ban covers the client's /64.SWWAF_STATE_DIR(default/var/lib/smallwebwaf): the directory of the state files, an absolute path. A directorysmallwebwafcannot write stops the start.SWWAF_STATE_WRITE_DELAY(default10s): how long after a ban is madebans.jsonis written, with every ban made in between.SWWAF_STATE_COUNTER_INTERVAL(default15m): how often every state file is written.SWWAF_LOG_REQUEST_HEADERS(defaultaccept,accept-language,accept-encoding,content-type,origin,range): the request headers whose values the request log gives, in either case.Authorization,CookieandSet-Cookieare never logged, even when listed (see "Request log" below). An entry namingHostorTransfer-Encodingstops the start, since Go's HTTP server takes both out of the request; the request's host is the fieldhost.SWWAF_METRICS_TOKEN(default unset): the token a scraper sends for the metrics, a long random value. While it is unset the metrics are off; one shorter than 32 characters stops the start. The settings logged at start show********in its place.SWWAF_METRICS_TOP_N(default50): how many countries get series of their own in the metrics by country; the others are counted asother.SWWAF_RULES_DIR(default/etc/smallwebwaf/rules.d): the directory of the rule files. A directory that does not exist stops the start.SWWAF_RULES_ENABLED(defaulttrue):falsereads no rule file, and checks no request against one.
Durations are in Go's syntax, with d for days (90s, 15m, 7d). Sizes are
bytes, with an optional K, M or G, which are powers of 1024 (1K is 1024
bytes). Rate limits are whole numbers of requests. Netblocks are in CIDR form,
and a bare address stands for itself alone. Countries are the two-letter codes
ISO 3166-1 assigns today, and xk for Kosovo, in either case (de and DE are
the same); any other code, such as nk (North Korea is kp) or the withdrawn
su, stops the start, and so does a code on both country lists. off switches
a timeout, a size limit or a rate limit off;
SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, the ban settings, the state settings
and SWWAF_METRICS_TOP_N cannot be off.
Several limits are fixed rather than settings. At most 20,000 clients are kept, with their counters and history, and an IPv6 client is counted by its /64. A new client waits at most a second for its country, and at most 100,000 answers from GeoJS are kept, for 7 days each.
Request log
smallwebwaf writes one JSON object per line on stdout for every request,
refused ones included:
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
A field that does not apply to a request is left out of its line, apart from
type, the fields from time to user_agent, request_id, peer_ip,
client_group, country, action and duration_total, which every line has.
timeis when the request arrived, in UTC.instanceisSWWAF_INSTANCE_NAME.schemeis theX-Forwarded-Protoa trusted proxy sent, and otherwisehttp.pathandqueryare as the client sent them.request_idis theX-Request-IDa trusted proxy sent, or a new random one of 26 letters and digits when it sent none, or when the peer is not a trusted proxy. A request passed to the app takes it there inX-Request-ID.peer_ipis the TCP peer, normally traefik.forwarded_foris theX-Forwarded-Forheader as received, several lines of it joined with,.client_groupis the client as the rate limits count it: its IPv4 address as a /32, or the /64 of its IPv6 address.countryis the client's country as GeoJS places it. It is empty with neither country list set, for a client inSWWAF_ALLOW_NETSorSWWAF_DENY_NETS, for a client on a private, loopback or link-local address, when GeoJS cannot place the client or has not answered in time, and for a request whose client a ban covers, even when the client's country is known.content_typeis the request'sContent-Type, andcontent_lengththe length the request announced for its body, which is left out for none or zero.request_headersare the request's headers thatSWWAF_LOG_REQUEST_HEADERSnames, by name in lower case, several lines of one joined with,.Authorization,CookieandSet-Cookieare never among them, whatever the setting says:has_authorizationandhas_cookieare there instead, and true, when the request has anAuthorizationor aCookieheader.websocketis there, and true, when the app switched the connection to another protocol, as it does for a WebSocket.statusis what the client was sent,0if nothing was;upstream_statusis what the app answered, and is left out when the app did not answer.response_content_type,cache_controlandlocationare theContent-Type,Cache-ControlandLocationheaders of the answer: the app's, as passed on, or those ofsmallwebwaf's own answer.request_bytesandresponse_bytescount body bytes.actionisforwardfor a request passed to the app,deniedfor one refused because its client is inSWWAF_DENY_NETS,bannedfor one refused because a ban covers its client or because it matched abanrule, which bans its client,country_deniedfor one refused for its client's country,rate_limitedfor one that broke a rate limit and banned its client,rule_blockedfor one ablockrule refused,too_largefor a request or response over its size limit,timed_outfor one that ran out of time,upstream_errorwhen the app could not be reached or its answer broke off, andadminfor onesmallwebwafanswered at its own endpoint.would_actionis there inobservemode for a request thatSWWAF_DENY_NETS, a ban, the country lists, a rate limit or a rule would have refused inenforcemode, and names the action that refusal would have had:denied,banned,country_denied,rate_limitedorrule_blocked.actionthen names what was done:forwardfor a request passed to the app, and another action, such astoo_large, for one a size or time limit refused.countsgives the client's requests in the minute, the hour and the day as the rate limits count them, this request included: in each window, those in the bucket under way and a share of those in the bucket before, so a count can have a fraction. For a request that broke a limit, they are the counts that broke it. It is left out for a request the rate limits do not count: the health check, one from a client inSWWAF_ALLOW_NETSorSWWAF_RATE_LIMIT_EXEMPT_NETS, one for a path thatSWWAF_RATE_LIMIT_EXEMPT_PATHSexempts, and one thatSWWAF_DENY_NETS, a ban or the country lists refuse, or would refuse inobservemode. The byte totals come with the byte limits.rule_idsis there for a request that matched rules of the rule files, and lists their ids in the order they matched, up to the one that refused it.limit_hitis there for a request that broke a rate limit, and names the window whose limit it went over:minute,hourorday, the shortest if it went over several.offenceis thenlimit.ban_expiresis there for a request that made a ban or was refused under one, or inobservemode would have been refused under one, and gives when the ban ends, in the same form astime, orpermanent.abortedis there, and true, when the client went away early.- The timings are in milliseconds, to the microsecond.
duration_totalruns from when the request's headers had been read to when its line is written, andduration_checksover the same start to when the checks were done; the health check runs none, and its line has noduration_checks.duration_upstream_connect,duration_upstream_first_byteandduration_upstream_totalare there for a request passed to the app, and run from when it was handed to the app: until there was a connection to it, new or kept open from an earlier request, until the first byte of its answer arrived, and until the end. The first two are left out when that never happened, as for an app that cannot be reached.
No body is logged, and no header but those above. smallwebwaf's own messages
(start, the settings, stop, errors) share the stream as JSON lines marked
"type":"process".
Go's HTTP server, on which smallwebwaf is built, reads a request's line and
headers before smallwebwaf sees the request, and some requests end there,
without a line in the log: headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES,
which it answers 431, headers slower than SWWAF_CLIENT_REQUEST_TIMEOUT,
whose connection it closes without an answer, and requests it cannot read at
all, which it answers itself, mostly with 400.
State files
smallwebwaf keeps its state in memory and a copy of it in three JSON files in
SWWAF_STATE_DIR, /var/lib/smallwebwaf by default, as "Persistent state" in
SPEC.md describes. Each has a top-level version, 1, and lists its
entries by client address, with times in UTC.
bans.json: every ban with its notes, indented to be read; a permanent ban'sexpiresisnull, and a bansmallwebwafmade has thecauselimitfor a broken rate limit orattackfor a clear sign of attack.clients.json: each client's two buckets in the minute, the hour and the day, and its history: when it was first and last seen, its country as last looked up and when, its requests, how many were forwarded and how many refused (onesmallwebwafanswered at its own endpoints is neither, unless it was refused with401for a missing or wrong token), the body bytes in each direction, its responses by status class and its offences by kind. Each client is on a line of its own, sogrepshows everything about one.lookups.json: GeoJS's answers, one to a line, with when GeoJS gave each and when it was last used.
bans.json is written SWWAF_STATE_WRITE_DELAY after a ban is made or made
permanent, with every such change in between, and every file every
SWWAF_STATE_COUNTER_INTERVAL and when smallwebwaf stops. Each write goes to
a temporary file in the same directory, which then replaces the file, so a crash
leaves the old file or the new one, whole. A write that fails is logged, and
tried again at the next write. A hard kill loses what changed since the last
write.
At start the files are read back: each client keeps its counts, so a restart
gives it no fresh allowance, and each ban keeps refusing every client in its
netblock until it ends, even after SWWAF_BAN_SCOPE_V4_PREFIX has changed. A
netblock whose address has bits past its length, such as 203.0.113.9/24, is
read as the netblock it is in, 203.0.113.0/24. Buckets and answers whose time
has passed are dropped. A missing file is empty state, as on a first start. A
file that does not parse, or has another version, stops the start with a
message naming the file, and the line and column where Go's JSON decoder gives
them; so does a state directory smallwebwaf cannot write. So does an entry
without a field it needs, named with the entry's place in the file: a ban's
netblock, start or expires, which is null for a permanent ban; a
client's client, or the start of a window in which it has requests; an
answer's client, country, which is "" for a client GeoJS cannot place, or
answered. So does a ban whose cause is neither limit nor attack. The AS
number and AS name come with their lookup.
While it runs, smallwebwaf watches SWWAF_STATE_DIR and takes in your edit of
a state file as soon as you save it: what the file then holds replaces what
smallwebwaf held for it, as if read at start. It tells its own writes from
yours by comparing the file with what it last read or wrote, and before it
writes a file it takes in any edit made since, so your edit is not overwritten;
a change smallwebwaf made after you opened the file, such as a new ban, is
lost when you save over it. An edit that would stop the start, because it does
not parse, has another version, leaves out a field an entry needs or gives a
ban another cause, does not stop the running smallwebwaf: it keeps what it
holds, and at the file's next write renames your file to <name>.bad, such as
bans.json.bad, writes the file again from memory, and logs the file and where
the error is. It waits for that write because an editor's file can be read
before the editor has finished writing it. Mend the .bad file and move it
back. A file you remove is written again at its next write.
To ban a netblock, add an entry to bans.json with its netblock, its start
and its expires, null for a ban that never ends; its cause and its notes
may be left out. A ban whose cause is attack becomes permanent at the first
request it refuses; one without a cause does not. This bans.json bans
203.0.113.0/24 for good:
{
"version": 1,
"bans": [
{
"netblock": "203.0.113.0/24",
"start": "2026-10-06T12:00:00Z",
"expires": null
}
]
}
To lift a ban, delete its entry. smallwebwaf then forgets the ban, so it does
not make the netblock's next ban longer.
Rule files
smallwebwaf reads every *.rules file in SWWAF_RULES_DIR,
/etc/smallwebwaf/rules.d by default, in the order of their names, and checks
each request against their rules in that order, as "Rule files" in
SPEC.md describes. A file whose name starts with ., such as an
editor's lock file .#50-app.rules, is not a rule file, as a shell's *.rules
would not match it. A rule is a line of four fields separated by spaces or tabs:
an id, a target, an action and a regex, which runs to the end of the line. Blank
lines and lines that start with # are ignored.
# id target action regex
env-file path ban (?i)^/\.env(\.[a-z]+)?$
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
- The id is letters, digits,
-and_, and no two rules share one. The request log, the metrics and a ban's notes name the rule by it. - The target is what the regex is matched against:
pathorquery, as the client sent it, before any decoding;uri, the path and the query together, both as sent and once percent-decoded, so that an encoded probe does not slip past;method;host;user_agent;referer; orheader:<Name>, any one request header butHostandTransfer-Encoding, which Go's HTTP server takes out of every request; the request's host is the targethost. A header sent more than once is matched with its values joined by,, and one not sent as empty text. No body is read. - The action is
log,blockorban(see "What it does so far" above). Keepbanfor requests no real visitor sends, and anchor a path at the site root with^/: a file of the same name deeper in a site can be ordinary content, such as a file in a repository on a code forge. - The regex is in Go's syntax, RE2, which has no backreferences or lookaround,
and takes time linear in the text it reads. It matches anywhere in the target
unless anchored with
^and$;(?i)at its front makes it ignore case.
A line that is not a rule, a rule for the Host or the Transfer-Encoding
header, a regex that does not compile or an id used twice stops the start with a
message naming the file and the line, and so does a SWWAF_RULES_DIR that does
not exist. An empty directory is no error, and the log says that it holds no
rules. While it runs, smallwebwaf watches the directory, and reads the rule
files again once the directory has had no change for 2 seconds after one is
edited, added or removed, so that a file saved in place, appended to or copied
in with scp is read only once whole, unless its writing stops for longer. It
also reads them 2 seconds after it starts watching, so that an edit saved while
it started is not missed. If they then hold one of those errors, the rules stay
as they were, the earlier version of the edited file included, the log names the
file and the line, and the files are read again after the next change.
The image ships one rule file, share/rules.d/00-default.rules here: rules that
ban probes no real visitor sends, for secrets, version control directories,
backups, logs and web shells at the site root, and the user agents of common
scanners; one that blocks ../ twice in a row in the path or the query; and one
that only notes a request without a user agent. An app's Dockerfile adds rules
of its own in a file beside it, named to sort after it, such as this
50-gitea.rules for an app that serves no WordPress:
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
A directory mounted over /etc/smallwebwaf/rules.d replaces the default file,
and single files mounted into it add to it. Docker does not show a single
mounted file being replaced, which is how many editors save, so rules to be
edited while smallwebwaf runs belong in a mounted directory, with a copy of
00-default.rules if its rules are to stay. To run without rules, mount an
empty directory or set SWWAF_RULES_ENABLED=false.
Metrics
GET /_smallwebwaf/metrics answers with the metrics in the Prometheus text
format, for a scraper that sends SWWAF_METRICS_TOKEN, through traefik like any
other request. No metric carries a client's address.
smallwebwaf_requests_total,smallwebwaf_request_bytes_totalandsmallwebwaf_response_bytes_total: requests, and their body bytes each way, bystatus_class, such as2xx, ornonewhen nothing was sent, and byaction, as the request log names it.smallwebwaf_request_duration_seconds: how long requests took, andsmallwebwaf_upstream_duration_seconds: how long those passed to the app took from then on, as histograms;smallwebwaf_requests_in_flight: the requests under way.smallwebwaf_rate_limit_hits_totalbywindow,smallwebwaf_size_and_time_limit_hits_totalbylimit, the setting whose limit was passed,smallwebwaf_offences_totalbykind, andsmallwebwaf_bans_made_totalbycause,limitorattack;smallwebwaf_active_bansandsmallwebwaf_permanent_bans.smallwebwaf_rule_matches_total: the requests that matched each rule, byrule_idandaction, the rule's own; andsmallwebwaf_rules_loaded: the rules read from the rule files.smallwebwaf_country_requests_total,smallwebwaf_country_request_bytes_total,smallwebwaf_country_response_bytes_total, andsmallwebwaf_country_list_refusals_total, the requests the country lists refused, bycountry, for the requests whose client's country is known. TheSWWAF_METRICS_TOP_Ncountries with the most requests since the start have series of their own, and the others are counted asother. A country that drops out of them loses its series, and its later requests count asother; one that comes into them gets a series that counts from then on.smallwebwaf_geojs_requests_total: the requests to GeoJS;smallwebwaf_geojs_failures_total: those that failed, an answer that leaves out an address asked about included; andsmallwebwaf_geojs_unanswered_total: the requests whose client counted as coming from an unknown country because GeoJS had not answered about it in time.smallwebwaf_tracked_clients: the clients in the table of clients.smallwebwaf_state_file_writes_total,smallwebwaf_state_file_write_failures_total,smallwebwaf_state_file_last_write_timestamp_secondsandsmallwebwaf_state_file_size_bytes, byfile; and, byfiletoo,smallwebwaf_state_file_edits_taken_in_total: your edits taken in, andsmallwebwaf_state_file_edits_set_aside_total: those renamed to<name>.badbecause they would stop the start.- Go's own
go_metrics and the process'sprocess_metrics.
The requests Go's HTTP server ends before smallwebwaf sees them (see "Request
log") are not counted. The metrics of the features still to come, such as the
Core Rule Set, come with them.
Why
Small self-hosted sites now receive a great deal of traffic nobody asked for:
scrapers that ignore robots.txt and crawl every commit of every repository on
a public git server, vulnerability scanners walking through lists of WordPress
and .env paths, and credential-guessing bots. Most of it comes from a small
number of hosting networks and countries. A single-person operation has no abuse
desk and no CDN contract; it needs something small that can be put in front of
one service and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want several containers, a database and a web console. None of them can
say "clients from these networks are banned after half as many requests as
anyone else", which is the most useful thing to be able to say when nearly all
abuse comes from a known list of AS numbers. EVALUATION.md
goes through the candidates one by one.
smallwebwaf is meant to fill that gap:
- protect a service from misbehaving scrapers and scanners with per-client request and byte limits over a minute, an hour and a day;
- lower those limits for the countries and AS numbers that abuse commonly comes from, so their clients are banned after fewer requests than others;
- ban abusers: briefly at first, longer each time they come back, and permanently when they keep at it; a scanner's first probe bans it for seven days;
- log everything in a form that is easy to search and ship elsewhere;
- stay small enough to understand: one binary in the app's own container, environment variables, no database, no required setting.
Proposed features
- Reverse proxy for one application, streaming in both directions, with
WebSocket support. One
smallwebwafper app, inside the app's own container. - Internet-ready out of the box: no setting is required, and every setting has a
default chosen for a service facing the internet in 2026. Every setting's name
starts with
SWWAF_, so that it cannot clash with the app's own. - Real client address worked out from
X-Forwarded-For, trusting only the proxy networks you list, by default the private address ranges. IPv6 clients are counted by /64 by default. - Size and time limits on requests and responses, with the time limits both
between the client and
smallwebwafand betweensmallwebwafand the app: by default a request may take 60 seconds and 100 MiB, a response 30 minutes and 5 GiB. - Rate limits per client on requests per minute, per hour and per day, and on bytes per minute, per hour and per day, on by default and set well above what real visitors need.
- Netblocks that bypass rate limiting, netblocks that bypass everything, and netblocks that are always refused.
- AS number and country lookup for every client, on by default through the free GeoJS web service, which is sent the address of every new visitor. The IPinfo Lite database file, which you download and mount, can be used instead, or lookups switched off (see "Country and AS number lookup" below).
- Country lists:
SWWAF_DENIED_COUNTRIESrefuses every request from the countries listed,SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESevery request from anywhere else. Such a request gets the answer a banned client gets as soon as the client's address has been looked up, before its body is read and without the rule files or the Core Rule Set looking at it, and no ban is made. - Biased limits: listed AS numbers and countries get a percentage of every limit, for example 50 percent for common abuse-source networks, so their clients are banned after fewer requests. Zero percent is a zero allowance: the first request breaks the limit and bans the client.
- Attack detection:
- a directory of plain text rule files, one regex per line, for catching scanning and penetration probes; easy to edit by hand, and picked up while running;
- the OWASP Core Rule Set, run by the Coraza engine, refusing the requests it flags; it reads the URL and headers of every request, and request bodies only once you switch that on, since on a code forge they are full of code it would take for attacks;
- trap paths, and a ban for a client that the rule files or the Core Rule Set refuse again and again.
- Bans:
- a clear sign of attack, such as a probe for a
.envfile or a scanner's user agent, bans for seven days on the first request, and any further request during those days makes the ban permanent; - breaking a limit bans for an hour; breaking one again within a day of a ban ending triples the length, and a ban that would last longer than seven days is permanent instead;
- every ban carries notes on why it was made, to help decide whether to lift it.
- a clear sign of attack, such as a probe for a
- IP reputation: downloadable blocklists, DNS blocklists, AbuseIPDB, and an optional feed of decisions from a CrowdSec engine. Lookups happen in the background and never delay a request. None is on until you add it.
- Alerts on attacks and bans to a generic webhook, Slack or ntfy, with a cooldown and an hourly cap so a wide attack cannot flood the channel.
- Anomaly alerts when requests or bytes per minute or hour cross a threshold you set, for a single client, its surrounding netblock, an AS number, a named netblock or the whole service.
- Observe mode: log and alert on every decision while refusing nothing.
- Request log: one JSON object per request on stdout with the usual web log fields, the decision taken and why, AS number and country, and timings. Optionally also sent to a remote syslog server.
- Prometheus metrics, for a scraper that holds the metrics token.
- State (bans with their notes, each client's counters and history, the GeoJS
answers, the reputation cache, the alerting state) held in memory and kept in
readable JSON files, written regularly and at every stop, so a restart loses
nothing. Edit a file, or add a rule file, and the running
smallwebwafpicks up the change. Nothing is read from disk while serving a request. The files for the bans, the clients and the GeoJS answers are built, with an edit taken in while running (see "State files" above); the others come with their features. - Health checks, the metrics, and listing, adding and lifting bans or asking why
a given address was refused, all on the one port every request uses: under
/_smallwebwaf/on the app's own address, through traefik like any other request. The metrics need the metrics token, and the ban management the admin token.
Not planned: TLS termination, routing for several apps, browser challenges (captcha or proof of work), a web console, or defence against floods large enough to fill the host's network link.
How it works, in short
For each request smallwebwaf:
- works out who the client really is;
- lets it straight through if it is on the bypass list, refuses it if it is on the deny list or currently banned;
- looks up its AS number and country, and refuses it if that country is denied, or is not among the only ones allowed;
- checks for a cached reputation verdict;
- picks the client's limit percentage from those;
- checks the minute, hour and day request counters against the limits, and bans the client if it breaks one;
- checks the request against the rule files and the Core Rule Set, and bans the client at once for a clear sign of attack;
- forwards it to the app and streams the response back, within the size and time limits;
- counts the bytes and any refusal by the rule files or the Core Rule Set, bans the client if it broke a limit, updates its history, sends any alerts that are due, and writes the log line.
A minimal deployment is the app's own Dockerfile, built on the smallwebwaf
image, with no setting. That image is built on Ubuntu 26.04 LTS, the newest
long-term support release of Ubuntu, pinned by digest, and moves to the next one
when it ships. It has nixpkgs installed, so the app adds the packages it needs
from nixpkgs. Beyond its FROM line the app's Dockerfile adds the app's binary,
any packages it needs, and the app's runit service, which starts the app as a
user of its own, listening on 127.0.0.1:8081:
# 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, where --listen and --trusted-proxies
stand for the app's own options:
#!/usr/bin/env bash
set -euo pipefail
main() {
sleep 1
exec chpst -u app:app /usr/local/bin/app \
--listen 127.0.0.1:8081 \
--trusted-proxies 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128
}
main "$@"
- The image's entrypoint,
runsvinit, has runit startsmallwebwafand the app side by side, each as its own user, and start either again a second after it exits. Leave outENTRYPOINTandUSERfrom the app's Dockerfile. nix-env -iA nixpkgs.<name>installs a package from the nixpkgs in the image, and the app finds it on itsPATH, after Ubuntu's own commands. That nixpkgs is fixed at one commit, so the samesmallwebwafimage always gives the app the same packages; newer ones come with a newersmallwebwafimage.- Deploy it as you deploy any app, with traefik's labels on this one container pointing at port 8080. upaas needs no change for this.
- The app has to trust
127.0.0.1and::1for forwarded headers, besides the private address ranges, since the requests it gets now come fromsmallwebwafon loopback. An app left at the usual default, the private ranges alone, sees every visitor as127.0.0.1. - Port 8080 is the only one the app must leave free: the health check, the
metrics and ban management are all on it, under
/_smallwebwaf/. The image's health check passes whilesmallwebwafanswers and the app accepts connections.SWWAF_LISTEN_ADDRcan movesmallwebwafto another port, which the app then leaves free instead; the health check follows it, and traefik's labels must point at it. The address part ofSWWAF_LISTEN_ADDRstays empty (for example:9000, never127.0.0.1:9000), sosmallwebwafkeeps listening on every address: traefik reaches it on the container's address, and the health check on127.0.0.1. smallwebwafkeeps its state files in/var/lib/smallwebwaf. Mount a volume there to keep bans and client history when a deploy replaces the container; without one, it still starts. At each start therunscript ofsmallwebwafgives that directory and every file in it to thesmallwebwafuser, so a host directory mounted there needs no change of owner.docker stophas runit stop both processes.smallwebwafthen stops taking requests and gives those in progress five seconds to finish.
A rule file is one rule per line: a name, what to match against, what to do, and a regex.
env-file path ban (?i)^/\.env(\.[a-z]+)?$
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
SPEC.md has the full design: the deployment, every environment
variable, the ban rules, the rule file format, the state files, the log fields,
the metrics, failure behaviour and the build order.
Country and AS number lookup
So far smallwebwaf looks up only the country, only through GeoJS, and only
while SWWAF_DENIED_COUNTRIES or SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set:
then the address of every new visitor is sent to GeoJS, except a visitor in
SWWAF_ALLOW_NETS or SWWAF_DENY_NETS and one whose netblock a ban covers, and
with neither set, none is. An IPv6 visitor is asked about by the first address
of its /64. A new visitor waits at most a second for its answer, and without one
counts as coming from an unknown country until the answer arrives. The addresses
waiting are asked about together, up to 200 in one request, one request at a
time; at most 10,000 visitors wait, and one more counts as coming from an
unknown country until there is room. While GeoJS fails, visitors with a kept
answer are unaffected and new ones count as coming from an unknown country.
GeoJS is then left alone for a second, twice as long after each further failure
up to five minutes, and asked again by the next request that needs it.
In the full design, smallwebwaf looks up the AS number and country of every
client, for the request log, the metrics and the ban notes, and for the country
lists and biased limits when you set them. It works with no setup: by default it
asks the free GeoJS web service, which needs no account and no file. This means
that, by default, the address of every new visitor is sent to GeoJS. Each answer
is kept for seven days, in memory and in lookups.json, so that it survives a
restart, and many addresses are asked about in one request. GeoJS publishes no
rate limit but may block a caller it thinks asks too much; while it is not
answering, new visitors count as coming from an unknown country, which
SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuses.
To keep your visitors' addresses on your own host, set
SWWAF_LOOKUP_SOURCE=off, or use the database file instead of GeoJS:
SWWAF_LOOKUP_SOURCE=file reads the free IPinfo Lite database
(ipinfo_lite.mmdb). SWWAF_LOOKUP_SOURCE comes in milestone 3 or later (see
the build order in SPEC.md); until then GeoJS is asked only while a
country list is set. You download the database with your own IPinfo account,
mount the directory that holds it into the container, point
SWWAF_LOOKUP_DB_PATH at the file and refresh it when you choose; smallwebwaf
never downloads it itself, and reads it again when you replace it. It has to be
the directory rather than the file itself: docker does not show a single mounted
file being replaced, so a refresh would go unseen. IPinfo releases it under the
Creative Commons Attribution-ShareAlike 4.0 International License and asks for
attribution, in its own words on https://ipinfo.io/lite: "The attribution
requirements can be met by giving our service credit as your data source. Simply
place a link to IPinfo on the website, application, or social media account that
uses our data." Its example of such a credit is a link mentioning "IP address
data is powered by IPinfo". A service that uses the database through
smallwebwaf should carry that link.
Neither source can place a private address, so a client on one, such as a
visitor on your local network, another container or your monitoring, has no
country: SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuses it unless you list it in
SWWAF_ALLOW_NETS, and SWWAF_DENIED_COUNTRIES does not refuse it. Such
addresses are never sent to GeoJS.
How the code is laid out
cmd/smallwebwaf: the binary, which only callsinternal/smallwebwaf.internal/smallwebwaf: the process: it reads the settings, the rule files and the state files, listens, serves requests untilSIGTERMorSIGINT, and stops, writing the state files. Run assmallwebwaf healthcheck, it is the image's health check instead.internal/config: reads the settings, the one place they are read.internal/proxy: what happens to each request: it works out the client, runs the checks, passes the request to the app and the answer back with the standard library'shttputil.ReverseProxywithin the timeouts and size limits, and writes the request's log line. Itscheckmethod is where a request is refused before anything reaches the app: forSWWAF_DENY_NETS, for a ban, for the country lists, for a rate limit, which bans the client, for ablockorbanrule, the latter banning the client, and for an announced body over the size limit; inobservemode, only for the size limit, with what it would have refused for noted in the log line. A request under/_smallwebwaf/thatchecklets through is answered byanswerAdmininstead of reaching the app.internal/metrics: the metrics, counted as the other parts tell it what happened, and served in the Prometheus text format.internal/bans: the ban ledger: each netblock's bans with their notes, how long a new ban lasts, when a ban for a clear sign of attack becomes permanent, and which ban is dropped whenSWWAF_MAX_BANSare held.internal/rules: reads the rule files at start and again as they change, and tells which of their rules a request matches.internal/lookup: looks up each client's country through GeoJS, and keeps the answers.internal/ratelimit: the table of clients: counts each client's requests, tells when one takes it over a rate limit, and keeps each client's history.internal/state: reads the state files at start, takes in an admin's edit of one while running, and writes them when they are due and at the stop.internal/requestlog: the lines on stdout: the request log line and the process's own messages.Dockerfile: the lint and test phases, then the image, whose last stage installs Ubuntu's packages, nixpkgs,runsvinitandsmallwebwaf, withshare/smallwebwaf.runas runit'srunscript forsmallwebwafandshare/rules.d/00-default.rulesas its default rule file.deploy/example-app: an app built on the image, whichscript/example-appchecks.
Besides the Go standard library, github.com/hashicorp/golang-lru/v2 keeps the
table of clients to 20,000, the GeoJS answers to 100,000 and the banned
netblocks to SWWAF_MAX_BANS, dropping the least recently seen, and
github.com/prometheus/client_golang keeps the metrics and serves them, and
github.com/fsnotify/fsnotify tells smallwebwaf when a state file or a rule
file is saved. The country codes are the list in internal/config/config.go.
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard: the scripts in script/ are the entrypoints for working on it, and
the Makefile targets are thin shims that call them. The scripts are POSIX sh,
so that they run in minimal containers.
script/bootstrap: installs what the other scripts need on the host:make,git,curl, Go forgofmt, node and yarn, and prettier.script/setup: readies a fresh clone: runsscript/bootstrap, thenscript/install-precommit.script/projectname: prints the project's name,smallwebwaf, whichscript/dockerand the others tag their images with.script/test: runs the tests, as thetestphase of theDockerfile.script/lint: runs golangci-lint, as thelintphase of theDockerfile.script/fmt: formats the Go code withgofmtand the Markdown with prettier.script/fmt-check: checks the formatting, and changes nothing.script/check: runsscript/test,script/lintandscript/fmt-check.script/docker: builds the image, whose build runs the tests and the linter first.script/cibuild: what CI runs:script/bootstrap,script/check, then the image build.script/precommit: run by the git pre-commit hook; runsscript/check.script/install-precommit: installs that hook;make hooksruns it.script/build: buildsbin/smallwebwafon the host, with Go installed, for working on the code by hand;make buildruns it.script/run: buildsbin/smallwebwafwithscript/buildand runs it, with its state files inbin/stateunlessSWWAF_STATE_DIRis set, and the rule files ofshare/rules.dunlessSWWAF_RULES_DIRis set;make runruns it.script/example-app: builds the image and, on it, the example app indeploy/example-app, runs it with a volume for the state files, and checks that the health check passes, that a request reaches the app throughsmallwebwaf, that a second request in a minute bans the client, that a probe for/.envbans another client, whose next request makes the ban permanent, thatsv stopanddocker stopstop it in order, and that a new container on the same volume still refuses the banned client; then removes the containers, the volume and both images. It needs network access, for nixpkgs' binary cache, andscript/checkdoes not run it;make example-appdoes.
TODO
- The rest of the design, in the order of the build order in
SPEC.md.
Documents
SPEC.md: the design.EVALUATION.md: what already exists, what each tool covers and misses, and why none was adopted.REPO_POLICIES.md: the policies this repository follows.
License
MIT. See LICENSE.