The bounds sentence, the blocklist URL sentence, the deny list sentence, the limit action's scope and the observe mode sentence now say what the code does. Each of the twenty-one requirements the fourth review found twice is stated once, with a link from the other place. The request id header, redirect_uri's case, change 4's reason and the live archive's InRelease files are back. The biased thresholds are defined where they are introduced. The docs files apply the fifth README review's findings and stay identical to the README branch. Model: fable-5-1
67 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. The reference documents
under docs/ give the settings, the formats and the lists. This document links
to them.
Purpose
- It protects one self-hosted app on the open internet from scrapers, scanners and other abusers. It does so 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.
- Packages:
cmd/smallwebwaf: the binary, which only callsinternal/smallwebwaf.internal/smallwebwaf: the process. It reads the settings, the rule files, the lookup database and the state files, listens, and serves untilSIGTERMorSIGINT. Then it stops, writing the state files. Run assmallwebwaf healthcheck, it is the image's health check instead.internal/config: reads the settings, the one place they are read.internal/proxy: what happens to each request. It works out the client and runs the checks incheck. It passes the request to the app with the standard library'shttputil.ReverseProxy, within the timeouts and size limits, and answers its own endpoints inanswerAdmin. Once the request has ended, it counts bytes, refusals and anomalies, and writes the log line.internal/ratelimit: the table of clients. It counts requests, bytes and refusals, and keeps each client's history.internal/bans: the bans. It holds each netblock's bans with their notes, decides how long a new ban lasts, and picks the ban to drop when the table of bans is full.internal/rules: reads the rule files at start and again when they change, and tells which rules a request matches.internal/waf: the Core Rule Set, with the six changes listed under "Attack detection", as Coraza directives.internal/lookup: looks clients up through GeoJS or in the lookup database.internal/reputation: fetches the lists, asks the DNS blocklists and AbuseIPDB in the background, and keeps their answers.internal/anomaly: the anomaly counters and their alerts.internal/alerts: holds back repeats and alerts past the hourly limit, and sends the rest to the webhook, Slack and ntfy, each from a queue of its own.internal/state: reads the state files at start, takes in edits while running, and writes the files when due and at the stop.internal/requestlogandinternal/remotelog: the lines on stdout, and sending them to the syslog server.internal/metrics: the metrics, in the Prometheus text format.Dockerfile: the lint and test phases, then the image.deploy/example-appis an app built on the image, whichscript/example-appchecks.
- 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 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;github.com/maxmind/mmdbwriter, in the tests, to write lookup databases;- 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 checks; step 15 runs for every request. 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 and the error burst at step 15. 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 "Attack detection"). 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.X-Forwarded-Foris added, andX-Request-IDcarries the request's id, generated when traefik did not supply one. WithSWWAF_ADD_LOOKUP_HEADERS,X-Client-ASNandX-Client-Countryare added for the app's own logs; a header whose value is unknown is left out. Whatever that setting says, any such header the client sent is removed. -
After the response, or after the refusal:
- For a request forwarded to the app, the response bytes, the request bytes
or both are added to the byte counters, as
SWWAF_BYTES_COUNTsays. For a WebSocket, what it carried each way counts once it closes. 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 refusals the error burst counts (see "Attack detection") are counted.
- The client's history and the anomaly counters are updated, and the alert thresholds are evaluated.
- For a request forwarded to the app, 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 are bounded bySWWAF_MAX_BANS(see "Bans"). The GeoJS answers, the reputation verdicts and scores, the DNS queries under way and the anomaly counters have fixed bounds, listed under "Fixed limits" indocs/settings.md. Past its bound, the GeoJS answer or anomaly counter used longest ago goes first, and the verdict or score fetched longest ago. Past 1,000 DNS queries under way, a zone is asked at the client's next request.
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. A banned client is not
looked up.
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), with the causeattack. - Any further request from the netblock while that ban lasts shows it is malicious: the ban becomes permanent. No other cause of ban becomes permanent this way.
- 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), with the causelimit. 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, with 403 and
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. A ban lifted while the
decision is in force is made again at the client's next request. To lift it for
good, delete the decision in CrowdSec, then lift the ban once the list has been
fetched again.
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,
drawn from what smallwebwaf already knows. No lookup is made for the notes
alone. docs/state-files.md lists the notes with the rest of a ban's entry.
An admin adds, keeps or lifts a ban by editing bans.json (see "Persistent
state") or through the ban endpoints (see "Admin endpoints"). A ban an admin
makes has the cause admin; an admin who wants to keep a ban smallwebwaf made
sets its cause to admin. A ban lifted before it ends is kept, marked as
lifted, and does not count toward a longer ban; it refuses nothing. Deleting its
entry from bans.json forgets it entirely. An admin whose own address is banned
lifts the ban by editing bans.json, since the endpoints refuse a banned
client's request like any other.
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 whose cause is admin is never dropped
and does not count toward SWWAF_MAX_BANS, so such bans can never fill the
table.
Settings
docs/settings.md lists every setting with its default and the values it takes,
in the groups below. It also gives the form each kind of value takes.
Conventions:
-
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.
No other setting can be switched off with
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. The file's contents are the value, less one newline at the end, and are checked as the value itself would be. Setting both forms stops the start, and so does naming a file that cannot be read.SWWAF_LOG_REMOTE_TLS_CA_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 one log line, marked
"type":"process"with"msg":"invalid setting". The line names the variable and what is wrong with the value; "The two processes" says what happens then. At start the settings in effect are logged on one line; tokens, keys and the paths of webhooks, which often carry a secret, are shown as********.
Core
SWWAF_INSTANCE_NAMEis in every log line, metric and alert, so that instances can be told apart. The default, the container's host name, changes at every redeploy; a name set by the operator survives it.SWWAF_MODE=observelets a configuration be tried before it is enforced (see "What happens to a request").X-Forwarded-Foris believed only fromSWWAF_TRUSTED_PROXIES, by default the private address ranges, so a client that connects directly cannot claim another address. Where other containers reachsmallwebwafdirectly, or visitors on a private network reach traefik, they can name another address in the header. Narrowing the setting to traefik's own network closes that gap.SWWAF_IPV6_GROUP_PREFIXsets the group size. After a change, each IPv6 client starts afresh; existing bans keep refusing their netblocks until they end.SWWAF_MAX_TRACKED_CLIENTSandSWWAF_MAX_BANSset the bounds under "Counting method" and "Bans".
Tokens
SWWAF_ADMIN_TOKENswitches on the ban endpoints and/_smallwebwaf/clients/<ip>. The metrics are off untilSWWAF_METRICS_TOKENis set, since without a token they would be open to anyone on the internet. The two are separate, so a scraper that holds the metrics token 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_DIRnames the directory of the state files (see "State" under "Deployment").bans.jsonis writtenSWWAF_STATE_WRITE_DELAYafter a ban is made, lifted or made permanent, so a burst of changes becomes one write. Every file is written everySWWAF_STATE_COUNTER_INTERVAL, and at an orderly stop.
Logging
- The request log on stdout is always on and has no switch (see "Request log").
SWWAF_LOG_LEVELfilters only the process's own messages, and no message is atdebug.warnanderrorhold back the line of settings at start. SWWAF_LOG_REQUEST_HEADERSnames the request headers to record.Authorization,CookieandSet-Cookievalues are never logged, even if listed, only whether they were present.SWWAF_LOG_REMOTE_URLnames a syslog server (see "Request log").
Metrics
SWWAF_METRICS_TOP_Nis how many of the busiest AS numbers and countries get a series of their own. The rest are summed asother, so the label set stays bounded.
Static lists
SWWAF_ALLOW_NETSskips the checks step 3 names, for monitoring or the owner's own networks.SWWAF_RATE_LIMIT_EXEMPT_NETSbypasses the request and byte limits only; the error burst, attack detection and bans still apply.- 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, and a git clone a handful. A machine that talks to the app all day can
still pass the day limit. Its address belongs in SWWAF_RATE_LIMIT_EXEMPT_NETS.
SWWAF_RATE_LIMIT_EXEMPT_PATHS takes path prefixes out of the count, such as
static assets or health checks; their bytes are not counted either. A prefix is
matched exactly as sent, without the query, and is not percent-decoded:
/assets/ matches /assets/app.js, and does not match /Assets/app.js,
/%61ssets/app.js or /static/assets/app.js. There are no wildcards. A path
holding .., a backslash or an encoded slash (%2F) is never exempt, since the
app may read it as a path outside the prefix. SWWAF_WAF_EXEMPT_PATHS is
matched the same way.
Byte limits, per client
A response's bytes are counted when it ends (see step 15). So every default sits above the largest request and answer together, and no single download breaks a limit.
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, except the part the Core
Rule Set reads while SWWAF_WAF_BODY_LIMIT is set.
SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTESmust 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_TIMEOUTis longer by default 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.
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, or an app that cannot be reached;504for an app that is too slow.
- A request body streams through, so each side can hold up the other. A request
timeout can run out while the body is on its way. The answer is then
408ifsmallwebwafwas waiting for the client, 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.
- Only one source is used. The start stops, naming both settings, when
fileis 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.
- 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.
These addresses are never sent:
- one in
SWWAF_ALLOW_NETSorSWWAF_DENY_NETS; - one under a ban;
- a private, loopback or link-local address, which no source can place.
- one in
- For an IPv6 visitor, GeoJS is asked about the first address of its group.
Addresses are asked about together, up to 200 in one request
(
https://get.geojs.io/v1/ip/geo.json?ip=a,b,c), one request at a time, which keeps the number of requests to GeoJS low. Each answer'scountry_code,asnandorganization_nameare read. An answer without acountry_code, or with theasn64512, counts as unknown. - Each answer is kept for seven days, in memory and in
lookups.json. Answers are kept apart from the table of clients, so that an answer outlasts a client dropped from that table. The answer reaches the client's history and ban notes when it comes. SWWAF_LOOKUP_TIMEOUTis how long a visitor's request waits for its client's first answer, when a setting needs it (see step 5). It also bounds each request to GeoJS. A client whose answer has not come in time counts as unknown until it comes, and its later requests do not wait. Its address is asked about again in the background.- A client the lookup cannot place has an unknown country and AS number, which the country lists and the biased thresholds treat as described there. Nothing else treats it differently.
- 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, new clients count as unknown. GeoJS is then left alone for a second, and twice as long after each further failure, up to five minutes, and onesource_failurealert is sent per cooldown. A service that cannot accept this uses the file. - The file is the free IPinfo Lite database (
ipinfo_lite.mmdb), whoseasn,as_nameandcountry_codefields are read. 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. IPinfo updates it daily. Its licence is Creative Commons Attribution-ShareAlike 4.0, and IPinfo asks for a link to it on the site that uses it, such as "IP address data is powered by IPinfo". - The file is read whole at start; one that is missing or unreadable stops the
start. It is read again once it has gone 2 seconds without a change after
being replaced. One that cannot be read is logged, sent as one
file_erroralert and ignored. The database already loaded stays in use. A client whose address the file does not hold counts as unknown.
Country lists
Both are empty by default: the defaults judge a client by what it does, not by where it comes from.
SWWAF_DENIED_COUNTRIESrefuses every request from a listed country.SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESlets through only clients placed in a listed country. Every request from any other country is refused. So is every request from a client the lookup cannot place, such as internal monitoring or a visitor on a private address; 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 biased thresholds lower the limits of clients in listed AS numbers and countries. 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.
- Clients in a listed AS number or country get that percentage of every request
and byte limit (
SWWAF_ASN_LIMIT_PERCENT,SWWAF_COUNTRY_LIMIT_PERCENT). A lowered limit is rounded down.0is a zero allowance: the client's first request breaks a limit and bans it. SWWAF_ASN_BYTES_PERCENTandSWWAF_COUNTRY_BYTES_PERCENTset the byte limits' percentage alone, when it should differ from the request limits'. For an AS number they list, they replace its percentage fromSWWAF_ASN_LIMIT_PERCENTandSWWAF_ASN_LIMIT_PERCENT_URL; for a country, the one fromSWWAF_COUNTRY_LIMIT_PERCENT.SWWAF_ASN_LIMIT_PERCENT_URLnames a text file ofAS:percentlines, so one abuse-source list can be shared by everysmallwebwafin the fleet. It is fetched and kept like a blocklist (see "Reputation"). Where it andSWWAF_ASN_LIMIT_PERCENTboth give a percentage, the lower applies.- 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>. A client the lookup cannot place getsSWWAF_UNKNOWN_LIMIT_PERCENTin place of an AS number's or a country's. The percentage 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. The log line of each counted request, and the notes of each ban, say which setting lowered the limit. - 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.
-
The Core Rule Set inspects only a request that every earlier check let through, and skips a path under
SWWAF_WAF_EXEMPT_PATHS. An engine error on a request forwards the request, and is logged and counted. -
SWWAF_WAF_PARANOIA_LEVELandSWWAF_WAF_ANOMALY_THRESHOLDare the Core Rule Set's own two tuning values, at its own defaults. Each level up runs more rules, which find more attacks and take more ordinary requests for attacks. Each rule that matches adds its score to the request's, and a rule rated critical adds 5. A request whose score reaches the threshold is a match, so by default one critical rule is enough. With the thresholdoff, nothing is a match, and scores are still logged. A request with more than 1000 query parameters or body fields adds 5 to its score, since Coraza reads only the first 1000. For a match, inblockanddetectmode alike, the log line gives the rule ids and the score, and awaf_blockalert is sent. -
SWWAF_WAF_DISABLED_RULESswitches off rule ids 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 you give replaces the default, so name these six ids in it to keep them off.
-
SWWAF_WAF_BODY_LIMITswitches body inspection on, up to that size. Form data and multipart bodies are read up to the size, and the rest streams on without being held in memory. JSON and XML bodies are read only when 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. A body the Core Rule Set cannot parse adds 5 to the score, and so does a multipart body cut by the limit in the middle of a part's header. The client has untilSWWAF_CLIENT_REQUEST_TIMEOUTruns out to send the part that is read. Body inspection suits apps whose forms carry no code;docs/gitea.mdrecords what it refuses in front of gitea. -
SWWAF_TRAP_PATHSnames paths the app never serves and only scanners ask for, such as/wp-login.phpin front of gitea. This is the short form of apathrule with thebanaction, for deployments that mount no rule files. A trap path is matched whole, without the query, against the path as sent and once percent-decoded:/wp-login.phpmatches/wp-login.php?x=1and/%77p-login.php, and does not match/wp-login.php/or/blog/wp-login.php. -
SWWAF_ERROR_BURST_THRESHOLDcounts a client's refusals per minute for a rule file match, a trap path, a Core Rule Set match, or a missing or wrong token; no other refusal counts. 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.
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. It reads these values as names in a repository or in its own records, or as a page of its own site, never as a command. 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. 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.
The parameter and cookie names in these changes are matched without regard to case, as Coraza matches them.
Ban lengths, response and scope
SWWAF_BAN_RESPONSE is 403 by default, which makes a ban easy to recognise
when debugging; 429 and close are the alternatives. Behind traefik, close
does not leave the client unanswered: traefik answers 502, as it does whenever
its backend drops a connection. SWWAF_BAN_SCOPE_V4_PREFIX widens an IPv4 ban
to the surrounding netblock. The four ban lengths are 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, has terms, below, that forbid frequent fetching, and
Spamhaus may block an address that breaks them. Each smallwebwaf fetches its
own copy, so several apps on one host, sharing one address, would often fetch
too close together.
-
Blocklists (
SWWAF_BLOCKLIST_URLS) are text files of addresses and netblocks, one to a line, as the Spamhaus DROP list is written. Anything after a;or#on a line is ignored. An IPv4-mapped address such as::ffff:192.0.2.0/120is read as the IPv4 netblock it stands for. Spamhaus's terms for DROP, 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.
So
SWWAF_BLOCKLIST_REFRESHis never less than1h, and the last good copy is kept whole, comment lines included, on failure and across restarts. -
Each list is fetched again
SWWAF_BLOCKLIST_REFRESHafter its last try, one list at a time.reputation.jsonkeeps when each was last tried, so a restart does not fetch a list again before its time. A fetch fails when:- the server answers other than
200; - the fetch does not finish within a minute;
- the list is longer than 16 MiB;
- a line is not an address or netblock, or, for the AS number file, not
AS<number>:<percent>.
The log, the alerts, the ban notes and the metrics name each list by its URL, so a list's URL must not hold a secret.
- the server answers other than
-
SWWAF_BLOCKLIST_ACTIONisdenyby default, which refuses every request from a listed address and suits lists of networks that send nothing legitimate, such as DROP.limit:<percent>gives listed clients that percentage of every request and byte limit, and suits a list of addresses shared with ordinary visitors, such as Tor exits.logonly notes the hit in the log line and raises an alert. -
A CrowdSec engine on the host (
SWWAF_CROWDSEC_LAPI_URL) has its decision list fetched every minute, withSWWAF_CROWDSEC_LAPI_KEYin theX-Api-Keyheader. The list is kept like a fetched blocklist, and a client on it is banned (see "Bans"). Only decisions of the typebanon an address or a netblock are used; any other, such as a captcha or a country, is left out. A decision lasts for thedurationit had left when the engine answered. This is how a fleet-wide blocklist can arrive withoutsmallwebwafdepending on CrowdSec. A fetch fails when:- the engine answers other than
200, a redirect included; - the fetch does not finish within a minute;
- the answer is longer than 16 MiB;
- the answer is not a list of decisions.
- the engine answers other than
-
Each DNS blocklist (
SWWAF_DNSBL_ZONES) is asked about a client's own address in the background, the first time the client is seen. No request waits for the answer. The name asked about is the one RFC 5782 gives: the numbers of an IPv4 address reversed, or the 32 hex digits of an IPv6 address reversed. So192.0.2.99is asked about as99.2.0.192.dnsbl.dronebl.org. An answer in127.0.0.0/8lists the client, but one in127.255.255.0/24is Spamhaus refusing the query. A name that does not exist does not list the client. These are failures, after which the zone is not asked again for a minute:- an answer in
127.255.255.0/24; - an answer outside
127.0.0.0/8; - a resolver error;
- no answer within
SWWAF_REPUTATION_TIMEOUT.
DNS blocklists made for mail servers list residential ranges, and would block ordinary visitors, so they should not be used. Spamhaus zones need their keyed query service, given as a zone name that contains the key.
smallwebwafshows the key as********wherever it names the zone. Onlyreputation.jsonkeeps the key.SWWAF_DNSBL_RESOLVERnames a resolver, since public resolvers are refused by several list operators. - an answer in
-
AbuseIPDB (
SWWAF_ABUSEIPDB_KEY) is asked, at its check endpoint, for the abuse confidence score of a client, from 0 to 100. Only a client that has already committed one offence is checked, so the daily budget (SWWAF_ABUSEIPDB_DAILY_BUDGET, under the free tier's 1000 checks a day) is spent on suspects. The check runs in the background at the client's next request that no ban refuses, so no request waits; the score applies to the client's later requests. For an IPv6 client, AbuseIPDB is asked about the address of the request that set off the check, and the score is used for the whole group. A score at or overSWWAF_ABUSEIPDB_MIN_SCOREis a hit. The budget is counted in UTC days and kept inreputation.json, so a restart does not make it whole again. The check that spends the last of it is logged and alerted, and no client is checked until the day ends. The key is sent in theKeyheader and nowhere else, and is never logged. A check fails, after which no client is checked for a minute, when:- AbuseIPDB answers other than
200; - the answer gives no score;
- no answer comes within
SWWAF_REPUTATION_TIMEOUT.
- AbuseIPDB answers other than
-
SWWAF_REPUTATION_ACTION, for DNS blocklist and AbuseIPDB hits, islimit:25by default. Such verdicts are less certain than a blocklist, so a listed client gets a quarter of every request and byte limit rather than a refusal. -
Verdicts and scores are kept for
SWWAF_REPUTATION_CACHE_TTL, in memory and inreputation.json, with the fixed bounds under "Fixed limits" indocs/settings.md. A source that is down or over quota gives no verdict. The service continues, with onesource_failurealert per cooldown.
Alerting
- Alerts go to a webhook as JSON, and to Slack and ntfy as formatted messages.
docs/alerts.mdlists the events, the JSON object and the messages.SWWAF_ALERT_EVENTSchooses which of the seven event types are sent; the hourlysummaryis sent whatever it says. The bans an admin makes raise no alert. Inobservemode, a request that would have made a ban still raises the ban's alert, withmodeset toobservein itsdetail. - A repeat is an alert with the same event, and the same netblock, source, file
or anomaly scope, as one sent less than
SWWAF_ALERT_COOLDOWNbefore. An anomaly scope is the thing counted: one client, netblock, AS number or named netblock, or the whole service. A repeat is held back and counted, and the next such alert sent gives the count. - Past
SWWAF_ALERT_MAX_PER_HOURalerts in an hour, the rest are held back and counted by event, so a wide attack cannot flood the channel. Once the hour ends, one alert with the eventsummarygives those counts. It also counts the repeats that a cooldown held back and that no later alert reported. - Each destination has a bounded queue (see "Fixed limits" in
docs/settings.md), sent one at a time, so a slow destination holds up nothing. When a queue is full, its oldest alert is dropped and counted in the metrics. A2xxanswer takes an alert. A4xxother than408and429refuses it, and it is dropped. Anything else is a failure: the alert is sent again a second later, and twice as long after each further failure, up to a minute.alerts.jsonkeeps the queues and cooldowns across a restart.
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 reveal 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.
- Requests and bytes are counted per minute and per hour, for each of:
- a client;
- the surrounding netblock;
- an AS number;
- the whole service;
- a named netblock in
SWWAF_WATCH_NETS.
- Every request but the health check is counted, whatever was done with it,
clients in
SWWAF_ALLOW_NETSandSWWAF_RATE_LIMIT_EXEMPT_NETSincluded, since an exempt client misbehaving is worth knowing about. The counters are kept in memory and inalerts.json, with a fixed bound.
Rule files
smallwebwaf reads every *.rules file in SWWAF_RULES_DIR and checks each
request against them. docs/rules.md gives the format, the targets and actions,
and the default file.
-
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.
smallwebwafwatches the directory: a file edited, added or removed there takes effect while it runs. It reads the files again once the directory has had no change for 2 seconds, so that a file still being written is read whole. A file whose name starts with., such as an editor's lock file, is not a rule file. -
Files are read in name order (
00-default.rulesbefore50-gitea.rules), rules in line order. -
A rule's regex is Go's RE2 syntax. 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.
-
pathanduriare matched both as received and once percent-decoded, so an encoded probe cannot slip past. A header sent more than once is matched with its values joined by,; a header not sent is empty text.HostandTransfer-Encodingcannot be targets, since Go's HTTP server takes them out of every request; a rule naming one stops the start like any bad line. Request bodies are not available to rule files; reading them is the Core Rule Set's job. -
logonly notes the match in the request log.blockrefuses the request without a ban;banmakes the request a clear sign of attack (see "Bans"), and is for requests no real visitor sends. A visitor banned by mistake is let back in by lifting the ban. -
At start, these stop the process with a message naming the file and line:
- a line that does not parse;
- a regex that does not compile;
- a duplicate id.
So does a
SWWAF_RULES_DIRthat does not exist or cannot be read. 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. -
The image ships a default file, kept short and limited to patterns that are wrong for every app, with its path rules anchored at the site root. In front of gitea,
/.envis a probe, while/<owner>/<repo>/src/branch/main/.env.exampleis a file in a repository that any visitor may open.banrules of your own follow the same rule. 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. A directory mounted overSWWAF_RULES_DIRreplaces the default file; single files mounted into it add to it. -
Rules meant to be edited while
smallwebwafruns belong in a mounted directory, not a mounted file. Many editors save a file by replacing it, and a container keeps seeing the old one when the file alone is mounted. The lookup database is mounted the same way, for the same reason. To run without rule files, mount an empty directory or setSWWAF_RULES_ENABLED=false.
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 remote log is the exception; see
"Request log"). 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.
docs/state-files.md lists the files, what each holds, and their sizes.
-
Each file holds one kind of state, as 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.
- a top-level
-
bans.json,reputation.jsonandalerts.jsonare indented, so that a person can openbans.jsonin an editor, find an address, and remove or add an entry.clients.jsonandlookups.jsonput each entry on one line, which halves their size and letsgrepshow everything about one client. -
Clients that were never banned are kept too, so every client's history survives a restart.
-
Each file has a bound, so it can be rewritten whole. Writing one takes well under a second, in the background, so rewriting whole files needs nothing cleverer.
-
A write serialises from a snapshot taken under the lock, so requests are not held up. It goes to a temporary file in the same directory, which is synced and renamed over the real name, and the directory is synced. A crash at any point leaves either the old complete file or the new complete file, never a partial one. Before writing a file,
smallwebwaftakes in any edit made to it on disk, so an admin's edit is never overwritten. -
At start, the files are read back. Active bans go into an in-memory lookup by netblock, everything else into maps. Expired cache entries are discarded. A missing file means empty state, and is normal on first run. These stop the process with a message naming the file and position:
- a file that does not parse, or has an unknown
version; - an entry without a field it needs;
- a ban whose cause is not one of the four;
- a ban whose
expiresis not after itsstart.
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.
- a file that does not parse, or has an unknown
-
While running,
smallwebwafnotices 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 read or wrote. An edit that parses is taken in at once: what the file says replaces whatsmallwebwafheld for that file. An edit tobans.jsonmakes, lifts or forgets bans as "Bans" describes. A changesmallwebwafmade after the admin opened the file, such as a new ban, is lost when the admin saves over it. Most editors warn when a file changed on disk while it was open. A file that is removed is written again at its next write. -
An edit that does not parse never stops the running
smallwebwaf. It keeps the state it has and sets the edited file aside as<name>.bad, so the edit is kept. It writes the file again from memory, and names the file and the position of the error in the log and in onefile_erroralert. The admin mends the.badfile and moves it back. -
A write that fails leaves the state correct in memory. It is logged, counted in the metrics and sent as one
file_erroralert per cooldown, and retried at the next write. -
A hard kill loses the changes since the last write: ban changes from the last
SWWAF_STATE_WRITE_DELAY, everything else from the lastSWWAF_STATE_COUNTER_INTERVAL. 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, and no header but those listed there. 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 (start, the settings, stop, errors) 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 as RFC
5424 syslog records. So a deployment can stop depending on docker's log handling
while docker logs keeps working.
- Over UDP each record is one datagram; a line too long for one datagram is dropped alone. Over TCP and TLS each record follows its length in bytes and a space. UDP gives no delivery signal at all and is offered only for compatibility.
- The record carries the facility from
SWWAF_LOG_REMOTE_FACILITY, the severity informational, the time, the host's name and the app name fromSWWAF_LOG_REMOTE_APP_NAME. - Sending happens on its own goroutine from a buffer of
SWWAF_LOG_REMOTE_BUFFERlines, so a server that is slow or down never holds up a request and never stops stdout. When the buffer is full, the oldest line is dropped and counted in the metrics. A failed connection is tried again a second later, and twice as long after each further failure, up to a minute. - As
smallwebwafstops, it spends at most two seconds sending the lines still waiting, and drops the rest; stdout has carried them.
Metrics endpoint
GET /_smallwebwaf/metrics answers in the Prometheus text format, for a request
carrying SWWAF_METRICS_TOKEN. docs/metrics.md lists the metrics. Every
metric carries the label instance, set to SWWAF_INSTANCE_NAME. 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. docs/admin-endpoints.md lists the
endpoints, what each takes and what each answers.
GET /_smallwebwaf/healthzanswers200withokto 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.- Every other endpoint needs its token, 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 ban made through the endpoints starts at once, with the cause
admin.
Deployment
smallwebwaf runs inside the app's own container: the app's Dockerfile builds
FROM the smallwebwaf image. Each app is one container. An app is hardened by
changing its base image. It can add packages from nixpkgs. docs/image.md lists
what the image holds and how each part is pinned.
- The image is Ubuntu's newest long-term support release, pinned by digest, and
moves to the next LTS release when that ships. Its packages come from Ubuntu's
snapshot service, the archive as it was at the moment the Dockerfile names,
rather than the live archive, whose packages change with every update. The
snapshot is never older than the pinned Ubuntu image, since older packages can
need older versions of packages the image holds, and it moves forward with the
image's digest. The Dockerfile names the hash of each of the snapshot's
InReleasefiles, which cover every package list and so every package, and the build checks them before installing. The live archive'sInReleasefiles, whichapt-get updatealso fetches and the install does not use, are left out of the check. The hashes are the amd64 archive's, so the image is built for amd64 only. - The Ubuntu image has no CA certificates. Without them Nix cannot download
packages and
smallwebwafcannot reach GeoJS, soca-certificatesis installed by name. The snapshot itself is fetched over HTTPS with the CA certificates of the Go image that buildssmallwebwaf, itself pinned by digest. - nixpkgs comes from a release file on
releases.nixos.org, named with its hash, not from a GitHub archive, whose bytes can change. Root uses Nix directly; no Nix daemon runs, and sincenix-bincreates no build users,build-users-group =in/etc/nix/nix.conflets root build without them. Root's Nix profile is last on thePATH, so that no package hides the image's own commands; busybox'ssv, for one, looks for services elsewhere. An app built on the image gets the same packages each time. Security fixes from Ubuntu and nixpkgs come with a newersmallwebwafimage, which an app takes by changing the digest in itsFROMline. The image moves to each new nixpkgs release, every six months. - runit supervises the two processes, with
runsvinitas the entrypoint, as the owner's code style guide asks for service containers. No package carriesrunsvinit, so the image builds it from its archived source, unchanged since 2015, at a fixed commit hash, with ago.modthe build writes.runsvinitstarts runit'srunsvdir, which runs therunscript of each directory under/etc/service. Ubuntu'ssvlooks for services there too. - The image's
runscript forsmallwebwafwaits one second and makesSWWAF_STATE_DIRand every file in it belong to thesmallwebwafuser (uid and gid 65532). Then it startssmallwebwafas that user with runit'schpst.
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 runs itsrunscript again, which waits a second before starting it. 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 therefore 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, a directory of its own
beside the app's data directories, belonging to the smallwebwaf user. 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. A host directory mounted there needs no
change of owner, and files an earlier owner left in it can be read and replaced.
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.
"Settings given as files" in docs/settings.md shows the commands.
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
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".