# smallwebwaf specification `smallwebwaf` is a web application firewall for self-hosted apps. It is one binary, `sww`, and two of its commands do the work: - `sww rproxy` is a reverse proxy between traefik and one app. - `sww discriminator` decides what happens to each request, for many sites. This document says what it must do and why, each requirement once. Everything in it is built, except what the last section lists. `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 self-hosted apps on the open internet from scrapers, scanners and other abusers. It does so from an app's first request. `rproxy` requires only the address of `discriminator`. In a container of its own it also needs the app's address. Every other 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 keeps a site answering while the part that decides is down, slow or restarting (see "Without an answer from `discriminator`"). - 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. The names of `discriminator`'s start with `SWW_`, and those of `rproxy` with `SWRP_`. The only other inputs are `discriminator`'s files: - the rule files; - the state files; - a lookup database; - a `SWW_` setting given as a file; - the file `SWW_LOG_REMOTE_TLS_CA_FILE` names; - the file `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names. ## Non-goals - TLS, certificates and routing by host name. Those are traefik's job. - More than one app behind one `rproxy`. Each app has its own. - Browser challenges such as captchas or proof of work. If wanted, chain Anubis between `rproxy` and 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. Only `discriminator` writes to disk, and only to its state directory. It ships in one image, which runs either command (see "Deployment"). - No database of any kind. Nothing is read from disk while serving a request. - Packages: - `cmd/sww`: the binary, which only calls `internal/sww`. - `internal/sww`: the process, as the command it is given. `sww discriminator` reads the settings, the rule files, the lookup database and the state files, listens, and serves until `SIGTERM` or `SIGINT`. Then it stops, writing the state files (see "`discriminator`"). `sww rproxy` reads only that command's settings, and runs `internal/rproxy` until `SIGTERM` or `SIGINT` (see "`rproxy`"). `sww healthcheck` is the image's health check. With no command, or one it does not know, the binary prints the commands and exits with status 2. - `internal/config`: reads the settings, the one place they are read. - `internal/discriminator`: `discriminator`'s server. It runs the checks in `check`, and holds `discriminator`'s question, answer and report. It answers `discriminator`'s own endpoints in `answerOwnEndpoint`. It counts bytes, refusals and anomalies, and writes the log lines. - `internal/rproxy`: the reverse proxy in front of one app that asks `discriminator` about each request. It passes a request to the app with the standard library's `httputil.ReverseProxy`, within the time and size limits. - `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 and the apps' fixes 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/requestlog` and `internal/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-app` is an app built on the image, with `rproxy` in its container, which `script/example-app` checks with `discriminator` in another. - Libraries, checked against the owner's Go dependency defaults: - the standard library for the reverse proxy, HTTP clients, DNS, logging (`log/slog`) and the state files (`encoding/json`, `os.Rename`); - `github.com/corazawaf/coraza/v3` and `github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries the Core Rule Set 4.25.0. The changes `smallwebwaf` makes to it, and the default `SWW_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/v2` for the bounded tables; - `github.com/oschwald/maxminddb-golang/v2` to read the lookup database; - `github.com/fsnotify/fsnotify` to notice edited files; - `github.com/prometheus/client_golang` for 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/syslog` is frozen and writes only the older syslog format. ## What happens to a request `rproxy` runs steps 1, 2 and 14, and sends the report of step 15. `discriminator` runs steps 3 to 13, and does the counting of step 15. The first step that produces a final answer ends the checks. Step 15 runs for each request `discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and time limits apply to the whole exchange. Each step names the section that holds its rules. 1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a health checker never needs an exemption and is never refused. 2. The client is identified. - If the TCP peer is inside `SWRP_TRUSTED_PROXIES`, `X-Forwarded-For` is 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. An entry that is not an address ends the walk. With no header, the peer is the client. - If the peer is outside `SWRP_TRUSTED_PROXIES`, it is the client, and the header is ignored. - `rproxy` then sends `discriminator` the question (see "The question"). `discriminator` takes the address the question gives as the client. - An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX` around 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. 3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client in both lists is let through. 4. Bans. A client under a ban is refused (see "Bans"). 5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and country"). The request goes on at once. It waits for the first GeoJS answer, up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it: - a country list; - a biased threshold; - `SWW_ADD_LOOKUP_HEADERS`. A request that ends before the answer comes is logged without it. 6. The country lists (see "Country lists"). 7. The reputation sources (see "Reputation"). A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is banned. A client listed by a DNS blocklist, or scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`. 8. The client's limit percentage (see "Biased thresholds"). 9. Rate limiting (see "Request rate limits, per client"). A request over a limit times the percentage is refused and bans the client. 10. The trap paths (see "Attack detection"). A request for one is refused and bans the client. 11. The rule files (see "Rule files"). Matching stops at the first rule that refuses. 12. The header limits (see "Answering in time"), and then the Core Rule Set (see "Attack detection"). A Core Rule Set match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`. 13. A path under `/_sww/` is answered `404`, so that the app never sees that prefix. 14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and `X-Client-Country` are 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. 15. After the visitor's answer has ended, `rproxy` sends the report (see "The report"). `discriminator` then counts: - For a request it allowed, the response bytes, the request bytes or both are added to the byte counters, as `SWW_BYTES_COUNT` says. 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. - For a request it allowed, the client's history and the anomaly counters are updated, and the alert thresholds are evaluated. For a request it refused, `discriminator` adds nothing to the byte limits. It updates the history and the anomaly counters at its decision. It counts the refusals the error burst counts at the decision too (see "Attack detection"). In `SWW_MODE=observe`, every refusal above is logged as what would have happened, nothing is refused, and `discriminator` makes no ban of its own. A path under `/_sww/` is still answered `404`, and `rproxy`'s size and time limits 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.json` and loaded again at start (see "Persistent state"), so a restart does not hand every client a fresh allowance. Buckets whose time has passed are discarded on load. - Memory is bounded. When the table of clients holds `SWW_MAX_TRACKED_CLIENTS`, the least recently seen client is dropped first, with its history. The bans are bounded by `SWW_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" in `docs/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 the bound on 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 `SWW_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to `SWW_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 `discriminator` holds against the client. It is one of: - a clear sign of attack; - a broken limit; - a refusal by a rule file or by the Core Rule Set. 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 in `SWW_TRAP_PATHS`. Examples are a probe for a `.env` file or a `.git` directory, and a known scanner's user agent. - The first one bans the netblock for `SWW_ATTACK_BAN_DURATION` (default `7d`), with the cause `attack`. - 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. - A broken limit, one of: - a request over a rate limit; - a response that takes the client's byte total past a byte limit; - more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute. The bans for a broken limit: - The first such ban lasts `SWW_LIMIT_BAN_DURATION` (default `1h`), with the cause `limit`. So does one that comes more than `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) 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 `SWW_MAX_BAN_DURATION` (default `7d`) 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. 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 for a broken limit. 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: - `SWW_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 `discriminator` 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 `discriminator` 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. A permanent ban does not run out. It ends when an admin lifts it, or when `discriminator` drops it from a full table, as below. When `discriminator` holds `SWW_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 `SWW_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. The groups below are `discriminator`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions: - These settings take `off`, which switches them off: - `SWW_LOOKUP_SOURCE` and `SWW_WAF_MODE`; - the three rate limits and the three byte limits; - `SWW_WAF_ANOMALY_THRESHOLD`, `SWW_WAF_BODY_LIMIT` and `SWW_ERROR_BURST_THRESHOLD`; - `SWW_ALERT_COOLDOWN` and `SWW_ALERT_MAX_PER_HOUR`; - every anomaly threshold; - `SWRP_MAX_REQUEST_SIZE`, `SWRP_MAX_RESPONSE_SIZE`, `SWRP_MAX_REQUEST_TIME_MS` and `SWRP_MAX_RESPONSE_TIME_MS`. 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 `SWW_` setting can be read from a file instead, for secrets and long lists: `SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_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. `SWW_LOG_REMOTE_TLS_CA_FILE` and `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already hold a file name, so they have no `_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has one, since `rproxy` never touches disk. - Settings, files included, are read once at start. Changing one means restarting the container. The files `discriminator` watches 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; "Each container's 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 - `SWW_INSTANCE_NAME` is in each line `discriminator` logs, and in each 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. - `SWW_MODE=observe` lets a configuration be tried before it is enforced (see "What happens to a request"). - `SWW_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6 client starts afresh; existing bans keep refusing their netblocks until they end. `SWW_MAX_TRACKED_CLIENTS` and `SWW_MAX_BANS` set the bounds under "Counting method" and "Bans". ### Tokens - `SWW_ADMIN_TOKEN` switches on the ban endpoints and `/_sww/clients/`. The metrics are off until `SWW_METRICS_TOKEN` is set, since without a token they would be open to anything on `discriminator`'s network. The two are separate, so the metrics collector, which holds the metrics token, cannot manage bans. - A token is a long random value. One shorter than 32 characters stops the start. The file form keeps a token out of the container's environment (see "Tokens" under "Deployment"). ### State files - `SWW_STATE_DIR` names the directory of the state files (see "State" under "Deployment"). - `bans.json` is written `SWW_STATE_WRITE_DELAY` after a ban is made, lifted or made permanent, so a burst of changes becomes one write. Every file is written every `SWW_STATE_COUNTER_INTERVAL`, and at an orderly stop. ### Logging - The request log on stdout is always on and has no switch (see "Request log"). `SWW_LOG_LEVEL` filters only the process's own messages, and no message is at `debug`. `warn` and `error` hold back the line of settings at start. - `SWW_LOG_REQUEST_HEADERS` names the request headers to record. `Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if listed. The log says only whether `Authorization` and `Cookie` were present. - `SWW_LOG_REMOTE_URL` names a syslog server (see "Request log"). ### Metrics - `SWW_METRICS_TOP_N` is how many of the busiest AS numbers and countries get a series of their own. The rest are summed as `other`, so the label set stays bounded. ### Static lists - `SWW_ALLOW_NETS` skips the checks step 3 names, for monitoring or the owner's own networks. `SWW_RATE_LIMIT_EXEMPT_NETS` bypasses the request and byte limits only; the error burst, attack detection and bans still apply. - Many people can share one address, as in an office, on a mobile carrier or through Tor. They are one client, and share its limits and its bans. A known shared address busier than the limits allow belongs in `SWW_ALLOW_NETS` or `SWW_RATE_LIMIT_EXEMPT_NETS`. ### Request rate limits, per client Breaking one bans the client. The defaults let people read a site and stop scrapers that send many requests slowly over hours or days. The hour limit is the main one. - Per minute: 120, 2 requests a second. It leaves room for a person opening two pages at once with an empty cache. A web app's page opened with an empty cache loads 20 to 60 files, and a handful once cached. - Per hour: 1,500. A person reading a site for a whole hour, a page every 20 seconds, makes about 900 requests. A scraper making one request every 2 seconds makes 1,800 an hour, more than the limit. - Per day: 6,000, four hours at the hour limit. A scraper making one request every 10 seconds makes 8,640 a day, more than the limit. Counting cannot catch a client that stays under all three limits, about 4 requests a minute around the clock. Only the other checks can catch an abuser that slow. They include the trap paths, the rule files, the Core Rule Set and the blocklists. An abuser that trips none of them is not caught. Some legitimate clients pass the limits: - A CI runner asks its code forge for work all day and can pass the limits. - Everyone behind one shared address, such as an office, shares one client's limits. Their addresses belong in `SWW_RATE_LIMIT_EXEMPT_NETS`. `SWW_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. `SWW_WAF_EXEMPT_PATHS` is matched the same way. ### Byte limits, per client By default there is no minute limit, so a client can download in a burst at full speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They allow several downloads of a few GiB each. A response's bytes are counted when it ends (see step 15). ### 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 `file` is set without `SWW_LOOKUP_DB_PATH`, or `SWW_LOOKUP_DB_PATH` with any other source. The start also stops when the source is `off` and a setting that needs a lookup is set (`docs/settings.md` lists them). - 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 `SWW_ALLOW_NETS` or `SWW_DENY_NETS`; - one under a ban; - a private, loopback or link-local address, which no source can place. - 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's `country_code`, `asn` and `organization_name` are read. An answer without a `country_code` leaves the country unknown, and the `asn` `64512` leaves the AS number 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. - `SWW_LOOKUP_TIMEOUT` is 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 `discriminator`, 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. Each failure raises a `source_failure` alert (see "Alerting"). A service that cannot accept this uses the file. - The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`, `as_name` and `country_code` fields are read. The operator downloads it with a free IPinfo account and mounts it read-only; `discriminator` never 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. A replacement that cannot be read is logged, sent as one `file_error` alert 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. - `SWW_DENIED_COUNTRIES` refuses every request from a listed country. - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets 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 in `SWW_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. `SWW_DENIED_COUNTRIES` then 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 `SWW_BAN_RESPONSE`, as soon as the client's address has been looked up. It is logged with the action `country_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. - Each list gives AS numbers or countries a percentage, such as `AS14061:50`. Their clients get that percentage of every request and byte limit (`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is rounded down. `0` is a zero allowance: the client's first request breaks a limit and bans it. - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits' percentage alone, when it should differ from the request limits'. For an AS number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and `SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from `SWW_COUNTRY_LIMIT_PERCENT`. - `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one abuse-source list can be shared by every `discriminator` in the fleet. It is fetched and kept like a blocklist (see "Reputation"). Where it and `SWW_ASN_LIMIT_PERCENT` both 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:`. A client without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of 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. In front of a code forge, 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 skips a path under `SWW_WAF_EXEMPT_PATHS`. - `SWW_WAF_PARANOIA_LEVEL` and `SWW_WAF_ANOMALY_THRESHOLD` are 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 threshold `off`, 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, in `block` and `detect` mode alike, the log line gives the rule ids and the score, and a `waf_block` alert is sent. - `SWW_WAF_DISABLED_RULES` switches 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 `.sh` or `.sql`; - 930130 and 930140 refuse a file or directory name in the path, such as `.git/`, `.gitignore`, `Dockerfile`, `package.json` or 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. - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as Coraza directives. An exclusion switches one rule off for some fields or paths only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file holds an app's own exclusions; gitea's fixes below hold gitea's. - `discriminator` loads the file after `smallwebwaf`'s own setup rules and before the Core Rule Set's rules. The Core Rule Set loads its own `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point. - So the file holds only `SecAction` and `SecRule` lines that exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. These run with each request, before the rules they exclude, those of phase 1 included. - A rule that `SWW_WAF_DISABLED_RULES` switches off and the file names is simply off. - The start stops on a file that cannot be read or does not parse, whatever `SWW_WAF_MODE` is. - It stops too on a file that names a number from 900000 to 900999, the ids of `smallwebwaf`'s own rules, alone or in a range. Comment lines are not checked. - It stops too on a directive that removes or changes rules as the file loads, such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded yet. - `SWW_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes at most `64K`, since `discriminator` gets no more of a body than that (see "The answer"). Form data and multipart bodies are read up to the size. 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. Body inspection suits apps whose forms carry no code. - `SWW_TRAP_PATHS` names paths the app never serves and only scanners ask for, such as `/wp-login.php` in front of an app that is not WordPress. This is the short form of a `path` rule with the `ban` action, 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.php` matches `/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/` or `/blog/wp-login.php`. - `SWW_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these kinds, and of no other: - a rule file match; - a trap path; - a Core Rule Set match. More than this breaks a limit (see "Bans"). A client trying one attack 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 a code forge, registry and git clients get `404` and `401` by 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: 1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST` and `OPTIONS`. APIs, container image pushes and package uploads use them. Other methods stay refused. 2. The request header `Expect` is allowed, since curl and git send it before some large uploads. `Content-Encoding` is allowed on a request whose body the Core Rule Set does not read, which by default is every request. git compresses a fetch request over 1 KiB and says so in that header. On a body the Core Rule Set does read, the header stays refused, since a compressed body cannot be inspected. The other headers the Core Rule Set refuses, such as `Proxy`, stay refused. 3. The `redirect_uri` parameter is not checked for a URL naming an IP address or `localhost` (931100, 934110). Git Credential Manager, git-credential-oauth and tea, which gitea registers for OAuth sign-in, ask to be sent back to `http://127.0.0.1` on the user's own machine. The server never fetches that address. 4. `Referer` is not checked for a Unix command given without arguments (932340), for Java starting a process (944110) or for a Java class name (944130). `Referer` names 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: - a search for `env`; - OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`; - a file named `java.lang.String.md`. Every request from such a page would be refused. `Referer` keeps the other rules, except after some gitea searches (gitea's fixes). An app that passes `Referer` to a shell must check it itself. 5. 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. 6. The `from` parameter of a container registry's upload path, `/v2//blobs/uploads/`, is not checked for command names (932260). When a push reuses a layer, `from` names the image the layer comes from. An image such as `owner/python3-app` would have that push refused. Each supported app also has its own fixes, which let its ordinary requests through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for: - `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon, then one or more apps separated by commas, for example `git.example.com:gitea;code.example.com:gitea`. - The request's `Host` is matched by exact name, without its port and without regard to case. There are no wildcards. - A hostname not listed gets no app's fixes, so every rule checks it in full. Set empty, `SWW_APPS` lists no hostname. - Unset, every app's fixes hold for every hostname. An app's fixes then weaken the check of any other app that serves the same paths or fields. - The start stops on an unknown app, a hostname listed twice, an app listed twice for one hostname, or an entry without a colon. `gitea` is the only app supported. Each app's fixes are in one file, such as `internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`. gitea's fixes are: 1. Ten query parameters are not checked against the Core Rule Set's lists of system files (930120), shell paths (932160) and command names (932260), on any path: - `path`, `files`, `skip-to` and `sub_path`; - `ref`, `sha`, `branch` and `workflow`; - `artifactName` and `redirect_to`. gitea puts into them file paths, branch and workflow names, and the page to return to after signing in. 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 `.gitignore` or `docker-build`. In these parameters, what only these three rules would catch reaches the app, such as the command `|cat /etc/passwd`. Path traversal, SQL and script injection, and PHP, Java and Node.js code are still refused there. Other parameters keep all three rules, except on the gitea paths the third fix names. 2. The Core Rule Set reads the request without the `gitea_flash` and `redirect_to` cookies. gitea puts a message naming what was just done into `gitea_flash`. It puts into `redirect_to` the page to return to after signing in. The Core Rule Set takes file names in them, such as `package.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. 3. On gitea's paths, some fields skip the rules that refused ordinary gitea requests there. They are the fields where gitea sends a search, a file name or a branch name. `internal/waf/gitea.conf` lists each rule and the request it refused. The fields are: - `q` on gitea's searches of code, of issues and pull requests, and of commits. A code search holds code, such as `