Every requirement the second review found in two to five places is now stated once, with links from the other places. "Risks the design has to handle", "Rollout per service" and most of "Architecture" are gone, their two points of their own moved to "Attack detection" and "Anomaly thresholds". The request log's fields, the metrics and the image's contents move to docs/request-log.md, docs/metrics.md and docs/image.md, each linked from the spec. The off rule names SWWAF_LOOKUP_SOURCE and SWWAF_WAF_MODE, the reputation bound is given, and the six Core Rule Set changes are a numbered list. Model: fable-5-1
65 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, each requirement once. Everything in it is built,
except the last section, "Planned, not built". README.md says how to run it.
EVALUATION.md says why no existing tool was adopted. docs/ holds the lists
this document links to, and docs/gitea.md records how smallwebwaf behaves in
front of gitea.
Purpose
- It protects one self-hosted app on the open internet from scrapers, scanners and other abusers, from the app's first request, with no setting at all. Every setting has a default chosen for a service facing the internet in 2026. A setting that needs an alert destination, an account key or a token stays off until the operator supplies it.
- 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, lower its limits for listed AS numbers and countries, consult reputation sources, and send alerts.
- Everything is set by environment variables whose names start with
SWWAF_, sincesmallwebwafshares its container, and so its environment, with the app. 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.
Architecture
- One statically linked Go binary. It writes only to its state directory. It ships in the image that is the base of the app's own image (see "Deployment").
- 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 (
httputil.ReverseProxy), 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 it, and the defaultSWWAF_WAF_DISABLED_RULES, are built for that version. They are checked again before the version changes, since rule ids, and what each rule matches, change between versions;github.com/hashicorp/golang-lru/v2for the bounded tables;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 (
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. Each step names the section that holds its rules.
-
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, and the first address outside those netblocks is the client. If every address is inside them, the leftmost is, 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 steps 4 to 12 and the byte limits. It is still counted for the anomaly thresholds, and still needs a token at step 13. A client inSWWAF_DENY_NETSis refused; a client in both lists is let through. -
Bans. A client under a ban is refused (see "Bans").
-
The lookup, unless
SWWAF_LOOKUP_SOURCEisoff(see "Lookup of AS number and country"). The request goes on at once. It waits for the first GeoJS answer, up toSWWAF_LOOKUP_TIMEOUT, only when one of these needs it:- a country list;
- a biased threshold;
SWWAF_ADD_LOOKUP_HEADERS.
A request that ends before the answer comes is logged without it.
-
The country lists (see "Country lists").
-
The reputation sources (see "Reputation"). A client on a fetched blocklist gets
SWWAF_BLOCKLIST_ACTION. A client on the CrowdSec decision list is banned. A client listed by a DNS blocklist, or scored at leastSWWAF_ABUSEIPDB_MIN_SCOREby AbuseIPDB, getsSWWAF_REPUTATION_ACTION. With no cached verdict, a query is queued in the background and the request goes on, so a first request is never delayed by a reputation query. -
The client's limit percentage (see "Biased thresholds").
-
Rate limiting (see "Request rate limits, per client"). A request over a limit times the percentage is refused and bans the client.
-
The trap paths (see
SWWAF_TRAP_PATHS). A request for one is refused and bans the client. -
The rule files (see "Rule files"). Matching stops at the first rule that refuses.
-
The Core Rule Set (see "Attack detection"). A match is refused with
403whileSWWAF_WAF_MODEisblock, and only logged and alerted while it isdetect. -
A request under
/_smallwebwaf/is answered here (see "Admin endpoints"). -
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 bytes or both are added to the byte
counters, as
SWWAF_BYTES_COUNTsays. 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 for the error burst (see
SWWAF_ERROR_BURST_THRESHOLD). - The client's history and the anomaly counters are updated, and the alert thresholds are evaluated.
- The response bytes, the request bytes or both are added to the byte
counters, as
In SWWAF_MODE=observe, every refusal above is logged as what would have
happened, nothing is refused, and no ban is made. The size and time limits and
the token check still apply.
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 and avoids the burst at bucket boundaries that a single fixed bucket allows.
- Counters are read and updated in memory. They are written to
clients.jsonand loaded again at start (see "Persistent state"), so a restart does not hand every client a fresh allowance. Buckets whose time has passed are discarded on load. - Memory is bounded. When the table of clients holds
SWWAF_MAX_TRACKED_CLIENTS, the least recently seen client is dropped first, with its history. The bans, the GeoJS answers, the reputation verdicts and the anomaly counters have bounds of their own, given where each is described.
Bans
Until a ban ends, every request from its netblock is refused with
SWWAF_BAN_RESPONSE. The netblock is the client: its IPv4 address, widened to
SWWAF_BAN_SCOPE_V4_PREFIX, or its IPv6 group. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did
nothing. Requests refused under a ban are not counted against limits. They are
counted in the client's history and in the ban's notes.
An offence is a request smallwebwaf holds against the client. It is one of:
- a clear sign of attack;
- a broken limit;
- a refusal 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. The first 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.
A client on the CrowdSec decision list is banned until the decision ends, with
the cause crowdsec. Only a client that sends a request is banned, so a long
list does not fill bans.json. Such a ban is never made permanent. It does not
make the netblock's next ban for a broken limit longer.
These refuse each request they cover, record nothing in bans.json, and count
no offence:
SWWAF_DENY_NETS;- a blocklist or reputation hit whose action is
deny; - the country lists.
Every ban carries notes with what an admin needs to decide whether to lift it.
An admin adds or lifts a ban by editing bans.json (see "Persistent state"), 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.
A permanent ban does not run out. It ends when an admin lifts it, or when
smallwebwaf drops it from a full table, as below. When smallwebwaf holds
SWWAF_MAX_BANS bans of its own, it drops the earliest ban of one netblock: the
netblock that has gone longest without a request. A past, active or permanent
ban is dropped alike. A scanner whose ban was dropped that way is refused and
banned again by its next probe. A ban an admin made, whose cause is admin, is
never dropped and does not count toward SWWAF_MAX_BANS, so such bans can never
fill the table. An admin who wants to keep a ban smallwebwaf made sets its
cause to admin.
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. -
These settings take
off, which switches them off:SWWAF_LOOKUP_SOURCEandSWWAF_WAF_MODE;- the five timeouts under "Size and time limits";
SWWAF_REQUEST_MAX_BYTESandSWWAF_RESPONSE_MAX_BYTES;- the three rate limits and the three byte limits;
SWWAF_WAF_ANOMALY_THRESHOLD,SWWAF_WAF_BODY_LIMITandSWWAF_ERROR_BURST_THRESHOLD;SWWAF_ALERT_COOLDOWNandSWWAF_ALERT_MAX_PER_HOUR;- every anomaly threshold.
Every other setting refuses
off. -
A list set to an empty value is an empty list, and replaces the default.
-
Any setting can be read from a file instead, for secrets and long lists:
SWWAF_ADMIN_TOKEN_FILEnames the file that holdsSWWAF_ADMIN_TOKEN.SWWAF_LOG_REMOTE_TLS_CA_FILEalready holds a file name, so it 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.SWWAF_LISTEN_ADDR(default:8080): the one listener, for the requestssmallwebwafforwards and for its own endpoints. The address part stays empty (see "Ports").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 to a name that survives a redeploy and tells instances apart, such asfsn1app1/gitea.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. Where other containers reachsmallwebwafdirectly, or visitors on a private network reach traefik, they can name another address in the header; this setting, narrowed to traefik's own network, closes that gap.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 (see "Bans").
Tokens
SWWAF_ADMIN_TOKEN: the bearer token for the ban endpoints and/_smallwebwaf/clients/<ip>. Unset by default, which switches them off.SWWAF_METRICS_TOKEN: the bearer token a scraper sends for/_smallwebwaf/metrics. Unset by default, which switches the metrics off, since they would otherwise be open to anyone on the internet. It is separate fromSWWAF_ADMIN_TOKEN, so a scraper that holds it cannot manage bans.- A token is a long random value, since the endpoints can be reached from the
internet. One shorter than 32 characters stops the start. The app starts with
the same environment variables as
smallwebwaf, so it can read a token set as an environment variable. The file form keeps a token out of the app's reach (see "Tokens" under "Deployment").
State files
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.SWWAF_STATE_WRITE_DELAY(default10s): how longbans.jsonwaits to be written after a ban is made, lifted or made permanent, 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_TOP_N(default50): how many AS numbers and countries get series of their own. The rest are summed asother, so the label set stays bounded.
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.
Many clients share one address (mobile carriers, offices, Tor), and so share its limits and its bans. The defaults sit well above one person's use, and the first two lists take known shared addresses out.
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 between smallwebwaf
and the app, since a slow client and a slow app are separate problems. There is
one size limit for request bodies and one for response bodies. Bodies pass
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. It must be more than4K. 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.
What the client gets when a limit is passed:
- Go's HTTP server reads a request's line and headers before
smallwebwafsees the request. It answers431to headers overSWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, and a client too slow to send them gets an error answer, mostly400, or none. Either way the server closes the connection, nothing reaches the app, and the request log gets no line. - A request that announces a body larger than its limit is refused before anything reaches the app.
- A limit passed after the headers have been read, but before the response has
started, is answered by
smallwebwaf: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 body streams through, so each side can hold up the other. A request
timeout that runs out while the body is still on its way answers
408ifsmallwebwafwas waiting for the client at that moment, and504if it was waiting for the app. - Once the response has started it can only be cut off, and the connection is closed.
- 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. A local database file is the alternative for a service that keeps its visitors' addresses on its own host.
SWWAF_LOOKUP_SOURCE(defaultgeojs):geojs,fileoroff. Only one source is used. The start stops, naming both settings, whenfileis set withoutSWWAF_LOOKUP_DB_PATH, orSWWAF_LOOKUP_DB_PATHwith any other source. The start also stops when the source isoffand one of these needs lookups:- a country list;
- a biased threshold;
SWWAF_ADD_LOOKUP_HEADERS;- an anomaly threshold per AS number.
SWWAF_LOOKUP_DB_PATH: the database thefilesource reads: the IPinfo Lite database (ipinfo_lite.mmdb), whoseasn,as_nameandcountry_codefields are read. The operator downloads it with a free IPinfo account, mounts the directory that holds it read-only (see "Rule files" for why), and refreshes it.smallwebwafnever fetches it. A file that is missing or unreadable at start stops the start. A replaced file is read again; a replacement that cannot be read is logged, sent as onefile_erroralert, and ignored, the file in use staying in use.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), one request at a time, and reads each answer'scountry_code,asnandorganization_name. An answer without acountry_code, or with theasn64512, counts as unknown. GeoJS is told the address of every new visitor, whether or not a setting uses the answer, since the request log, the metrics and the ban notes carry it. Private, loopback and link-local addresses, which no source can place, are never sent.- A client the lookup cannot place has an unknown country and AS number. The
exclusive country list refuses it, and the biased thresholds give it
SWWAF_UNKNOWN_LIMIT_PERCENT. Nothing else treats it differently. - Each GeoJS answer is kept for 7 days, in memory and in
lookups.json, separately from the table of clients, so that it outlasts a client dropped from that table. Up to 100,000 answers are kept, about 15 MiB; past that, the answer used longest ago goes first. SWWAF_LOOKUP_TIMEOUT(default1s): how long a visitor's request waits for its client's first answer when a setting needs it. It is also how long a request to GeoJS may take. A client whose answer has not come in time counts as unknown until it comes, and its later requests do not wait.- 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. Kept answers
and batched requests keep the number of requests low. While GeoJS is slow,
down or refusing
smallwebwaf, new clients count as unknown,smallwebwafkeeps asking with backoff, and onesource_failurealert is sent per cooldown. A service that cannot accept this, or that every new visitor's address goes to a third party, uses the file. 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 (see "Lookup of AS number and country"), such as internal monitoring; those that should reach the app go inSWWAF_ALLOW_NETS. A list that let unplaced clients through would let every new client in whenever GeoJS stops answering. The cost: while GeoJS fails, this list refuses every new client. No other failure of GeoJS, a reputation source or an alert destination comes this close to taking the service down.- 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. - A refused request is answered with
SWWAF_BAN_RESPONSE, as soon as the client's address has been looked up, before its body is read. It skips the reputation checks, the limits, the rule files and the Core Rule Set. It is logged with the actioncountry_denied, and counted in the metrics.
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.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.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 a client 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.- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's
limit:<percent>. It is 100 if none applies. Clients given a lower percentage are banned after fewer requests, first for a while and then, for repeated abuse, permanently. - 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.
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, such as issue text and
files saved in the web editor. The Core Rule Set takes the shell commands and
code in 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 (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. Each rule that matches adds its score to the request's. A request whose score reaches the threshold is a match. With the thresholdoff, nothing is a match, and scores are still logged. -
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 (see "Rule files"). A list given replaces the default, so name these six ids in it to keep them off.
-
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.docs/gitea.mdrecords what it refuses in front of gitea. -
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, registry and git clients get404and401by design, hundreds of times a minute. -
A Core Rule Set engine error on a request forwards the request, and is logged and counted.
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-toandsub_path;ref,sha,branchandworkflow;artifactNameandredirect_to.
gitea puts file paths, branch and workflow names, and the page to return to after signing in into them. Any name on those lists can be an ordinary file or branch, such as
.gitignoreordocker-build. In these parameters, anything only these three rules catch reaches the app, commands such as|cat /etc/passwdincluded. Path traversal, SQL and script injection, and PHP, Java and Node.js code are still refused there. Every other parameter keeps all three rules. Parameter names 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, must check the value itself. A rule file can refuse it instead. -
Two cookies are left out, and
Refererkeeps fewer rules:- The Core Rule Set reads the request without the
gitea_flashandredirect_tocookies. gitea puts a message naming what was just done intogitea_flash. It puts the page to return to after signing in intoredirect_to. The Core Rule Set takes file names in them, such aspackage.json, for attacks. A browser sends such a cookie with every request until gitea replaces it. gitea cannot replace it while those requests are refused, so one refusal would keep the browser out of the whole site. gitea shows the message with any script removed and returns only to its own pages. Every other cookie is read in full. An app that reads a cookie of either name must check it itself. Refereris not checked for a Unix command given without arguments (932340) or for Java starting a process (944110).Referernames the page a request comes from, on everything that page loads and on every link followed from it. The Core Rule Set takes some page addresses for attacks, such as a search forenv, or OpenJDK'sRuntime.java, whose path holds bothruntimeandjava.. All of those requests would be refused.Refererkeeps the other rules. An app that passesRefererto a shell must check it itself.
- The Core Rule Set reads the request without the
-
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.
Ban response and scope
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.SWWAF_ATTACK_BAN_DURATION,SWWAF_LIMIT_BAN_DURATION,SWWAF_LIMIT_BAN_REPEAT_WINDOWandSWWAF_MAX_BAN_DURATION: the ban lengths, with their defaults, under "Bans".
Reputation
No source is on by default. A source acts on a list kept elsewhere; the defaults
judge a client only by what it does here. AbuseIPDB and CrowdSec also need an
account or an engine of the operator's own. The list best suited to be on by
default, Spamhaus DROP, must not be fetched more than once an hour, and Spamhaus
may block an address that does. Each smallwebwaf fetches its own copy, so
several apps on one host, sharing one address, would often fetch 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.
SWWAF_BLOCKLIST_REFRESH(default24h, never less than1h): how often each list is fetched again. 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. 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. It is kept like a fetched blocklist, and a client on it is banned (see "Bans"). This is how a fleet-wide blocklist can arrive withoutsmallwebwafdepending on CrowdSec.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.SWWAF_REPUTATION_CACHE_TTL(default24h) andSWWAF_REPUTATION_TIMEOUT(default2s).- Up to 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept. Past that, the one fetched longest ago goes first.
- A source that is down or over quota gives no verdict. The service continues,
with one
source_failurealert per cooldown.
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 one of:- a rule file or state file edited while running that does not parse;
- a replacement lookup database that cannot be read;
- a state file that cannot be written.
SWWAF_ALERT_COOLDOWN(default15m): the same event is not repeated within this time for the same netblock, source or file, or, for an anomaly, for the same thing counted: one client, netblock, AS number or named netblock, or the whole service. 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.- A destination that is down is retried with backoff from a bounded queue, the oldest alert dropped first, and the drops counted in the metrics.
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. They are what reveals a widely distributed scraper,
thousands of addresses at low rates each, which the per-client limits do not
see; a low SWWAF_ASN_LIMIT_PERCENT for its AS number is the response.
- 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. At most 20,000 counters are kept, in memory and inalerts.json.
Rule files
smallwebwaf reads every *.rules file in SWWAF_RULES_DIR and checks each
request against them, unless the client is in SWWAF_ALLOW_NETS. The files are
plain text meant to be edited by hand, so that a new scanning pattern seen in
the request log becomes 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, without a ban (see "Bans").ban: the request is a clear sign of attack (see "Bans"). 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. A visitor banned by mistake is let back in by lifting the ban.
-
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. So does a
SWWAF_RULES_DIRthat does not exist. While running, the same faults leave the rules as they were, and the log and onefile_erroralert name the file and line. Once the file is fixed, it is read again. 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. Many editors save a file by replacing it, and a container keeps seeing the old one when the file alone is mounted. So rules meant to be edited whilesmallwebwafruns belong in a mounted directory. The lookup database is mounted the same way, for the same reason.
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. 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 may open. Anything app-specific, such as a rule banning WordPress
probes in front of gitea, belongs in a file the app's Dockerfile adds or the
deployer mounts.
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.
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 and expiry (
nullfor permanent); - the cause:
attack,limit,adminorcrowdsec; - a short reason;
- when it was lifted, if an admin lifted it;
- the notes below.
- the netblock, start and expiry (
-
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. -
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;
- 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 thing counted, when it was last sent, and the count of repeats held back since;
- the alerts sent this hour;
- 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 and the count reached;
- the client's limit percentage, with the setting that gave it;
- the 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;
- 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.
Each file is rewritten whole, so each has a bound. clients.json takes about 1
KiB per client, about 20 MiB at the default SWWAF_MAX_TRACKED_CLIENTS. Written
every 15 minutes, that is under 2 GiB of disk writes a day. bans.json takes
about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at the default
SWWAF_MAX_BANS. With a full file, a hundred new bans a day come to about 600
MiB of writes. lookups.json takes about 150 bytes per answer, under 1.5 GiB of
writes a day when full. The other two files are usually a few MiB or less.
Writing a file of these sizes takes well under a second, in the background, so
rewriting whole files needs nothing cleverer.
Format, so that a person can open bans.json in an editor, find an address, and
remove or add an entry:
- indented JSON with a top-level
versionnumber; - entries sorted by client address;
- times in RFC 3339 UTC;
- durations and sizes as plain numbers, with the unit in the field name;
clients.jsonandlookups.jsonput each entry on one line instead. That halves their size and letsgrepshow 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;
- changes that only update the counts in a ban's notes wait for the write every
SWWAF_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. SWWAF_STATE_DIRnot writable at start: the process exits.
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, an admin's own included; 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.
A write that fails while running:
- state stays correct in memory;
- the failure is logged and counted in the metrics;
- one
file_erroralert is sent per cooldown; - the write is retried at the next write.
What a hard kill can lose:
- ban changes from the last
SWWAF_STATE_WRITE_DELAY(10 seconds); - everything else from the last
SWWAF_STATE_COUNTER_INTERVAL(15 minutes):- counts and history, and 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
Every request gets one JSON object on one line on stdout, refused ones included,
apart from those Go's HTTP server ends before smallwebwaf sees them (see "Size
and time limits"). docs/request-log.md lists the fields. 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".
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:
- 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
GET /_smallwebwaf/metrics answers in the Prometheus text format, for a request
carrying SWWAF_METRICS_TOKEN. docs/metrics.md lists the 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
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.GET /_smallwebwaf/clients/<ip>: needsSWWAF_ADMIN_TOKEN. It answers with the client's current counters, its history (with 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>(see "Tokens" under "Settings"). 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.
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. An app is
hardened by changing its base image. It can add packages from nixpkgs.
The image holds:
- Ubuntu 26.04 LTS, with
ca-certificates,nix-binandrunitfrom Ubuntu; - nixpkgs, fixed at one release;
runsvinitas the entrypoint;- the
smallwebwafbinary, a user of its own (uid and gid 65532), and its runit service; - its state and rules directories;
EXPOSE 8080and the health check below.
Every input to the image is pinned by digest or hash, so that an app built on it
gets the same packages each time. docs/image.md says how each part is pinned,
and why.
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 whereSWWAF_UPSTREAM_URLpoints (see "Ports"). The app runs as a user of its own, which 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.
"Getting started" in README.md shows such a Dockerfile and run script.
The two processes
runsvinit,runsvdirand each service'srunsvrun as root. Eachrunscript starts as root and hands over to its program's user withchpst. Both services start with the container's environment variables and write to the container's output, whichdocker logsshows.- When
smallwebwafor the app exits, runit starts it again a second later. The other process keeps running, and the container does not stop. Whilesmallwebwafis down, traefik answers502. While the app is down,smallwebwafanswers502, which is not a client offence. - Only four things stop
smallwebwaf, all at start:- invalid configuration;
- a lookup database that is missing or unreadable;
- a rule file or state file that does not parse;
- an unwritable
SWWAF_STATE_DIR.
- A
smallwebwafthat stops at start is started again every second, logging its message each time, until the cause is fixed; the health check fails meanwhile. Once running, a failing GeoJS, reputation source or alert destination, or a broken edit, never takes the protected service down. 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. An app that exits at once onSIGTERMends those requests: the client gets502, or a response cut short. To let them finish, the app must keep answering for five seconds afterSIGTERM.- 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 SWWAF_UPSTREAM_URL. So
the container 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 no port but the one in SWWAF_LISTEN_ADDR, 8080 by
default, which traefik routes to. Its own endpoints are on that listener, and it
forwards every other request to the app at SWWAF_UPSTREAM_URL,
127.0.0.1:8081 by default. Another port may be set: the health check follows
the setting, traefik's port label
(traefik.http.services.<name>.loadbalancer.server.port) must name it, and the
app must leave it free. The address part of SWWAF_LISTEN_ADDR stays empty
(:9000, never 127.0.0.1:9000), so that traefik reaches smallwebwaf on the
container's address and the health check on 127.0.0.1. The app listens on that
loopback address only. 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 SWWAF_STATE_DIR, beside the app's own
data directories. Without a volume there, the files 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 makes it and every file in it belong to the smallwebwaf user,
so a host directory mounted there needs no change of owner. The volume is
separate from the app's, holds a few tens of MiB at most with the defaults, and
needs no backup beyond the host's own. 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.
upaas
An app deployed by upaas takes this shape with no change to upaas. upaas builds
the app's Dockerfile and runs the one container as it runs any app. The traefik
labels route to port 8080, and any SWWAF_ settings go with the app's
environment variables. The volume for /var/lib/smallwebwaf and the directory
of any token file go beside the app's own volumes. gitea is built on the image
like any other app; docs/gitea.md records what the defaults do in front of it,
and script/gitea-requests checks those notes.
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".