The old SPEC opened with a draft status and milestone narrative, said several requirements twice, and ended with a build order that is all built. The new one keeps every requirement the old one states, said once, in short sentences with bulleted lists in place of comma chains. What the code on next does is in the main sections; the two points the old SPEC deferred, sharing bans between instances and RELP, are in a last section, "Planned, not built", each marked as planned. The status line, the milestones and the build order are dropped as history. Where the old SPEC disagreed with the code, the new one follows the code; the PR body lists each such point. Model: fable-5-1
95 KiB
smallwebwaf specification
smallwebwaf is a web application firewall and reverse proxy for one app. It
runs inside the app's container, between traefik and the app. This document says
what it must do and why. Everything in it but the last section, "Planned, not
built", is built. README.md says how to run it. EVALUATION.md says why no
existing tool was adopted.
Purpose
- It protects one self-hosted app on the open internet from scrapers, scanners and other abusers.
- It protects the app from its first request with no setting at all. Every setting has a default chosen for a service facing the internet in 2026.
- It limits each client's requests and bytes per minute, hour and day. It bounds the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack, and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries and netblocks, and lower its limits for listed AS numbers and countries. It consults reputation sources. It sends alerts.
- Everything is set by environment variables named with the
SWWAF_prefix. The only other inputs are files: the rule files, the state files, a lookup database, and a setting given as a file.
Non-goals
- TLS, certificates and routing by host name. Those are traefik's job.
- More than one app per
smallwebwaf. Each app's image carries its own. - Browser challenges such as captchas or proof of work. If wanted, chain Anubis
between
smallwebwafand the app. - Defence against floods that saturate the host's network link. That needs help upstream of the host.
- A web console or a configuration file.
- Sharing bans between the instances of several apps. Each instance keeps its own. Reading a CrowdSec engine's decisions is supported.
Architecture
- One statically linked Go binary. It ships in an image built on Ubuntu 26.04
LTS with nixpkgs installed. That image is the base of the app's own image, so
that
smallwebwafand the app run in one container (see "Deployment"). runit startssmallwebwafas a non-root user of its own.smallwebwafwrites 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. It answers its own endpoints, for health, metrics and ban management, under/_smallwebwaf/. Those never reach the app. - The parts of the process:
- Client identification: the real client address from
X-Forwarded-For, trusting only the netblocks inSWWAF_TRUSTED_PROXIES. - Lookup: the AS number and country, from the GeoJS web service by default or from a database file the operator supplies, held in memory.
- Reputation: blocklists fetched on a schedule; DNS blocklists and AbuseIPDB asked in the background, with their answers cached.
- Counters: per-client requests and bytes per minute, hour and day, held in memory and written out with the rest of the state.
- Attack detection: regex rules read from plain text files and read again when they change; Coraza with the OWASP Core Rule Set; trap paths; and the error burst, a burst of refusals from one client.
- Ban ledger: active and past bans with their notes, and each client's history, held in memory and kept in JSON files the operator may edit.
- Request log: one JSON object per request on stdout, optionally also sent to a syslog server.
- Metrics: Prometheus counters and gauges.
- Alerting: a queue that holds back repeats and feeds a webhook, Slack and ntfy.
- Proxy: the standard library's
httputil.ReverseProxy, streaming in both directions within the size and time limits, with WebSocket support.
- Client identification: the real client address from
- No database of any kind. Nothing is read from disk while serving a request.
- Libraries, checked against the owner's Go dependency defaults:
- the standard library for the proxy, HTTP clients, DNS, logging
(
log/slog) and the state files (encoding/json,os.Rename); github.com/corazawaf/coraza/v3andgithub.com/corazawaf/coraza-coreruleset/v4atv4.25.0, which carries the Core Rule Set 4.25.0. The changessmallwebwafmakes to the Core Rule Set, and the defaultSWWAF_WAF_DISABLED_RULES, are built for that version. They are checked again before the version changes;github.com/oschwald/maxminddb-golang/v2to read the lookup database;github.com/fsnotify/fsnotifyto notice edited files;github.com/prometheus/client_golangfor the metrics;- remote log sending written in this project, since the standard library's
log/syslogis frozen and writes only the older syslog format.
- the standard library for the proxy, HTTP clients, DNS, logging
(
What happens to a request
The steps run in this order. The first step that produces a final answer ends the handling. The size and time limits apply to the whole exchange.
GET /_smallwebwaf/healthzis answered at once, before any check.- The client is identified.
- If the TCP peer is inside
SWWAF_TRUSTED_PROXIES,X-Forwarded-Foris walked from the right. The first address outside those netblocks is the client. If every address is inside them, the leftmost is the client, so that a visitor on a private network is known by its own address. With no header, the peer is the client. - If the peer is outside
SWWAF_TRUSTED_PROXIES, it is the client, and the header is ignored. - An IPv6 client is its group: the netblock of
SWWAF_IPV6_GROUP_PREFIXaround its address, a /64 by default, since one abuser usually controls a whole /64. A client is therefore one IPv4 address or one IPv6 group.
- If the TCP peer is inside
- The static lists. A client in
SWWAF_ALLOW_NETSskips every check below. It is still counted for the anomaly thresholds. A client inSWWAF_DENY_NETSis refused. - The ban ledger. A client under a ban is refused with
SWWAF_BAN_RESPONSE. If a clear sign of attack caused the ban, the ban becomes permanent. - The lookup, unless
SWWAF_LOOKUP_SOURCEisoff.- With GeoJS, 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: a country list, a biased threshold orSWWAF_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. A request that ends before then is logged without it. - A client the lookup cannot place has an unknown country and AS number.
Every private, loopback and link-local address is such a client. The
exclusive country list refuses it, and the biased thresholds give it
SWWAF_UNKNOWN_LIMIT_PERCENT. Nothing else treats it differently.
- With GeoJS, a request waits for its client's first answer, up to
- The country lists. A client from a country in
SWWAF_DENIED_COUNTRIESis refused. WhenSWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESis set, a client from any other country is refused. The answer isSWWAF_BAN_RESPONSE. The request body has not been read yet. The refusal skips everything below, the rule files and the Core Rule Set included. It is logged with the actioncountry_deniedand counted in the metrics. It is not an offence and makes no ban. - The reputation sources. A client inside a fetched blocklist gets
SWWAF_BLOCKLIST_ACTION. A client the CrowdSec decision list lists is banned until the decision ends. A cached DNS blocklist verdict or AbuseIPDB score getsSWWAF_REPUTATION_ACTION. With no cached verdict, a query is queued in the background and the request goes on. A first request is never delayed by a reputation query. - The client's limit percentage: the lowest of the percentages that apply to it, from its AS number, its country or a reputation source, or 100 if none does.
- Rate limiting, unless the client is in
SWWAF_RATE_LIMIT_EXEMPT_NETSor the path is underSWWAF_RATE_LIMIT_EXEMPT_PATHS. The minute, hour and day counters are checked against each limit times the percentage. A request over any of them breaks that limit: it is refused withSWWAF_BAN_RESPONSE, and the client is banned (see "Bans"). - The trap paths. A request for a path in
SWWAF_TRAP_PATHSis a clear sign of attack: it is refused and the client is banned. - The rule files, in file name order and then line order. A rule that matches
takes its action: only log, refuse with
403, or refuse and ban. Matching stops at the first rule that refuses. - The Core Rule Set inspects the request's method, its URL with the query
string, and its headers. By default it reads no body. When
SWWAF_WAF_BODY_LIMITis a size, it reads a body that is form data, multipart, JSON or XML, up to that size. Any other body reaches the app uninspected. So does a JSON or XML body larger than the limit, which cannot be read in part. Responses are not inspected. Inblockmode, the default, a match at or over the anomaly threshold is refused with403. Indetectmode it is only logged and alerted. - A request under
/_smallwebwaf/is answered here and goes no further. - The request is forwarded to
SWWAF_UPSTREAM_URL, streaming, withX-Forwarded-Foradded, andX-Client-ASNandX-Client-Countrywhen enabled. - After the response:
- The response bytes, the request body bytes, or both are added to the byte counters. A client whose total passes a byte limit times the percentage has broken that limit and is banned. The response in progress is not cut off.
- The client's refusals are counted: those
smallwebwafrefused after a rule file match, a trap path, a Core Rule Set match, or a missing or wrong token. Answers the app gave are not counted. More thanSWWAF_ERROR_BURST_THRESHOLDof them within a minute break a limit and ban the client. - The client's history and the anomaly counters are updated, and the alert thresholds are evaluated.
Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client. The earlier bucket is weighted by how much of it still overlaps the sliding window. This costs a few integers per client per window. It 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.jsoneverySWWAF_STATE_COUNTER_INTERVALand at shutdown, and loaded again at start. A restart therefore does not hand every client a fresh allowance. Buckets whose time has passed are discarded on load. - Memory is bounded by
SWWAF_MAX_TRACKED_CLIENTSandSWWAF_MAX_BANS, and by fixed limits on the GeoJS answers, the reputation verdicts and the anomaly counters kept. When the table of clients is full, the least recently seen client is dropped first, with its history. WhenSWWAF_MAX_BANSbanssmallwebwafmade are held, the one that has gone longest without a request is dropped first, past, active or permanent. Bans an admin made are never dropped.
Bans
A ban refuses every request from a netblock with SWWAF_BAN_RESPONSE until it
ends. A permanent ban does not run out. It ends when an admin lifts it, or, if
smallwebwaf made it, when it is dropped past SWWAF_MAX_BANS. 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. Such bans can never fill the table. 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.
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. It is one that
carries a clear sign of attack, breaks a limit, or is refused by a rule file, by
the Core Rule Set or for a missing or wrong token. 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 whose action is
ban, or asks for a path inSWWAF_TRAP_PATHS. Examples are a probe for a.envfile or a.gitdirectory, and a known scanner's user agent.- The first one bans the netblock for
SWWAF_ATTACK_BAN_DURATION(default7d). - 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. Its next clear sign of attack bans it permanently at once.
- The first one bans the netblock for
- A broken limit: a request over a rate limit, a response that takes the
client's byte total past a byte limit, or more than
SWWAF_ERROR_BURST_THRESHOLDrefusals within a minute.- The first such ban lasts
SWWAF_LIMIT_BAN_DURATION(default1h). So does one that comes more thanSWWAF_LIMIT_BAN_REPEAT_WINDOW(default24h) after the last such ban ended. - 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(default7d) is permanent instead. At the defaults, that is the sixth ban in a row. - Each such ban sets the client's counters for every limit back to zero. Once it ends, only new requests can break a limit again. The client's history keeps its totals.
- The first such ban lasts
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. They are counted in the client's history and in the
ban's notes.
A client the CrowdSec decision list lists is banned until the decision ends,
with the cause crowdsec. Such a ban is never made permanent. It does not make
the netblock's next ban for a broken limit longer.
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. 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 get
lower limits. So do clients a reputation source lists whose action is
limit:<percent>. The same rules then ban them after fewer requests.
Some refusals make no ban at all. SWWAF_DENY_NETS, a blocklist or reputation
hit whose action is deny, and the country lists refuse each request they cover
and record nothing in bans.json.
Settings
Conventions:
- Lists are comma separated. Netblocks are CIDR; a bare address means /32 or
/128. Durations use Go's syntax plus
dfor days (90s,15m,24h,7d). Sizes takeK,MandG, powers of 1024. Countries are two-letter ISO codes in either case. A code that is not a country, such asnk, 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_, sincesmallwebwafshares its container, and so its environment variables, with the app. offswitches off:- a timeout, a size limit, a rate limit, a byte limit or an anomaly threshold;
- the Core Rule Set's anomaly threshold and the error burst threshold;
- the alert cooldown and the hourly alert cap.
offis refused bySWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, and by every setting that is not a limit: the lookup timeout, the reputation cache and timeout, the ban durations, the state write times, and the blocklist refresh.- 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
_FILEadded, such asSWWAF_ADMIN_TOKEN_FILE, for secrets and long lists.SWWAF_LOG_REMOTE_TLS_CA_FILEnames a file already and has no_FILEform. - Settings, files included, are read once at start. Changing one means
restarting the container. The files
smallwebwafwatches while it runs are its state files, its rule files and the lookup database. - An invalid setting stops the process at start with a message naming the variable. At start the settings in effect are logged, with secrets masked.
The settings, by group:
Core
SWWAF_UPSTREAM_URL(defaulthttp://127.0.0.1:8081): the app, which listens there in the same container.SWWAF_LISTEN_ADDR(default:8080): the one listener, for the requestssmallwebwafforwards and for its own endpoints.SWWAF_ADMIN_TOKEN: the bearer token for the ban endpoints and/_smallwebwaf/clients/<ip>. A long random value, since the endpoints can be reached from the internet. One shorter than 32 characters stops the start. Unset by default, which switches the endpoints off; bans are then managed by editingbans.json.SWWAF_INSTANCE_NAME(default: the container's host name, which docker sets to the first 12 characters of the container's id): included in every log line, metric and alert. Set it, for example tofsn1app1/gitea, to a name that stays the same when a deploy replaces the container and tells instances apart.SWWAF_MODE(defaultenforce):enforce, orobserveto log and alert on every decision while refusing nothing.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, normally where traefik reachessmallwebwaffrom. 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(default64), from 32 to 128.SWWAF_MAX_TRACKED_CLIENTS(default20000): the clients held in memory and inclients.json.SWWAF_MAX_BANS(default5000): the banssmallwebwafmade that are held, past, active and permanent. Bans an admin made are kept besides, and never dropped.
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 thesmallwebwafuser. A volume mounted there keeps the state when a deploy replaces the container.SWWAF_STATE_WRITE_DELAY(default10s): after a ban is made, lifted or made permanent,bans.jsonis written this long later, with every change made in between, so a burst of changes becomes one write.SWWAF_STATE_COUNTER_INTERVAL(default15m): how often every state file is written.
Logging
- The request log on stdout is always on and has no switch.
SWWAF_LOG_LEVEL(defaultinfo): for the process's own messages, which are JSON lines on stdout too, marked"type":"process". It does not filter the request log.SWWAF_LOG_REQUEST_HEADERS(defaultaccept,accept-language,accept-encoding,content-type,origin,range): the request headers to record.Authorization,CookieandSet-Cookievalues are never logged, only whether they were present.SWWAF_LOG_REMOTE_URL: when set, every log line is also sent there, assyslog+udp://host:514,syslog+tcp://host:514orsyslog+tls://host:6514.SWWAF_LOG_REMOTE_TLS_CA_FILE: an optional CA certificate file for the+tlsform.SWWAF_LOG_REMOTE_BUFFER(default10000): the lines held in memory while the server is unreachable. When full, the oldest are dropped and counted.SWWAF_LOG_REMOTE_FACILITY(defaultlocal0) andSWWAF_LOG_REMOTE_APP_NAME(defaultSWWAF_INSTANCE_NAME): the syslog header fields.
Metrics
SWWAF_METRICS_TOKEN: the bearer token a scraper sends for/_smallwebwaf/metrics, a long random value. One shorter than 32 characters stops the start. Unset by default, which switches the metrics off, since they would otherwise be open to anyone on the internet.SWWAF_METRICS_TOP_N(default50): how many AS numbers and countries get series of their own. The rest are summed asother.
Static lists
SWWAF_ALLOW_NETS: bypass everything (monitoring, the owner's own networks).SWWAF_RATE_LIMIT_EXEMPT_NETS: bypass the request and byte limits only. The error burst, attack detection and bans still apply.SWWAF_DENY_NETS: always refused.
Request rate limits, per client
Breaking one bans the client, 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, can still
pass the day limit. Its address belongs in SWWAF_RATE_LIMIT_EXEMPT_NETS.
SWWAF_RATE_LIMIT_PER_MINUTE(default1000),SWWAF_RATE_LIMIT_PER_HOUR(default10000) andSWWAF_RATE_LIMIT_PER_DAY(default50000).SWWAF_RATE_LIMIT_EXEMPT_PATHS: path prefixes not counted, such as static assets or health checks.
Byte limits, per client
A response's bytes are counted when it ends, so every default sits above the largest response allowed, and no single download breaks one.
SWWAF_BYTES_LIMIT_PER_MINUTE(default10G),SWWAF_BYTES_LIMIT_PER_HOUR(default20G) andSWWAF_BYTES_LIMIT_PER_DAY(default50G).SWWAF_BYTES_COUNT(defaultboth):response,requestorboth.
Size and time limits
These apply per request, in both directions. The client-facing timeouts apply
between the client and smallwebwaf. The app-facing ones apply between
smallwebwaf and the app. 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(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. Over it, Go's HTTP server answers431and closes the connection, and nothing reaches the app. It must be more than4Kand 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. It is longer than the 90 seconds after which traefik, by default, closes an unused connection. So traefik closes first, and never sends a request on a connectionsmallwebwafis closing.SWWAF_CLIENT_RESPONSE_TIMEOUT(default30m): how long one 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 one whole response, 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.
When a limit is passed after the request line and headers have been read, but
before the response has started, smallwebwaf answers itself:
413for a request body that is too large;408for a client too slow to send its body;502for a response that is too large;504for 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.
Since a request body streams through, each side can hold up the other. While a
request body is still on its way, a request timeout that runs out answers 408
if smallwebwaf was waiting for the client to send more at that moment. It
answers 504 if it was waiting for the app to take what it had.
Go's HTTP server 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 either no answer or an error answer from the server, mostly
400. Either way the server closes the connection and nothing reaches the app.
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
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(defaultgeojs):geojs,fileoroff.SWWAF_LOOKUP_DB_PATH: the file forfile: the IPinfo Lite database in its.mmdbform (ipinfo_lite.mmdb), one file that carries both country and AS number.smallwebwafreads itsasn,as_nameandcountry_codefields. The operator downloads it with a free IPinfo account and mounts it read-only.smallwebwafnever 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.
smallwebwafnotices when the file is replaced and reads it again. A replacement it cannot read is ignored, logged and sent as onefile_erroralert, 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. geojsasks the free GeoJS web service about up to 200 addresses in one request (https://get.geojs.io/v1/ip/geo.json?ip=a,b,c). It reads each answer'scountry_code,asnandorganization_name(the AS name). An answer without acountry_code, which GeoJS sends for ranges it cannot place, counts as unknown. So does anasnof64512, which GeoJS gives when it knows none. GeoJS is told the address of every new visitor, whether or not a setting uses the answer. The request log, the metrics, the client's history and ban notes carry the AS number and country, so every answer is used. Private, loopback and link-local addresses, which no source can place, are never sent.- Each GeoJS answer is kept for 7 days, in memory and in
lookups.json, 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. SWWAF_LOOKUP_TIMEOUT(default1s): how long a request waits for its client's first answer when a setting needs it. 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, the rest in the requests after it. A request that takes longer thanSWWAF_LOOKUP_TIMEOUTis 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. SoSWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES, when set, refuses them.smallwebwafkeeps asking with backoff and sends onesource_failurealert per cooldown. A service that cannot accept this uses the file. - One source at a time.
SWWAF_LOOKUP_SOURCE=filewithoutSWWAF_LOOKUP_DB_PATH, orSWWAF_LOOKUP_DB_PATHwith any other source, stops the start with a message naming both. So does a setting that needs lookups whileSWWAF_LOOKUP_SOURCEisoff: the country lists, the biased thresholds,SWWAF_ADD_LOOKUP_HEADERS, and the anomaly thresholds per AS number. SWWAF_ADD_LOOKUP_HEADERS(defaultfalse): passX-Client-ASNandX-Client-Countryto 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 examplecn,ru,kp,ir,ua,by. Every request from a listed country is refused. -
SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES: for exampleus,de. Only clients placed in a listed country get through. Every request from any other country is refused. 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;
- 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_COUNTRIESthen adds nothing, since the exclusive list already refuses every other country. A code on both lists stops the start with a message naming it. -
What a refused request gets is under "What happens to a request", step 6.
Biased thresholds
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 exampleAS14061: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.0is a zero allowance: the client's first request breaks a limit and bans it.SWWAF_COUNTRY_LIMIT_PERCENT: the same form with country codes, for exampleCN:25,RU:50.- 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_PERCENTandSWWAF_COUNTRY_BYTES_PERCENT: optional overrides applied to the byte limits only, when the byte percentage should differ from the request percentage.SWWAF_UNKNOWN_LIMIT_PERCENT(default100): for clients the lookup cannot place.SWWAF_ASN_LIMIT_PERCENT_URL: optional URL of a text file ofAS:percentlines, so one abuse-source list can be shared by everysmallwebwafin the fleet. It is fetched and refreshed like the blocklists.
Attack detection
By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That 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: issue and comment text,
wiki pages, files saved in the web editor, package descriptions. 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): the directory of rule files, read at start and again whenever a file in it changes (see "Rule files"). -
SWWAF_RULES_ENABLED(defaulttrue):falseskips rule files entirely. -
SWWAF_WAF_MODE(defaultblock):off,detect(log and alert only), orblock. -
SWWAF_WAF_PARANOIA_LEVEL(default1) andSWWAF_WAF_ANOMALY_THRESHOLD(default5): the Core Rule Set's own two tuning values, at its own defaults. -
SWWAF_WAF_DISABLED_RULES(default920340,920420,920440,920640,930130,930140): rule ids to switch off when an app trips a false positive. The default switches off six rules:- 920340, 920420 and 920640 refuse a request for the type of its body, when the type is missing or not on the Core Rule Set's short list;
- 920440 refuses a request for its file extension, such as
.shor.sql; - 930130 and 930140 refuse a file or directory name in the path, such as
.git/,.gitignore,Dockerfile,package.jsonor an editor's settings directory.
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,/.htpasswdand/wp-config.php.bak. A list given replaces the default, so include them in it. -
SWWAF_WAF_EXEMPT_PATHS: path prefixes not inspected. -
SWWAF_WAF_BODY_LIMIT(defaultoff):offreads no request body. A size, such as128K, switches body inspection on. The Core Rule Set then reads form data and multipart bodies up to that size, streaming the rest of a longer one on without holding it in memory. It reads JSON and XML bodies no larger than the size, since those cannot be read in part. Any other body is not read: 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, until the rule ids the request log names are added toSWWAF_WAF_DISABLED_RULES:- 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);
- attachments named like
debug.logorconfig.yml(932180).
-
SWWAF_TRAP_PATHS: paths the app never serves and only scanners ask for, for example/wp-login.php,/xmlrpc.phpin front of gitea. A request for one is a clear sign of attack. This is the short form of apathrule with thebanaction, for deployments that mount no rule files. -
SWWAF_ERROR_BURST_THRESHOLD(default30): the refusals per client per minute after a rule file match, a trap path, a Core Rule Set match, or 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 answered404by design, often hundreds of times a minute. git and registry clients are answered401at the start of every push, every fetch from a private repository and every image pull.
The six changes to the Core Rule Set
smallwebwaf 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,PATCHandDELETEare allowed methods besidesGET,HEAD,POSTandOPTIONS. APIs, container image pushes and package uploads use them. Other methods stay refused.- The request header
Expectis allowed, since curl and git send it before some large uploads.Content-Encodingis 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 asProxy, stay refused. - The
redirect_uriparameter is not checked for a URL naming an IP address orlocalhost(931100, 934110). Git Credential Manager, git-credential-oauth and tea, which gitea registers for OAuth sign-in, ask to be sent back tohttp://127.0.0.1on the user's own machine. The server never fetches that address. - Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260):
path,files,skip-to,sub_path,ref,sha,branch,workflow,artifactNameandredirect_to. gitea sends file paths, branch and workflow names, and the page to return to after signing in, in them. 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-entrypointor a branch nameddocker-build. gitea reads these values as names within a repository or its own records, or as a page of its own site. What only these three rules refuse is let through there. That is more than a name such as/etc/passwdorwhoamion its own: commands such as|cat /etc/passwd,wget http://…andnc -e /bin/sh …, andfile:///etc/passwd, pass as well. Path traversal (../), SQL and script injection, and PHP, Java and Node.js code are still refused there. Every other parameter keeps all three rules. These names, andredirect_uri, are matched without regard to case, as Coraza matches them. 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_flashandredirect_tocookies. It does not checkRefererfor a Unix command given without arguments (932340) or for Java starting a process (944110). gitea writes intogitea_flasha message for the next page naming what was just done, and intoredirect_tothe page to return to after signing in.Refereris the address of the page a request comes from. In these cookies the Core Rule Set takes names such aspackage.json,document.write.jsorGet-ChildItem.ps1, and titles that hold them, for attacks. InRefererit takes the address of a search for one word, such asenv,setorlast, when the address ends in it, and the address of OpenJDK'ssrc/java.base/share/classes/java/lang/Runtime.java. A browser sends such a cookie with every request until gitea replaces or removes it. gitea cannot do that whilesmallwebwafrefuses those requests, so one refusal would keep the browser out of the whole site. A browser names a page inRefereron 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. 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 inReferer. Every other cookie is read in full, andRefererkeeps 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.
Bans
SWWAF_ATTACK_BAN_DURATION(default7d): the ban for a first clear sign of attack.SWWAF_LIMIT_BAN_DURATION(default1h): the ban for a first broken limit.SWWAF_LIMIT_BAN_REPEAT_WINDOW(default24h): 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(default7d): a ban that would be longer is permanent instead.SWWAF_BAN_RESPONSE(default403):403,429, orcloseto drop the connection without an answer.403makes a ban easy to recognise when debugging. Behind traefik,closedoes not leave the client unanswered: traefik answers502, as it does whenever its backend drops a connection.SWWAF_BAN_SCOPE_V4_PREFIX(default32): widen to for example24to ban the surrounding netblock.
Reputation
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.
Spamhaus may block an address that downloads it too often. Each smallwebwaf
fetches its own copy. 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 everySWWAF_BLOCKLIST_REFRESH(default24h, never less than1h). The last good copy is kept whole, comment lines included, on failure and across restarts.SWWAF_BLOCKLIST_ACTION(defaultdeny):deny,limit:<percent>orlog.denyrefuses 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 examplednsbl.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: an optional resolver address, since public resolvers are refused by several list operators.SWWAF_ABUSEIPDB_KEY,SWWAF_ABUSEIPDB_MIN_SCORE(default75) andSWWAF_ABUSEIPDB_DAILY_BUDGET(default900; 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_URLandSWWAF_CROWDSEC_LAPI_KEY: optional. If a CrowdSec engine exists on the host, its decision list is pulled on a schedule and listed addresses are treated as banned. This is how a fleet-wide blocklist can arrive withoutsmallwebwafdepending on CrowdSec. The list is kept like a fetched blocklist. A listed client's request makes a ban with the causecrowdsecthat lasts as long as CrowdSec's decision, so a long list does not fillbans.json.SWWAF_REPUTATION_ACTION(defaultlimit:25):deny,limit:<percent>orlog, for DNS blocklist and AbuseIPDB 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(default24h) andSWWAF_REPUTATION_TIMEOUT(default2s).
Alerting
SWWAF_ALERT_WEBHOOK_URL: a JSON POST; schema under "Alert webhook schema".SWWAF_ALERT_WEBHOOK_HEADERS: optionalName:valuepairs for authentication.SWWAF_ALERT_SLACK_WEBHOOK_URL: a Slack incoming webhook, sent a formatted message.SWWAF_ALERT_NTFY_URL(the full topic URL) andSWWAF_ALERT_NTFY_TOKEN: title, priority and tags set from the event type.SWWAF_ALERT_EVENTS(defaultban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error): which event types are sent.source_failureis a reputation source or GeoJS failing or refusingsmallwebwaf.file_erroris 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(default15m): the same event for the same netblock, source, file or anomaly scope is not repeated within this time. A count of suppressed repeats is included in the next one.SWWAF_ALERT_MAX_PER_HOUR(default60): beyond this, alerts are rolled into one summary per hour, so a wide attack cannot flood the channel.
Anomaly thresholds
These only alert; nothing is blocked. None is set by default, and a threshold left unset sends no 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_MINUTEandSWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR. - Per surrounding netblock (
SWWAF_ANOMALY_NET_V4_PREFIXdefault24,SWWAF_ANOMALY_NET_V6_PREFIXdefault48):SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE,..._PER_HOUR,SWWAF_ANOMALY_NET_BYTES_PER_MINUTEand..._PER_HOUR. - Per AS number, which needs a lookup source:
SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE,..._PER_HOUR,SWWAF_ANOMALY_ASN_BYTES_PER_MINUTEand..._PER_HOUR. - The whole service:
SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE,..._PER_HOUR,SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTEand..._PER_HOUR. - Named netblocks:
SWWAF_WATCH_NETS, for exampleoffice=203.0.113.0/24,scraper-x=198.51.100.0/22, withSWWAF_WATCH_REQUESTS_PER_MINUTE,..._PER_HOUR,SWWAF_WATCH_BYTES_PER_MINUTEand..._PER_HOURapplied to each named netblock as a whole. - Anomaly counting includes clients in
SWWAF_ALLOW_NETSandSWWAF_RATE_LIMIT_EXEMPT_NETS, since an exempt client misbehaving is worth knowing about.
Rule files
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, the metrics, the alerts and the ban notes. -
target: what the regex is matched against.path: the URL path without the query, both as received and once percent-decoded, so an encoded probe cannot slip past.query: the raw query string.uri: the path and query together, both as received and once percent-decoded.method,host,user_agent,referer.header:<Name>: any one request header butHostandTransfer-Encoding, which Go's HTTP server takes out of every request.- Request bodies are not available to rule files. Reading them is the Core
Rule Set's job, once
SWWAF_WAF_BODY_LIMITswitches it on.
-
action:log: note the match in the request log and do nothing else.block: refuse the request with403. The client is not banned for it, but the refusal counts toward the error burst.ban: the request is a clear sign of attack. Refuse it and ban the client's netblock forSWWAF_ATTACK_BAN_DURATION. Any further request during the ban, or a later clear sign of attack, makes the ban permanent. Use it only for requests no real visitor sends. 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. 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 ignore case. 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.rulesbefore50-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. The log and one
file_erroralert name the file and line. Once the file is fixed, it is read again. ASWWAF_RULES_DIRthat 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 setSWWAF_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 whilesmallwebwafruns belong in a mounted directory, with a copy of the default file if the defaults are to stay. -
In
SWWAF_MODE=observeevery action is logged as what would have happened and nothing is refused. -
Clients in
SWWAF_ALLOW_NETSare 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. 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. 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.
The files, each holding one kind of state:
-
bans.json: active and past bans, up toSWWAF_MAX_BANSthatsmallwebwafmade and every ban an admin made. Per entry: the netblock, start, expiry (nullfor permanent), the cause (attack,limit,adminorcrowdsec), a short reason, when it was lifted if an admin lifted it, and the notes below. -
clients.json: per client, its minute, hour and day counters and its history since it was first seen. The history holds:- the first and last time seen;
- the AS number, AS name and country last looked up, and when;
- total requests, and how many were forwarded and how many refused;
- bytes in each direction;
- responses by status class;
- offences by kind.
Clients that were never banned are kept too, so every client's history survives a restart.
-
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. -
reputation.json: the last good copy of each list fetched from a URL; the cached DNS blocklist verdicts and AbuseIPDB scores, each with the time it was fetched; and the AbuseIPDB checks spent today, so that a restart does not reset the daily budget. -
alerts.json: the anomaly counters; for each event and netblock, source, file or anomaly scope, when it was last sent and how many repeats the cooldown has held back since; the alerts sent this hour; and the alerts still waiting to be sent.
Ban notes hold what an admin needs to decide whether to lift a ban, 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 id and target that matched, or the trap path, 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 request that caused the ban: 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 window 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.jsontakes about 1 KiB per client. On a public service the file grows to the defaultSWWAF_MAX_TRACKED_CLIENTSof 20,000, about 20 MiB. Written every 15 minutes, that is under 2 GiB of disk writes a day.bans.jsontakes about 1.2 KiB per ban and at most about 2.5 KiB, since the notes hold one request and their texts are cut short. At the defaultSWWAF_MAX_BANSof 5,000 it is about 6 MiB, and never more than about 12 MiB, plus whatever bans an admin made.lookups.jsontakes about 150 bytes per answer, about 15 MiB when full.reputation.jsonandalerts.jsonare 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. That 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.jsonis writtenSWWAF_STATE_WRITE_DELAYafter a ban is made, lifted or made permanent. Changes that only update the counts in its notes wait for the write everySWWAF_STATE_COUNTER_INTERVAL, as the other four files do. All five are written on orderly shutdown (SIGTERM);- before writing a file,
smallwebwafchecks whether it changed on disk since it last read or wrote it. If it did,smallwebwaftakes that edit in first, 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. Since writes are atomic, a broken file can only come from a hand edit, which the editor should hear about.
Edits while running:
smallwebwafwatchesSWWAF_STATE_DIRand 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
smallwebwafheld for that file. Removing a ban's entry frombans.jsonlifts the ban; adding an entry bans. Changessmallwebwafmade 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 and renames the edited file to<name>.bad(for examplebans.json.bad), so the edit is kept. It writes the file again from memory, logs the file and the position of the error, and sends them in onefile_erroralert. The admin fixes the.badfile 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
the 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 "Size
and time limits"). stdout is always on. When SWWAF_LOG_REMOTE_URL is set the
same lines are also sent there, 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, and the headers named inSWWAF_LOG_REQUEST_HEADERS;has_authorizationandhas_cookie, as booleans;websocket, when the connection was upgraded.
- Response detail:
response_content_type,upstream_status(differs fromstatuswhensmallwebwafanswered itself),cache_control,locationon redirects,abortedwhen the client went away early. - Decision:
action:forward,banned,denied,country_denied,rate_limited,rule_blocked,waf_blocked,too_large,timed_out,upstream_error, oradminfor a requestsmallwebwafanswered at one of its own endpoints;would_action, inobservemode;limit_percent, and which setting gave it;counts, the client's minute, hour and day request and byte totals after this request;limit_hit, which window;rule_ids, the rule file rules that matched;waf_rule_idsandwaf_score;reputation, the sources that listed the client;offence, when one was recorded;ban_expires.
- Timings in milliseconds:
duration_total,duration_checks(everythingsmallwebwafdid before forwarding),duration_waf,duration_upstream_connect,duration_upstream_first_byteandduration_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:
- each line is the message of an RFC 5424 syslog 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 server never delays a request and never stops stdout. The sender reconnects with backoff, drops the oldest lines when the buffer is full, and counts the drops in the 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. 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, bytes or refusals); size and time limit hits by limit; offences by kind; bans made by cause; permanent bans; active bans; 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, 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_Nbusiest of each with the rest summed asother, 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; when each list was last fetched and the lookup database last read. - Housekeeping: tracked clients; state file writes, write failures, last successful write time and size per file; edits taken in, and edits set aside because they did not parse; alerts sent, failed, suppressed and dropped 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: answers200withokto anyone, without a token, whilesmallwebwafis running. It does not ask the app; the container's health check does. It is answered before any check, so a health checker never needs an exemption and is never refused, for example bySWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.GET /_smallwebwaf/metrics: the metrics; needsSWWAF_METRICS_TOKEN.GET /_smallwebwaf/bans,POST /_smallwebwaf/bans(a client or netblock, a duration, a reason) andDELETE /_smallwebwaf/bans/<client>: needSWWAF_ADMIN_TOKEN. Editingbans.jsondoes the same without a token.GET /_smallwebwaf/clients/<ip>: needsSWWAF_ADMIN_TOKEN. The client's current counters and history, which holds its lookup result and offences, and its bans with their notes. It answers "why was this address refused" and "what has this netblock been doing".- A token is sent as
Authorization: Bearer <token>. One set shorter than 32 characters stops the start. While a token is unset, the endpoints that need it answer404, 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. They 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, inSWWAF_MODE=observetoo, and counts toward the error burst. A client guessing tokens is therefore banned once it passesSWWAF_ERROR_BURST_THRESHOLDguesses in a minute. A client inSWWAF_ALLOW_NETSskips 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 theSWWAF_ALERT_EVENTSvalues, orsummaryfor the hourly summary);client,netblock,asn,as_name,country;reason: a short human-readable sentence;detail: event-specific fields, for examplewindow,count,limit,limit_percent,rule_ids,path,ban_expires, for a ban its notes, and forfile_errorthe file and the position of the error;suppressed_repeats: the number of identical alerts held back by the cooldown.
Deployment
smallwebwaf runs inside the app's own container: the app's Dockerfile builds
FROM the smallwebwaf image. Each app remains one container, deployed as
before. 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 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-binandrunit, installed from a dated snapshot of Ubuntu's archive and checked by hash (see "Packages from Ubuntu"). The Ubuntu image has no CA certificates. They come withnix-binonly because a library it uses recommends them, soca-certificatesis installed by name. Without it Nix cannot download packages, andsmallwebwaf, unable to reach GeoJS, would count every visitor as coming from an unknown country. - Nix, the package manager, from Ubuntu's own
nix-binpackage, and nixpkgs, the collection of packages Nix installs from, fixed at one commit (see "Packages from nixpkgs"). Root uses Nix directly, and no Nix daemon runs in the container. Nix run by root expects a group of build users,nixbld, whichnix-bindoes not create. So the image writesbuild-users-group =to/etc/nix/nix.conf, and root's builds then run without build users. - runit, from Ubuntu's own
runitpackage, andrunsvinitas the entrypoint, as the owner's code style guide asks for service containers. Neither Ubuntu nor nixpkgs packagesrunsvinit, 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 isv2.0.0. It has nogo.mod, andgo buildof its directory needs one, so the build writes one. Sincerunsvinituses only Go's standard library, that file names nothing else.runsvinitstarts runit'srunsvdir, which starts arunsvfor each directory under/etc/service. Eachrunsvruns therunscript in its directory, and runs it again whenever it exits. Ubuntu's runit looks for services in/etc/servicetoo, so whendocker stophasrunsvinitstop each service with runit'ssv,svfinds it. - The
smallwebwafbinary, and a user of its own,smallwebwaf(uid and gid 65532). - The service directory
/etc/service/smallwebwaf. Itsrunscript waits one second (sleep 1), makesSWWAF_STATE_DIRand every file in it belong to thesmallwebwafuser, and startssmallwebwafas that user with runit'schpst. - The directory
/var/lib/smallwebwaffor the state files, and/etc/smallwebwaf/rules.dwith the default rule file. - Port 8080 declared (
EXPOSE 8080), and the health check described below (HEALTHCHECK).
Every run script, the app's included, is a bash script. It 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/servicenamed after the app, neversmallwebwaf. Itsrunscript waits one second (sleep 1) and then starts the app withchpst, listening on127.0.0.1:8081, as a user of its own that the Dockerfile creates withuseradd. That user is neither root norsmallwebwaf, so that the app cannot touch the state files or the process ofsmallwebwaf.
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@sha256:<digest>
# Packages the app needs, if any, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.git
# The app's binary, and a user of its own to run it.
COPY app /usr/local/bin/app
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
# The app's runit service.
COPY --chmod=755 app.run /etc/service/app/run
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. The
archive itself changes with every update, so its packages are not pinned. 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. 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 of the
snapshot's InRelease files. 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. apt-get update also fetches
the live archive's InRelease files into the same directory. Their hashes
change whenever the archive does, and the install does not use them, so the
check leaves them out. The hashes are those of the archive for amd64, and so the
image is built for amd64: other architectures use Ubuntu's ports archive, whose
InRelease files differ.
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, mounted for that one step.
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. 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. 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>. Whatever it installs is on
the PATH of every service. The image adds root's Nix profile,
/nix/var/nix/profiles/default/bin, at the end of the PATH, after Ubuntu's
own directories, so that no package hides the image's own commands. busybox, for
one, brings its own sv, which looks for services elsewhere. 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. 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
runsvinitis the container's first process and runs as root, as do runit'srunsvdirand therunsvthat watches each service. Eachrunscript starts as root and hands over to its program's user withchpst:smallwebwafruns assmallwebwaf, the app as its own user.- Both start with the container's environment variables, which is why every
setting of
smallwebwafcarries theSWWAF_prefix. Both write to the container's output, whichdocker logsshows: the JSON lines ofsmallwebwafand whatever the app writes. - When
smallwebwafor the app exits, for whatever reason, runit runs itsrunscript again, which waits one second before starting it. The other process keeps running, and the container does not stop. Whilesmallwebwafis down, nothing answers on port 8080 and traefik answers502. While the app is down,smallwebwafanswers502. - A
smallwebwafthat 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 stopsendsSIGTERMtorunsvinit, which has runit stop both services at once, each withSIGTERM, and exits once they have stopped.smallwebwafstops taking requests, gives those in progress five seconds to finish, and writes its state files as it stops. The requests in progress can finish only while the app still answers. An app that exits at once onSIGTERMends them: the client gets502, or a response cut short if the app had already begun to answer. An app that should let them finish keeps taking and answering requests for the whole five seconds afterSIGTERM, not only the ones it already has.smallwebwafcan still pass it requests it took before then.- 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 runs smallwebwaf healthcheck. It 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. It fails when either does not. The container therefore
shows as healthy only while both processes are up. traefik sends a container no
requests until it shows as healthy. So the check runs every second from the
container's start until it first passes, for up to a minute, and every 30
seconds after that. 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/. SWWAF_LISTEN_ADDR may set another port. The image's health
check takes its port from that setting, 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. It holds a few tens of MiB at most with the defaults, 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.
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. 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.
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
gitea is built on the image like any other app.
-
SSH access to gitea does not pass through
smallwebwafand is not protected by it. -
A clone is a response. It 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: a git push, an LFS object, a container image layer, a package file or a release attachment. At the defaults, an upload larger than 100 MiB or taking more than 60 seconds is cut off. The client is answered
413for a body that is too large, before anything reaches gitea when the request announces its size. It is answered408for a body it sends too slowly, or504if gitea is too slow to take it. The upload fails, and no one is banned for it. A gitea that takes large uploads needsSWWAF_REQUEST_MAX_BYTES,SWWAF_CLIENT_REQUEST_TIMEOUTandSWWAF_UPSTREAM_REQUEST_TIMEOUTraised to fit. The Core Rule Set does not read an upload's body, which streams through without being held in memory. -
At the defaults, 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; - posting issues, pull requests, comments, wiki pages and files saved in the web editor, code included, since no body is read;
- the page gitea shows after a change, with its message naming what was done.
-
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 inwaf_rule_ids, whichSWWAF_WAF_DISABLED_RULEScan switch off.-
A query string that reads to it as an attack, most often a search. 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. Examples:- a name on its lists of system files and commands, such as
package.json,.gitignoreordocker-compose.yml; - text that starts with a command name, or whose first word has one
right after a
/, such aspython3,ssh keyorfeat/docker-support; - a shell command with its options or a system path (
ls -la,sed -i,/bin/sh), or a command in backticks; - script code (
fetch(,${VAR},process.env), HTML (<img src=) or an SQL statement (SELECT * FROM users WHERE); - a URL naming an IP address or
localhost.
- a name on its lists of system files and commands, such as
-
A branch, tag or file name that starts with an entry on the Core Rule Set's list of commands (932260), such as
docker-,python3,ansible,base64orwhoami. One that has such an entry right after a/counts too, such asfeat/docker-support,fix/docker-buildorrenovate/docker-build-push-action-6.x. gitea's own pages send the name in a query parameter that 930120, 932160 and 932260 still check:refSubUrl, the branch or tag of a directory listing. It is sent when the listing asks for its entries' last commits in a second request, which 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;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. 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 the403answer ofsmallwebwafin place of its form. Saving such a rule stores it, but the branch settings page gitea then returns to gets the403answer, so the save looks as if it failed. -
A file name in a query parameter in which the Core Rule Set's rules for Java, script, PowerShell and SQL find what they look for. The parameters the fourth change leaves out of 930120, 932160 and 932260 are included. Examples:
runtimein OpenJDK'ssrc/java.base/share/classes/java/lang/Runtime.java(944110);- a Java class, as in
java.lang.String.md(944130); document.write.js(941180);process.env.d.ts(934100);__proto__(934130);- the PowerShell command
Install-Script, as ininstall-script.sh(932120); - a database's name, as in PostgreSQL's
information_schema.sql(942140).
For such a file, these requests are refused: - the API's list of its commits (
path); - the API's call for the contents of files that names it in its query (body); - the commit graph of that file alone (file); - the hidden lines of its diff (path), which stay hidden; - the sign-in page gitea sends a visitor to on the way to a page of the file that needs signing in, such as its edit page (redirect_to). The visitor can still sign in by opening the sign-in page itself. -
A path: a file name ending in
~, or an.xhtmlfile whose path holds a space. -
A path that holds what the Core Rule Set takes for a Java class (944130), as
java.lang.String.mdandMETA-INF/services/org.apache.commons.logging.LogFactorydo. The same for a path that holds what it takes for Node.js code (934100), asprocess.env.d.tsdoes, and for a path with a directory named...(930100, 930110), such as.../notes.txt. The page of such a file, its raw file, its history, its blame and its edit page are refused. The directory that holds it lists it, and a clone carries it. -
A container image push that reuses a layer of an image docker has pushed to that gitea before, when that image's name trips the list of commands (932260). That is an image whose owner, or whose name after the owner, starts with an entry on the list, such as
owner/python3-app,owner/docker-baseor any image of an owner namedansibleordocker-team. docker asks gitea to take the layer from that image, naming it in the query parameterfrom, which 932260 checks, and gets the403answer. A docker that keeps its images in containerd's image store, as a new install of Docker Engine 29 does, then fails the push, and fails each new try the same way. A docker that keeps them in its own older image store uploads the layer instead. The push then succeeds after one refusal for each layer it reuses, and each of those refusals counts toward the error burst.
-
-
Artifact uploads from
actions/upload-artifact@v3send the headerContent-Range, which the Core Rule Set refuses (920450). The action sends two files at a time, does not retry a403, 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@v4does not send the header. -
An Actions runner that reaches gitea through
smallwebwafsends 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 inSWWAF_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. The request limits do most of the work there.
script/gitea-requests runs gitea behind the image at the defaults. It sends
through it a short sample of gitea's ordinary use and some of the requests the
notes above say are refused. It reports each request the Core Rule Set refuses,
so that those requests can be checked again when gitea or the Core Rule Set
changes version.
Failure behaviour
- Reputation source down or over quota: no verdict, the service continues, one
source_failurealert per cooldown. - Alert destination down: alerts are retried with backoff from a bounded queue, the oldest dropped first, and the drops counted in the metrics.
- App down or restarting:
502fromsmallwebwaf, not counted as a client offence. - Attack detection engine error on a request: the request is forwarded, and the error is logged and counted.
- Every other failure is covered where its part is specified:
- GeoJS and the lookup database under "Lookup of AS number and country";
- the remote log server under "Request log";
- state file writes and edits under "Persistent state";
- rule file edits under "Rule files";
- a process exiting under "The two processes".
- 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 whileSWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESis 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: the header is believed only from a peer insideSWWAF_TRUSTED_PROXIES, and walked from the right. The default trusts every private address, which in the intended deployment means traefik. Where other containers can reachsmallwebwafdirectly, or visitors on a private network reach traefik, they can name another address in the header. SettingSWWAF_TRUSTED_PROXIESto traefik's own network closes that gap. - Many clients behind one address (mobile carriers, offices, Tor): they share
its limits and its bans. The defaults sit well above one person's use,
SWWAF_RATE_LIMIT_EXEMPT_NETSandSWWAF_ALLOW_NETStake known shared addresses out, and the ban notes show an admin what happened before a ban is lifted. - A
banrule that matches a real visitor bans it for seven days, then permanently at its next request. So the default rule file keeps to requests no real visitor sends, anchored at the site root. A visitor banned by mistake is let back in by lifting the ban inbans.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_PERCENTfor that AS number lowers the limits of each of its clients. - Core Rule Set false positives against real apps: 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.
script/gitea-requestschecks the known cases. - Attacks carried in request bodies: not refused by default, for the reason
given under "Attack detection". The rule files, the limits and the bans still
apply to the client, and
SWWAF_WAF_BODY_LIMITswitches body inspection on. - An app that uses one of the query parameters left out of 930120, 932160 and
932260 as a file on the server, or passes it to a shell; or that reads a
cookie named
gitea_flashorredirect_to, or passesRefererto a shell: no setting restores those rules. Such an app has to check those values itself, or refuse what it must never receive in them with a rule file. - 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 token guessing is banned
through the error burst. A token given as a file that only the
smallwebwafuser can read is out of the app's reach. - GeoJS, the default lookup source: every new visitor's address goes to a third
party, and a swarm of fresh addresses 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: bounded by
SWWAF_CLIENT_REQUEST_TIMEOUT,SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTESandSWWAF_CLIENT_IDLE_TIMEOUT. - Alert floods: the cooldown and the hourly cap.
Planned, not built
Everything above is built. These two points are specified as later work, and nothing in the code does them yet:
- Planned: sharing bans between the instances of several apps, by running one CrowdSec engine per host that every instance reports its bans to. It is reconsidered once real ban volumes are known. Until then each instance keeps its own bans, and can read a CrowdSec engine's decisions as described under "Reputation".
- Planned: sending the log over RELP, the reliable syslog transport. It is revisited against the available Go packages. Until then the remote log goes over UDP, TCP or TLS as described under "Request log".