Author SHA1 Message Date
sneak ca9e97998f Name request log actions so they say what they mean (closes #229)
check / check (push) Successful in 3m46s
Rename too_large, rate_limited, rule_blocked and denied, and split banned
into temporarily_banned and permanently_banned by whether the ban runs out.
A request that makes a ban permanent gets permanently_banned. A ban the
request itself makes gets the value for that ban, and in observe mode
would_action names the ban enforce mode would have made.

The offence kind rule_blocked follows, as a metric label and a key in
clients.json.

Model: opus-5-5
2026-11-09 03:43:24 +00:00
59 changed files with 638 additions and 1802 deletions

No files matched your search

+121 -125
View File
@@ -220,123 +220,122 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
| Variable | Default | What it does |
| ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
@@ -469,7 +468,7 @@ services:
# Alerts to an ntfy topic, with its token read from a file.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes:
- discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro
@@ -568,9 +567,7 @@ the `tags` field of its log line:
or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_ROUTEWATCH_URL` is set.
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
@@ -632,10 +629,9 @@ is set aside as `<name>.bad`, and never stops the running process (see
- `internal/rproxy`: the reverse proxy in front of one app.
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
source each.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/alerts`: one check or source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the
metrics.
+11 -32
View File
@@ -81,14 +81,12 @@ to them.
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/coreruleset`: the Core Rule Set, with the eight changes listed
under "Attack detection", as Coraza directives.
- `internal/waf`: the Core Rule Set, with the eight 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/routewatch`: asks a routewatch instance about clients in the
background, and keeps its 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
@@ -146,9 +144,7 @@ request log's `heuristic` field gives that name. `refusal_limit` and
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 tag lists (`tag_lists`; see "Request tags"). Each list that holds the
client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and
`prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in
`SWW_ALLOW_NETS` gets no tag.
client adds its tag. A client in `SWW_ALLOW_NETS` gets no tag.
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
limits and the error burst. A client in `SWW_DENY_NETS` is refused
(`deny_nets`); a client in both lists is let through.
@@ -565,21 +561,6 @@ Each tag and where it comes from:
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
For the Core Rule Set a match is a score at or over
`SWW_WAF_ANOMALY_THRESHOLD`.
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
(`routewatch_tags`), while `SWW_ROUTEWATCH_URL` is set:
- The tag `prefix=` holds the netblock the client's address is announced in,
such as `prefix=192.0.2.0/24`.
- An `asn=` tag has the same spelling as the lookup's. When routewatch and
the lookup give the same AS number, a request has one `asn=` tag. When
they differ, it has both.
- They are added before `deny_nets`, so the log line of a request it refused
has them.
- `discriminator` asks routewatch in the background and never waits for it.
An answer is kept for 1 hour, in memory only.
- After a query fails, routewatch is not asked for 1 minute.
- A request without these tags gives the reason in the log field
`routewatch_tags_missing` (see
[`docs/request-log.md`](docs/request-log.md)).
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. Every tag is in
lower case, except a country code, which is in capitals. `SWW_TAG_DENY` and
@@ -598,9 +579,7 @@ Two settings act on tags:
The tag settings are empty by default, for the same reason as the country list.
The lookup waits for GeoJS when either names an `asn=` or `country=` tag. A
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`. The
exception is an `asn=` tag while `SWW_ROUTEWATCH_URL` is set, since routewatch
gives that tag.
setting that names one stops the start while `SWW_LOOKUP_SOURCE` is `off`.
### Biased thresholds
@@ -655,7 +634,7 @@ files, the limits and the bans still apply to the client that sends it.
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 `core_rule_set_match` alert is sent.
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
@@ -789,8 +768,8 @@ every app, and no setting undoes them:
such as `owner/python3-app` would have that push refused.
8. 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/coreruleset/gitea.conf` lists each rule and the
request it refused. The fields are:
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 `<script>`,
`SELECT * FROM users WHERE` or `../`.
@@ -1378,11 +1357,11 @@ these fields, named as the request log's fields of the same meaning:
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way, each a whole number from `0` to `1125899906842624`
(1 PiB);
- `duration_total_ms`: milliseconds from the request's headers being read to the
- `duration_total`: milliseconds from the request's headers being read to the
end of the answer, from `0` to `1000000000000` (about 31 years);
- `duration_upstream_total_ms`: milliseconds from the hand-over to the app to
the end of the answer, in the same range, and `0` for a request not passed to
the app.
- `duration_upstream_total`: milliseconds from the hand-over to the app to the
end of the answer, in the same range, and `0` for a request not passed to the
app.
`discriminator` answers `400` and writes a process warning when the body:
+4 -4
View File
@@ -9,9 +9,9 @@ back or dropped.
- `ban`: a ban `discriminator` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request.
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
threshold. The request was refused unless the alert's `detail` gives `mode`:
`detect` or `observe`.
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
request was refused unless the alert's `detail` gives `mode`: `detect` or
`observe`.
- `anomaly`: a count over an anomaly threshold.
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
@@ -130,7 +130,7 @@ The priority and tag by event:
- `ban`: priority `default`, tag `no_entry`.
- `permanent_ban`: priority `high`, tag `no_entry`.
- `core_rule_set_match`: priority `default`, tag `shield`.
- `waf_block`: priority `default`, tag `shield`.
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
- `reputation_hit`: priority `low`, tag `label`.
- `source_failure` and `file_error`: priority `high`, tag `warning`.
+5 -6
View File
@@ -28,8 +28,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Attack detection
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
- `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
fired.
- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
## Countries and AS numbers
@@ -44,8 +43,8 @@ For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
## Lookups
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
- `sww_requests_without_geojs_answer_total`: the requests that needed an answer
and went on without it;
- `sww_geojs_unanswered_total`: the requests that needed an answer and went on
without it;
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
`sww_lookup_database_read_failures_total`.
@@ -71,8 +70,8 @@ By `file`:
## Remote log, alerts and the process
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
`sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
- by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
`sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics.
+24 -31
View File
@@ -26,7 +26,7 @@ These questions write no line:
A `report` line:
```
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
```
### Every line
@@ -37,7 +37,7 @@ Every one of these lines has:
- the standard web log fields;
- `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`;
- `action` and `duration_total_ms`.
- `action` and `duration_total`.
### Standard web log fields
@@ -100,13 +100,13 @@ Every one of these lines has:
the action `deny`, or for a tag in `SWW_TAG_DENY`;
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
rule, a trap path or a CrowdSec decision that made such a ban;
- `permanently_banned`: refused under a ban that never runs out, including a
ban this request made or made permanent;
- `permanently_banned`: refused under a ban that never runs out, or for a
request that made such a ban or made its client's ban permanent;
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
`blocked_by_core_rule_set`: refused by that check;
`waf_blocked`: refused by that check;
- `request_header_too_large`: over a header limit;
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
`404` for a path under `/_sww/`.
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
a path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally
`forward`.
@@ -140,10 +140,9 @@ Every one of these lines has:
`byte_limits` bans when the report comes, so only the `report` line names
it.
- `rate_limit_percent` and `rate_limit_percent_setting`: the client's percentage
of the rate limits and the setting that lowered it, when one did.
`bytes_limit_percent` and `bytes_limit_percent_setting` do the same for the
byte limits.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_percent_setting` do the same for the byte limits.
- `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It
holds:
@@ -159,7 +158,7 @@ Every one of these lines has:
- `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
- `error_burst`.
When the bytes counted for a request and its response break a limit, the
request has been passed on whole, so its `action` stays `forward`. Only its
@@ -167,8 +166,8 @@ Every one of these lines has:
- `rule_ids`: the rule file rules that matched, in order, up to the one that
refused.
- `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
and the rules that matched, for a request it inspected.
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order:
- blocklist URLs;
- the CrowdSec decision list's URL;
@@ -176,12 +175,6 @@ Every one of these lines has:
- `abuseipdb`.
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and
the process log says why;
- `no_route`: routewatch knows no live route for the address.
- `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`.
@@ -190,14 +183,14 @@ Every one of these lines has:
All in milliseconds:
- `duration_total_ms`:
- `duration_total`:
- on the `decision` line, from `discriminator` having read the question's
body to the line;
- on the `report` line, from `rproxy` having read the request's headers to
the end of the answer.
- `duration_checks_ms`: to the end of the checks;
- `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
- `duration_upstream_total_ms`: on the `report` line of an allowed request, from
- `duration_checks`: to the end of the checks;
- `duration_waf`: the part of that the Core Rule Set took;
- `duration_upstream_total`: on the `report` line of an allowed request, from
the hand-over to the app to the end of the answer.
### Requests to its own endpoints
@@ -210,8 +203,8 @@ these meanings where they differ:
- `scheme`: `http`.
- `time`: when the request's headers had been read.
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
- `action`: `own_endpoint`.
- `duration_total_ms`: from the request's headers being read to the line.
- `action`: `admin`.
- `duration_total`: from the request's headers being read to the line.
## `rproxy`'s line
@@ -229,10 +222,10 @@ these meanings where they differ:
a WebSocket carried from the visitor.
- `response_bytes`: the bytes of the answer's body, with what a WebSocket
carried from the app.
- `duration_request_ms`: milliseconds from the request's headers being read
until the app had been sent the whole request, or the answer started if that
was sooner. It includes the wait for `discriminator`.
- `duration_response_ms`: milliseconds from then until the end of the answer.
- `duration_request`: milliseconds from the request's headers being read until
the app had been sent the whole request, or the answer started if that was
sooner. It includes the wait for `discriminator`.
- `duration_response`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
left out when:
@@ -246,5 +239,5 @@ these meanings where they differ:
For example:
```
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"}
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"}
```
+3 -12
View File
@@ -145,8 +145,8 @@ cut off; the client is banned.
the start while a setting that needs a lookup is set:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
tag;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number.
@@ -186,20 +186,11 @@ Every request gets tags from what `discriminator` knows of its client. See
`exploitsignature`: a request gets that tag after the rate limits have counted
it.
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS.
`discriminator` asks it about each client, in the background, and adds the
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
unset, nothing asks routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`;
- an `asn=` or `country=` tag;
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
@@ -324,7 +315,7 @@ the request first.
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused.
- `SWW_ALERT_EVENTS` (default
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused.
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
+2 -2
View File
@@ -64,8 +64,8 @@ since it was first seen:
- body bytes of its requests, and of the responses it was sent;
- responses by status class;
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
kept, and which nothing adds to.
`waf_blocked`. A file may also hold `token_refused`, which is kept, and which
nothing adds to.
## `lookups.json`
+5 -5
View File
@@ -44,10 +44,10 @@ const (
// EventAnomaly is a count of requests or bytes over an anomaly
// threshold.
EventAnomaly = "anomaly"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// EventWAFBlock is a request the Core Rule Set scored at or over
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode.
EventCoreRuleSetMatch = "core_rule_set_match"
EventWAFBlock = "waf_block"
// EventReputationHit is a request whose client a blocklist, the
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
// is a hit.
@@ -72,7 +72,7 @@ const (
// default.
func Events() []string {
return []string{
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
EventReputationHit, EventSourceFailure, EventFileError,
}
}
@@ -871,7 +871,7 @@ func ntfyPriority(event string) string {
return "high"
case EventReputationHit:
return "low"
default: // ban, core_rule_set_match and summary
default: // ban, waf_block and summary
return "default"
}
}
@@ -882,7 +882,7 @@ func ntfyTag(event string) string {
switch event {
case EventBan, EventPermanentBan:
return "no_entry"
case EventCoreRuleSetMatch:
case EventWAFBlock:
return "shield"
case EventAnomaly:
return "chart_with_upwards_trend"
+2 -2
View File
@@ -1281,12 +1281,12 @@ func anAlertForEachEvent() []eventMessage {
},
{
alerts.Alert{
Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
Reason: "refused by the Core Rule Set",
Host: "app.example",
},
"fsn1app1/blog: core_rule_set_match", "default shield",
"fsn1app1/blog: waf_block", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32",
},
+4 -4
View File
@@ -37,13 +37,13 @@ const (
ScopeASN = "asn"
// ScopeTotal is the whole service.
ScopeTotal = "total"
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
ScopeWatchedNet = "watched_net"
// ScopeWatch is a named netblock of SWW_WATCH_NETS.
ScopeWatch = "watch"
)
// Scopes returns every scope.
func Scopes() []string {
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
}
// The windows a counter counts in, as the alerts name them.
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
for _, named := range p.NamedNetblocks {
if named.Netblock.Contains(client) {
all = append(all, scope{
key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch,
})
}
}
+14 -59
View File
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
@@ -24,9 +24,9 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
t.Parallel()
params := defaultParams()
params.MaxBans = 1
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 1
ledger := bans.New(rules)
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
both := netip.MustParsePrefix("203.0.113.1/32")
second := netip.MustParsePrefix("203.0.113.2/32")
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
t.Parallel()
params := defaultParams()
params.MaxBans = 2
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 2
ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.1/32")
other := netip.MustParsePrefix("203.0.113.2/32")
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.1/32")
// The netblock breaks a limit twice, the second time after its first
@@ -117,7 +117,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
func TestReasonOfTheBansSwwMakes(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
Lifted: midnight().Add(10 * time.Minute),
}
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{lifted})
// While it would still last, it refuses nothing, and a limit broken
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
// A permanent ban for a clear sign of attack, lifted.
netblock := netip.MustParsePrefix("203.0.113.9/32")
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{{
Netblock: netblock,
Start: midnight(),
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{})
atStart := bans.Ban{
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
// An hour's ban for a broken limit.
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wide := netip.MustParsePrefix("203.0.113.0/24")
other := netip.MustParsePrefix("203.0.113.10/32")
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{
// Ended an hour ago.
{
@@ -341,48 +341,3 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wantChanged(t, ledger, false)
}
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
t.Parallel()
client := netip.MustParseAddr("203.0.113.9")
ledger := bans.New(defaultParams())
// The temporary ban is made first, so that its netblock is looked at first.
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
"for good")
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
}
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
"for an hour")
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
}
// wantPermanentBan checks that Find and Check return a permanent ban for
// client at now.
func wantPermanentBan(
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
) {
t.Helper()
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
"Find": ledger.Find,
"Check": ledger.Check,
} {
ban, banned, _ := look(client, now)
if !banned || !ban.Permanent() {
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
}
}
}
+19 -41
View File
@@ -40,8 +40,8 @@ const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256
// Params are how long a ban lasts, and how many bans are held.
type Params struct {
// Rules are how long a ban lasts, and how many bans are held.
type Rules struct {
// LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's
@@ -180,7 +180,7 @@ type Request struct {
// Ledger holds the bans. It is safe for concurrent use.
type Ledger struct {
params Params
rules Rules
// changed receives a value when a ban is made, unless one is waiting
// already.
changed chan struct{}
@@ -190,7 +190,7 @@ type Ledger struct {
// Find make each netblock they find the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds whose cause is not CauseAdmin,
// at most params.MaxBans.
// at most rules.MaxBans.
held int
// made is how many bans have been made since the start, by cause: by
// the ledger, and by an admin, through BanForAdmin or in an edit of
@@ -205,7 +205,7 @@ type Ledger struct {
}
// New returns a Ledger with no ban yet.
func New(params Params) *Ledger {
func New(rules Rules) *Ledger {
// The ledger drops bans itself, and never those whose cause is
// CauseAdmin, however many there are, so the LRU has no limit of its
// own: it keeps the netblocks in the order they were last seen.
@@ -215,7 +215,7 @@ func New(params Params) *Ledger {
}
return &Ledger{
params: params,
rules: rules,
changed: make(chan struct{}, 1),
netblocks: netblocks,
made: map[string]int{},
@@ -274,27 +274,16 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
// activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the
// newest permanent one, or the newest when none is permanent. Every ban is
// looked at, since a ban an admin adds to bans.json can start before the
// netblock's others and outlast them.
// one that started last. Every ban is looked at, since a ban an admin adds
// to bans.json can start before the netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban {
var newest *Ban
for i := len(bans) - 1; i >= 0; i-- {
if !bans[i].ActiveAt(now) {
continue
}
if bans[i].Permanent() {
if bans[i].ActiveAt(now) {
return &bans[i]
}
if newest == nil {
newest = &bans[i]
}
}
return newest
return nil
}
// BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -754,16 +743,13 @@ func (l *Ledger) markChanged() {
}
// active returns the ban active at now on a netblock client is in, or
// nil. If several netblocks have one, it returns a permanent ban when
// there is one.
// nil.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths
if client.Is4() {
lengths = l.v4Lengths
}
var first *Ban
for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found {
@@ -771,20 +757,12 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
}
ban := activeBan(*bans, now)
if ban == nil {
continue
}
if ban.Permanent() {
if ban != nil {
return ban
}
if first == nil {
first = ban
}
}
return first
return nil
}
// covering returns the bans of each netblock held that client is in,
@@ -812,7 +790,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
func (l *Ledger) add(ban Ban) {
counted := ban.Cause != CauseAdmin
if counted && l.held == l.params.MaxBans {
if counted && l.held == l.rules.MaxBans {
l.dropOne()
}
@@ -847,7 +825,7 @@ func (l *Ledger) add(ban Ban) {
// new ban longer. A ban an admin adds to bans.json can start after
// another and end before it, so that one is looked for among them all.
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
length := l.params.LimitBanDuration
length := l.rules.LimitBanDuration
var last *Ban
@@ -858,18 +836,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
}
}
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow.
if lastLength > l.params.MaxBanDuration/repeatFactor {
if lastLength > l.rules.MaxBanDuration/repeatFactor {
return time.Time{}
}
length = repeatFactor * lastLength
}
if length > l.params.MaxBanDuration {
if length > l.rules.MaxBanDuration {
return time.Time{}
}
@@ -887,7 +865,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
}
}
return now.Add(l.params.AttackBanDuration)
return now.Add(l.rules.AttackBanDuration)
}
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
+27 -27
View File
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
func TestRepeatsTripleUntilPermanent(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
t.Parallel()
params := defaultParams()
params.LimitBanDuration = params.MaxBanDuration + time.Hour
ledger := bans.New(params)
rules := defaultRules()
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
ledger := bans.New(rules)
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
bans.Notes{})
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent.
params := defaultParams()
params.MaxBanDuration = 100000 * day
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBanDuration = 100000 * day
ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
func TestFindCountsNothing(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t.Parallel()
params := defaultParams()
params.MaxBans = 3
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 3
ledger := bans.New(rules)
a := netip.MustParsePrefix("203.0.113.1/32")
b := netip.MustParsePrefix("203.0.113.2/32")
c := netip.MustParsePrefix("203.0.113.3/32")
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
// With room for one ban, the netblock's ended ban goes to make room for
// its new one, whose notes still count it.
params := defaultParams()
params.MaxBans = 1
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 1
ledger := bans.New(rules)
netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
notes := bans.Notes{RuleID: "env-file", Target: "path"}
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
// A ban for a broken limit before does not count.
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
wantChanged(t, ledger, true)
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the seven days would be permanent; a limit broken as the
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
long := strings.Repeat("a", 300)
request := bans.Request{
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
other := netip.MustParsePrefix("198.51.100.7/32")
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
}
}
// defaultParams are the params at the settings' defaults.
func defaultParams() bans.Params {
return bans.Params{
// defaultRules are the rules at the settings' defaults.
func defaultRules() bans.Rules {
return bans.Rules{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day,
+3 -3
View File
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour)
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour)
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the three days would be permanent; a limit broken as the
+14 -14
View File
@@ -14,7 +14,7 @@ import (
func TestChangedAfterABanIsMade(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
wantChanged(t, ledger, false)
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
v6 := netip.MustParsePrefix("2001:db8::/64")
high := netip.MustParsePrefix("203.0.113.10/32")
low := netip.MustParsePrefix("203.0.113.9/32")
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
func TestLoadedBansCarryOn(t *testing.T) {
t.Parallel()
before := bans.New(defaultParams())
before := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
// Loaded into a new ledger, as across a restart, the ban still refuses
// while it lasts, and once it has ended a broken limit bans for three
// times as long, with the loaded ban counted among the earlier ones.
after := bans.New(defaultParams())
after := bans.New(defaultRules())
after.Load(before.Snapshot())
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
// Two entries as an admin might write them, with addresses not masked
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
// ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
Expires: midnight().Add(time.Hour),
}
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{permanent, ended})
now := midnight().Add(2 * time.Hour)
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
Expires: midnight().Add(2 * time.Hour),
}
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{nineHours, admins})
// Once both have ended, a limit broken within the repeat window bans
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
Cause: bans.CauseLimit,
}
params := defaultParams()
params.MaxBans = 1
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 1
ledger := bans.New(rules)
ledger.Load([]bans.Ban{later, earlier})
held := ledger.Snapshot()
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
// Room for three bans, so that the second load, were it added to the
// two bans held, would drop none of them to make room.
params := defaultParams()
params.MaxBans = 3
ledger := bans.New(params)
rules := defaultRules()
rules.MaxBans = 3
ledger := bans.New(rules)
kept := bans.Ban{
Netblock: netip.MustParsePrefix("2001:db8::/64"),
Start: midnight(),
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
}},
}
ledger := bans.New(defaultParams())
ledger := bans.New(defaultRules())
ledger.Load([]bans.Ban{ban})
cut := long[:256]
+11 -60
View File
@@ -12,7 +12,6 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"math"
"net"
"net/http"
@@ -107,9 +106,6 @@ type Config struct {
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
// RouteWatchURL is where the routewatch instance is that adds the asn=
// and prefix= tags, "" for none (SWW_ROUTEWATCH_URL).
RouteWatchURL string
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
@@ -336,7 +332,7 @@ const (
maxParanoiaLevel = 4
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
// keeps for the rules that set it up, which sww's own rules
// have too (see internal/coreruleset). Switching one off would undo a change
// have too (see internal/waf). Switching one off would undo a change
// that no setting undoes.
firstSetupRuleID = 900000
lastSetupRuleID = 900999
@@ -432,7 +428,7 @@ var (
"holds a control character, such as the carriage return of a Windows line end",
)
errNotAlertEvent = errors.New(
"is not ban, permanent_ban, core_rule_set_match, anomaly, reputation_hit, " +
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error")
errNotNumberOrOff = errors.New(
"is not a whole number above zero, such as 60, or off",
@@ -466,9 +462,6 @@ var (
errNotLAPIURL = errors.New(
"is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080")
errNotRouteWatchURL = errors.New(
"is not an http or https URL without a user or a fragment, " +
"such as https://routewatch.example")
errNeedsLAPIKey = errors.New("the engine answers no request without it")
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New(
@@ -482,9 +475,7 @@ var (
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
errNotPrefixTag = errors.New(
"is not prefix= and a netblock, such as prefix=192.0.2.0/24")
errExploitTag = errors.New(
errExploitTag = errors.New(
"cannot lower limits: the rate limits count a request before its " +
"rules are matched")
errCrowdSecTag = errors.New(
@@ -492,9 +483,8 @@ var (
errNoRequestGetsTag = errors.New(
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
"a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " +
"SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
"Core Rule Set on")
"abuseipdb with SWW_ABUSEIPDB_KEY set, or exploitsignature with " +
"rule files or the Core Rule Set on")
)
// FromEnvironment reads the settings with lookupEnv, normally
@@ -534,7 +524,6 @@ func FromEnvironment(
TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
@@ -1030,22 +1019,6 @@ func (e *environment) crowdSecDecisionsURL(name string) string {
return lapi.JoinPath("v1", "decisions").String()
}
// routeWatchURL reads the setting that is the URL of a routewatch instance,
// "" while it is unset or empty.
func (e *environment) routeWatchURL(name string) string {
value := e.value(name, "")
if value == "" {
return ""
}
instance, err := url.Parse(value)
if err != nil || !isHTTPURL(instance) {
e.check(name, fmt.Errorf("%q %w", value, errNotRouteWatchURL))
}
return value
}
// lookupSource reads the setting that is where clients are looked up:
// geojs, file, or off.
func (e *environment) lookupSource(name, defaultValue string) string {
@@ -1091,21 +1064,14 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
return
}
// routewatch gives asn= tags too.
onlyLookupGives := func(tag string) bool {
return isLookupTag(tag) &&
(cfg.RouteWatchURL == "" || !strings.HasPrefix(tag, ASNTagPrefix))
}
for _, setting := range []struct {
name string
set bool
}{
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
{"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc(
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
@@ -1205,10 +1171,6 @@ func requestCanGetTag(cfg *Config, tag string) bool {
return cfg.AbuseIPDBKey != ""
}
if strings.HasPrefix(tag, PrefixTagPrefix) {
return cfg.RouteWatchURL != ""
}
zone, isZoneTag := strings.CutPrefix(tag, DNSBLTagPrefix)
return isZoneTag && slices.ContainsFunc(cfg.DNSBLZones, func(each string) bool {
@@ -1217,7 +1179,7 @@ func requestCanGetTag(cfg *Config, tag string) bool {
}
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// names an asn= or country= tag.
// names an asn= or country= tag, which only the lookup can give a request.
func (c *Config) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
}
@@ -1961,7 +1923,6 @@ const (
ASNTagPrefix = "asn="
CountryTagPrefix = "country="
DNSBLTagPrefix = "dnsbl="
PrefixTagPrefix = "prefix="
AbuseIPDBTag = "abuseipdb"
CrowdSecTag = "crowdsec"
// ExploitSignatureTag is the tag of a request a rule file or the Core
@@ -2038,10 +1999,9 @@ func parseTags(value string) ([]string, error) {
// parseTag reads a tag in either case, with spaces around it, and returns it
// as a request carries it: in lower case, but for a country code, which is in
// capitals, an asn= tag's number without leading zeros, and a prefix= tag's
// netblock as routewatch writes it. An asn=, country= or prefix= tag must name
// a number, a country or a netblock, so that a mistyped one stops the start
// instead of never matching.
// capitals, and an asn= tag's number without leading zeros. An asn= or
// country= tag must name a number or a country, so that a mistyped one stops
// the start instead of never matching.
func parseTag(value string) (string, error) {
tag := strings.ToLower(strings.TrimSpace(value))
@@ -2063,15 +2023,6 @@ func parseTag(value string) (string, error) {
return CountryTagPrefix + country, nil
}
if text, isPrefix := strings.CutPrefix(tag, PrefixTagPrefix); isPrefix {
netblock, err := netip.ParsePrefix(text)
if err != nil || netblock != netblock.Masked() {
return "", fmt.Errorf("%q %w", value, errNotPrefixTag)
}
return PrefixTagPrefix + netblock.String(), nil
}
return tag, nil
}
+1 -60
View File
@@ -61,7 +61,6 @@ const (
reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
routeWatchURL = "SWW_ROUTEWATCH_URL"
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
banResponse = "SWW_BAN_RESPONSE"
@@ -160,7 +159,7 @@ func loggedAnomalyDefaults() map[string]string {
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
const (
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
"source_failure,file_error"
defaultAlertCooldown = "15m"
)
@@ -1404,63 +1403,6 @@ func TestTagSettingsAsSet(t *testing.T) {
}
}
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
t.Parallel()
const rw = "https://routewatch.example"
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
}
cfg := fromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50",
})
wantDeny := []string{"prefix=2001:db8::/32"}
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
}
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
fromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
})
for _, stopped := range []struct {
env environment
name string
}{
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
{
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
tagLimitPercent,
},
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
} {
_, err := config.FromEnvironment(stopped.env.lookupEnv)
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
}
}
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
_, err := config.FromEnvironment(badURL.lookupEnv)
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
"without a user or a fragment, such as https://routewatch.example"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel()
@@ -2561,7 +2503,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s",
routeWatchURL: "",
crowdSecURL: "",
crowdSecKey: "",
banResponse: "403",
+2 -2
View File
@@ -42,7 +42,7 @@ var (
// answerHealth answers a request for the health endpoint.
func (rq *request) answerHealth() {
rq.line.Action = requestlog.ActionOwnEndpoint
rq.line.Action = requestlog.ActionAdmin
// Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -55,7 +55,7 @@ func (rq *request) answerHealth() {
// refused with 401. An endpoint whose token is unset answers 404, as any other
// path does.
func (rq *request) answerAdmin() {
rq.line.Action = requestlog.ActionOwnEndpoint
rq.line.Action = requestlog.ActionAdmin
token, answer := rq.endpoint()
+2 -2
View File
@@ -356,7 +356,7 @@ func adminEndpoints() []adminEndpoint {
// admin sends a request with method for path, with body, to discriminator's own
// address, with the admin token, and checks that it is answered with status, its
// log line's action own_endpoint. It returns the answer.
// log line's action admin. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer {
s.t.Helper()
@@ -380,7 +380,7 @@ func (s *sender) adminRequest(
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
wantLine(s.t, line, status, requestlog.ActionAdmin)
return got
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
},
} {
+8 -8
View File
@@ -86,8 +86,8 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.DurationCoreRuleSetMS != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
if line.DurationWAF != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
}
}
@@ -165,7 +165,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationCoreRuleSetMS == nil {
if line.DurationWAF == nil {
t.Error("the Core Rule Set did not read a request within the limits")
}
})
@@ -220,9 +220,9 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
line.DurationCoreRuleSetMS != nil {
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
line.DurationWAF != nil {
t.Errorf("would_action %q and duration_waf %v, want %q and none",
line.WouldAction, line.DurationWAF, requestlog.ActionRequestHeaderTooLarge)
}
}
@@ -283,8 +283,8 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.RateLimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
if line.LimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
}
// The client is under no ban, and GeoJS places it in a country.
+7 -5
View File
@@ -100,13 +100,13 @@ func (rq *request) countedBytes() int64 {
// rate limit or a byte limit; the error burst is not lowered. The ban sets
// the client's counters back to zero. In observe mode it makes no ban and
// sets nothing back, and raises the alert for the ban it would have made,
// if that alert would be sent.
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
// if that alert would be sent. It returns the action for that ban.
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) string {
switch hit.Kind {
case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
case ratelimit.KindRefusals:
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
rq.line.LimitHit = requestlog.LimitHitErrorBurst
default:
rq.line.LimitHit = hit.Window
}
@@ -116,7 +116,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
return
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseLimit))
}
notes := bans.Notes{
@@ -145,7 +145,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
rq.alertBan(ban)
}
return
return banAction(ban.Permanent())
}
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
@@ -155,6 +155,8 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
if made {
rq.alertBan(ban)
}
return banAction(ban.Permanent())
}
// banForAttack bans the client's netblock at now for a clear sign of
+13 -21
View File
@@ -79,16 +79,14 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
} {
line := s.get(tc.from, sent.status, sent.action)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"50 from "+tc.setting)
}
// fromKP, which no setting lists, has the whole limit.
for range 3 {
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
none)
}
})
@@ -121,8 +119,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
"50 from "+tc.setting)
// fromKP, which no setting lists, has the whole limit.
@@ -131,8 +128,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
})
}
}
@@ -181,11 +177,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
}
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.limitPercent)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
tc.bytesPercent)
})
}
@@ -199,10 +193,10 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
// The first request breaks the limit, and bans the client; the log line
// gives the 0.
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
line.fields["limit_percent"], line.fields["limit_percent_setting"],
unknownLimitPercent)
}
@@ -216,7 +210,7 @@ func TestLowestPercentageApplies(t *testing.T) {
name string
env map[string]string
from string
// want is the log line's rate_limit_percent, as percentText gives it.
// want is the log line's limit_percent, as percentText gives it.
want string
}{
{
@@ -257,8 +251,7 @@ func TestLowestPercentageApplies(t *testing.T) {
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.want)
})
}
@@ -301,8 +294,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"0 from "+unknownLimitPercent)
})
}
+3 -3
View File
@@ -35,7 +35,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{figures: limitFigures(hit)}
return &verdict{
action: rq.banForLimit(now, hit, rq.out.status), figures: limitFigures(hit),
}
}
+13 -13
View File
@@ -9,8 +9,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// coreRuleSet inspects the request with the Core Rule Set, unless
@@ -18,7 +18,7 @@ import (
// pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
// raises the waf_block alert, and in block mode refuses the request with 403,
// which is an offence its client's history counts. Its figures are the score
// and the threshold.
type coreRuleSet struct{}
@@ -38,12 +38,12 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
rq.requestBytes = int64(len(read))
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
rq.line.CoreRuleSetRuleIDs = result.RuleIDs
rq.line.CoreRuleSetScore = &result.Score
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
rq.line.WAFRuleIDs = result.RuleIDs
rq.line.WAFScore = &result.Score
for _, id := range result.RuleIDs {
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
}
threshold := cfg.WAFAnomalyThreshold
@@ -52,26 +52,26 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
}
rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result)
rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect {
return nil
}
rq.blockedByCoreRuleSet = true
rq.wafBlocked = true
return &verdict{
action: requestlog.ActionBlockedByCoreRuleSet,
action: requestlog.ActionWAFBlocked,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
}
}
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// alertWAFBlock raises the waf_block alert for the request, which the Core
// Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// detail gives the rule ids, the score, the method and the path with the
// query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
func (rq *request) alertWAFBlock(result waf.Result) {
detail := map[string]any{
"rule_ids": result.RuleIDs,
"score": result.Score,
@@ -87,7 +87,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
}
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventCoreRuleSetMatch,
Event: alerts.EventWAFBlock,
Host: rq.site,
Client: rq.client,
Netblock: rq.h.clientGroup(rq.client),
+49 -57
View File
@@ -40,30 +40,24 @@ const (
// SWW_WAF_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
// score, that it has none of the three: the Core Rule Set did not inspect the
// request.
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
// that it has duration_waf, or with no score, that it has none of the
// three: the Core Rule Set did not inspect the request.
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
t.Helper()
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
line.CoreRuleSetRuleIDs, ruleIDs)
if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
}
switch {
case score == nil &&
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
t.Errorf("log line has core_rule_set_score %v and "+
"duration_core_rule_set_ms %v, want neither",
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
case score != nil &&
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
t.Errorf("log line has core_rule_set_score %v, want %d",
line.fields["core_rule_set_score"], *score)
case score != nil && line.DurationCoreRuleSetMS == nil:
t.Error("log line has no duration_core_rule_set_ms")
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
line.fields["waf_score"], line.fields["duration_waf"])
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
case score != nil && line.DurationWAF == nil:
t.Error("log line has no duration_waf")
}
}
@@ -76,7 +70,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
status int
action string
}{
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
{detect, detect, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -139,14 +133,14 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
mode, action string
status int
}{
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
{block, requestlog.ActionWAFBlocked, http.StatusForbidden},
{detect, requestlog.ActionForward, http.StatusOK},
} {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action)
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
wantWAF(t, line, &attack.score, attack.ruleIDs...)
}
})
}
@@ -158,7 +152,7 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
requestlog.ActionWAFBlocked)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
}
@@ -169,7 +163,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
wantWAF(t, line, new(0))
}
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
@@ -189,26 +183,26 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil)
wantWAF(t, line, nil)
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
requestlog.ActionForward)
wantCoreRuleSet(t, line, nil)
wantWAF(t, line, nil)
// The prefix is matched as rate limit exempt paths are: a path that
// goes up and out of it is inspected.
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
requestlog.ActionWAFBlocked)
wantWAF(t, line, new(25), 930100, 930110, 942100)
// A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
wantCoreRuleSet(t, line, nil)
wantWAF(t, line, nil)
// With SWW_WAF_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil)
wantWAF(t, line, nil)
}
func TestAnomalyThreshold(t *testing.T) {
@@ -222,14 +216,13 @@ func TestAnomalyThreshold(t *testing.T) {
})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(5), 942100)
wantWAF(t, line, new(5), 942100)
}
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5",
})
s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
@@ -272,7 +265,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
wantWAF(t, line, new(0))
// A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{
@@ -280,12 +273,12 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
})
score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
wantWAF(t, line, &score, request.ruleIDs...)
// And switches off the rules it lists.
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
wantWAF(t, line, new(0))
})
}
}
@@ -301,14 +294,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
wantWAF(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
})
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantCoreRuleSet(t, line, new(5), 942100)
http.StatusForbidden, requestlog.ActionWAFBlocked)
wantWAF(t, line, new(5), 942100)
}
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
@@ -398,33 +391,32 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
})
for range 2 {
s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
}
// The third refusal in a minute breaks the error burst, and bans the
// client.
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
requestlog.ActionWAFBlocked)
if line.LimitHit != requestlog.LimitHitErrorBurst ||
line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
line.LimitHit, line.Offence)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want)
}
metrics := s.scrape()
wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
`sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
`sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
`instance="app",status_class="4xx"}`, 3)
}
@@ -448,9 +440,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
metrics := s.scrape()
wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
`sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantNoSeries(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
`sww_offences_total{instance="app",kind="waf_blocked"}`)
}
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
@@ -459,17 +451,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
wantCoreRuleSet(t, line, new(5), 942100)
wantWouldAction(t, line, requestlog.ActionWAFBlocked)
wantWAF(t, line, new(5), 942100)
// It is an offence as in enforce mode.
want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
want := ratelimit.Offences{WAFBlocked: 1}
if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want)
}
}
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@@ -483,7 +475,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
}{
{
"block", map[string]string{wafMode: block},
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
http.StatusForbidden, requestlog.ActionWAFBlocked, "",
},
{
"detect", map[string]string{wafMode: detect},
@@ -518,7 +510,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
wantAlerts(t, queue, alerts.Alert{
Instance: alertInstance,
Time: clk.Now(),
Event: alerts.EventCoreRuleSetMatch,
Event: alerts.EventWAFBlock,
Host: appHost,
Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"),
+17 -18
View File
@@ -89,12 +89,12 @@ var (
//
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Report struct {
RequestID string `json:"request_id"`
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
DurationTotalMS float64 `json:"duration_total_ms"`
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
RequestID string `json:"request_id"`
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
DurationTotal float64 `json:"duration_total"`
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
}
// New returns the server `sww discriminator` runs. It answers
@@ -123,7 +123,6 @@ func New(params Params) *Server {
Lists: h.lists,
DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
RouteWatch: h.routeWatch,
Metrics: h.metrics,
}
}
@@ -208,7 +207,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// The app never sees this prefix. discriminator's own endpoints are at its
// own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionOwnEndpoint
rq.line.Action = requestlog.ActionAdmin
rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
@@ -405,7 +404,7 @@ func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
for _, name := range []string{
"request_id", "status", "request_bytes", "response_bytes",
"duration_total_ms", "duration_upstream_total_ms",
"duration_total", "duration_upstream_total",
} {
value, there := fields[name]
if !there || string(value) == "null" {
@@ -431,11 +430,11 @@ func (report *Report) checkRanges() error {
return fmt.Errorf("%w: request_bytes", errReportValue)
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
return fmt.Errorf("%w: response_bytes", errReportValue)
case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_total_ms", errReportValue)
case report.DurationUpstreamTotalMS < 0 ||
report.DurationUpstreamTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue)
case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_total", errReportValue)
case report.DurationUpstreamTotal < 0 ||
report.DurationUpstreamTotal > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_upstream_total", errReportValue)
}
return nil
@@ -459,18 +458,18 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes
rq.line.DurationTotalMS = report.DurationTotalMS
rq.line.DurationTotal = report.DurationTotal
if rq.allowed {
rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies()
rq.addToHistory()
rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
time.Duration(report.DurationTotal*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
}
rq.writeLine()
+22 -24
View File
@@ -48,8 +48,8 @@ const (
reportStatus = "status"
reportRequestBytes = "request_bytes"
reportResponseBytes = "response_bytes"
reportDuration = "duration_total_ms"
reportUpstreamDuration = "duration_upstream_total_ms"
reportDuration = "duration_total"
reportUpstreamDuration = "duration_upstream_total"
)
// Discriminator is a discriminator running for one test, on a clock set to midnight.
@@ -693,7 +693,7 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
wantDisposition(t, got, http.StatusNotFound, discriminator.DispositionDeny)
}
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionOwnEndpoint)
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
got := s.ask(otherClient, discriminator.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
@@ -725,9 +725,9 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
}
lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst)
}
}
@@ -744,9 +744,9 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
}
lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst)
}
}
@@ -901,7 +901,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
got := s.report(discriminator.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotalMS: 2,
ResponseBytes: 1000, DurationTotal: 2,
})
wantStatus(t, got, http.StatusNoContent)
@@ -935,7 +935,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotalMS: 5, DurationUpstreamTotalMS: 4,
DurationTotal: 5, DurationUpstreamTotal: 4,
})
wantStatus(t, got, http.StatusNoContent)
@@ -951,8 +951,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
unscraped := startDiscriminator(t, "", map[string]string{metricsToken: token})
got = unscraped.report(discriminator.Report{
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway,
DurationTotalMS: 5,
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5,
})
wantStatus(t, got, http.StatusNoContent)
wantMetric(t, unscraped.metrics(),
@@ -1190,8 +1189,8 @@ func wantReportsFields(t *testing.T, line logLine) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.Status != http.StatusCreated ||
line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes ||
line.DurationTotalMS != 12.5 || line.DurationUpstreamTotalMS == nil ||
*line.DurationUpstreamTotalMS != 10.25 {
line.DurationTotal != 12.5 || line.DurationUpstreamTotal == nil ||
*line.DurationUpstreamTotal != 10.25 {
t.Errorf("the report's line lacks the report's fields: %+v", line.Line)
}
}
@@ -1206,8 +1205,8 @@ func wantSameDecision(t *testing.T, reported, decided logLine) {
same.Status = decided.Status
same.RequestBytes = decided.RequestBytes
same.ResponseBytes = decided.ResponseBytes
same.DurationTotalMS = decided.DurationTotalMS
same.DurationUpstreamTotalMS = decided.DurationUpstreamTotalMS
same.DurationTotal = decided.DurationTotal
same.DurationUpstreamTotal = decided.DurationUpstreamTotal
same.Counts.MinuteBytes = decided.Counts.MinuteBytes
same.Counts.HourBytes = decided.Counts.HourBytes
same.Counts.DayBytes = decided.Counts.DayBytes
@@ -1225,7 +1224,7 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
if line.DiscriminatorLine != requestlog.DiscriminatorLineDecision ||
line.Status != http.StatusOK || line.ResponseBytes != 0 ||
line.DurationUpstreamTotalMS != nil || line.DurationChecksMS == nil ||
line.DurationUpstreamTotal != nil || line.DurationChecks == nil ||
line.RequestID != id || line.Query != query ||
!line.HasAuthorization || !line.HasCookie ||
line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 ||
@@ -1261,7 +1260,7 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T
got := s.report(discriminator.Report{
RequestID: id, Status: http.StatusCreated,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotalMS: 12.5, DurationUpstreamTotalMS: 10.25,
DurationTotal: 12.5, DurationUpstreamTotal: 10.25,
})
wantStatus(t, got, http.StatusNoContent)
@@ -1306,7 +1305,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
0, requestlog.ActionClientDenied,
},
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
requestlog.ActionOwnEndpoint},
requestlog.ActionAdmin},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -1318,7 +1317,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
do(t, req)
got := s.report(discriminator.Report{
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotalMS: 3,
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3,
})
wantStatus(t, got, http.StatusNoContent)
@@ -1326,8 +1325,8 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
wantLine(t, line, tc.status, tc.action)
if line.DiscriminatorLine != requestlog.DiscriminatorLineReport ||
line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotalMS != 3 ||
line.DurationUpstreamTotalMS != nil {
line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotal != 3 ||
line.DurationUpstreamTotal != nil {
t.Errorf("the report's line is %+v", line.Line)
}
})
@@ -1584,10 +1583,9 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventCoreRuleSetMatch ||
if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock ||
waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want a core_rule_set_match naming %s",
waiting, siteA)
t.Errorf("alerts waiting are %+v, want a waf_block naming %s", waiting, siteA)
}
}
-1
View File
@@ -44,7 +44,6 @@ const (
func beforeLookup() []heuristic {
return []heuristic{
tagLists{},
routeWatchTags{},
denyNets{},
activeBan{},
}
+2 -2
View File
@@ -91,7 +91,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="own_endpoint",instance="app",status_class="4xx"}`
notFound := `{action="admin",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way.
metrics := scrape(t, server.Addr)
@@ -347,7 +347,7 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1)
}
// keptAnswer returns GeoJS's answer that the client at addr is in
-24
View File
@@ -7,7 +7,6 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
@@ -79,29 +78,6 @@ func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
}
}
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
trapPaths: trapPathList, mode: observe,
})
server.Ledger.Load([]bans.Ban{{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Expires: clk.Now().Add(-time.Hour),
Cause: bans.CauseAttack,
}})
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
}
}
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
+8 -18
View File
@@ -16,13 +16,12 @@ import (
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// adminPrefix starts the path of each of discriminator's own endpoints. The app never
@@ -65,7 +64,7 @@ type Params struct {
LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWW_WAF_MODE is not off, and nil while it is.
CoreRuleSet *coreruleset.CoreRuleSet
CoreRuleSet *waf.CoreRuleSet
// Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give.
@@ -86,8 +85,7 @@ type Params struct {
// Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
// AbuseIPDB's scores and checks spent, and the metrics.
type Server struct {
*http.Server
@@ -99,7 +97,6 @@ type Server struct {
Lists *reputation.Lists
DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB
RouteWatch *routewatch.Client
Metrics *metrics.Metrics
}
@@ -129,7 +126,7 @@ func newHandler(params Params) *handler {
BytesPerHour: params.Config.BytesLimitPerHour,
BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Params{
ledger: bans.New(bans.Rules{
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration,
@@ -155,12 +152,6 @@ func newHandler(params Params) *handler {
coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts,
}
if params.Config.RouteWatchURL != "" {
h.routeWatch = routewatch.New(routewatch.Params{
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
})
}
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
@@ -224,15 +215,15 @@ func newReputation(
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
coreRuleSet, err := coreruleset.New(coreruleset.Params{
coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
@@ -264,9 +255,8 @@ type handler struct {
lists *reputation.Lists
dnsbl *reputation.DNSBL
abuseIPDB *reputation.AbuseIPDB
routeWatch *routewatch.Client
rules *rules.Files
coreRuleSet *coreruleset.CoreRuleSet
coreRuleSet *waf.CoreRuleSet
alerts *alerts.Queue
// awaitingReport holds the decision on each answered question by its id
// until its report comes, and nil for a question still being handled.
+2 -2
View File
@@ -29,8 +29,8 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
}
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.RateLimitPercent, rq.line.RateLimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesLimitPercent, rq.line.BytesLimitPercentSetting = rq.bytesPercent.logged()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
+3 -5
View File
@@ -28,7 +28,7 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
return nil
}
if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet {
if !rq.attack && !rq.blockedByBlockRule && !rq.wafBlocked {
return nil
}
@@ -42,13 +42,11 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
case requestlog.ActionBlockedByBlockRule, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{figures: limitFigures(hit)}
return &verdict{action: rq.banForLimit(now, hit, status), figures: limitFigures(hit)}
}
+7 -8
View File
@@ -107,9 +107,9 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
// The fourth is answered as the others were, breaks the error
// burst, and bans the client.
line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+
"and limit", line.LimitHit, line.Offence)
}
@@ -277,8 +277,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
line = blockRule.send(s, client)
}
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken {
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
broken, tc.broken)
}
@@ -307,8 +306,8 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
blockRule.send(s, exempt)
line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
if line.LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
}
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -368,8 +367,8 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
"and none", line.LimitHit, line.BanExpires)
}
+20 -30
View File
@@ -54,7 +54,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for _, tc := range []struct {
action string
// statuses and actions are those of a listed client's three
// requests, and percent their rate_limit_percent, as percentText gives it.
// requests, and percent their limit_percent, as percentText gives it.
statuses []int
actions []string
percent string
@@ -90,8 +90,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i])
wantReputation(t, line, dropURL)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.RateLimitPercentSetting, tc.percent)
wantPercent(t, "limit_percent", line.LimitPercent,
line.LimitPercentSetting, tc.percent)
// A request refused for the list is not counted.
counted := line.fields["counts"] != nil
@@ -106,8 +106,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for range 3 {
line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
none)
}
@@ -208,7 +207,7 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct {
action, asnPercent string
// want is the upload's rate_limit_percent and bytes_limit_percent, as
// want is the upload's limit_percent and bytes_percent, as
// percentText gives them, and limitHit its limit_hit.
want, limitHit string
}{
@@ -230,11 +229,9 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// The upload's 100 bytes are over 49, a quarter of 199, and 99,
// half of it, and within 199.
line := s.uploadFrom(fromDE)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.want)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
tc.want)
if line.LimitHit != tc.limitHit {
@@ -291,11 +288,9 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
line := s.uploadFrom(fromDE)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.limitPercent)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
tc.bytesPercent)
})
}
@@ -390,7 +385,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for _, tc := range []struct {
action string
// statuses and actions are those of a listed client's three
// requests, and percent their rate_limit_percent, as percentText gives it.
// requests, and percent their limit_percent, as percentText gives it.
statuses []int
actions []string
percent string
@@ -427,8 +422,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWW_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.RateLimitPercentSetting, tc.percent)
wantPercent(t, "limit_percent", line.LimitPercent,
line.LimitPercentSetting, tc.percent)
// A request refused for the verdict is not counted.
counted := line.fields["counts"] != nil
@@ -443,8 +438,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for range 3 {
line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
none)
}
@@ -552,7 +546,7 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct {
blocklistAction, reputationAction string
// want is the request's rate_limit_percent and bytes_limit_percent, as
// want is the request's limit_percent and bytes_percent, as
// percentText gives them.
want string
}{
@@ -577,11 +571,9 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// Named by the blocklist, then by the zone.
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.want)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
tc.want)
})
}
@@ -827,7 +819,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
},
{
"the Core Rule Set", sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet, ratelimit.Offences{BlockedByCoreRuleSet: 1},
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
},
{
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
@@ -871,7 +863,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for _, tc := range []struct {
action string
// statuses and actions are those of fromDE's three requests, and
// percent their rate_limit_percent, as percentText gives it.
// percent their limit_percent, as percentText gives it.
statuses []int
actions []string
percent string
@@ -904,8 +896,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for i := range 3 {
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
wantReputation(t, line, abuseipdb)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.percent)
// A request refused for the score is not counted.
@@ -919,8 +910,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for range 3 {
line := s.get(fromKP, http.StatusOK, forward)
wantReputation(t, line)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
none)
}
+15 -15
View File
@@ -55,9 +55,9 @@ type decision struct {
limitPercent, bytesPercent percentage
// attack is true for a request that matched a ban rule or asked for a
// trap path, blockedByBlockRule for one a block rule refused, and
// blockedByCoreRuleSet for one the Core Rule Set refused, each an offence
// its client's history counts.
attack, blockedByBlockRule, blockedByCoreRuleSet bool
// wafBlocked for one the Core Rule Set refused, each an offence its
// client's history counts.
attack, blockedByBlockRule, wafBlocked bool
// reputation is the reputation sources that list the client, for the
// notes of a ban the request makes.
reputation []bans.ReputationHit
@@ -188,7 +188,7 @@ func (rq *request) check(ctx context.Context) *refusal {
return nil
case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionBlockedByCoreRuleSet:
action == requestlog.ActionWAFBlocked:
return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
@@ -262,8 +262,8 @@ func (rq *request) finish() {
}
duration := time.Since(rq.start)
line.DurationTotalMS = requestlog.Milliseconds(duration)
line.DurationChecksMS = timing(rq.start, rq.checked)
line.DurationTotal = requestlog.Milliseconds(duration)
line.DurationChecks = timing(rq.start, rq.checked)
// Counted before the log line is written, so that the metrics count
// every request whose line is out. An allowed request is the exception:
@@ -304,15 +304,15 @@ func timing(start, end time.Time) *float64 {
// when it comes.
func (rq *request) addToHistory() {
request := ratelimit.Request{
Forwarded: rq.allowed,
Refused: rq.refused != nil,
Status: rq.out.status,
RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack,
BlockedByBlockRule: rq.blockedByBlockRule,
BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
Forwarded: rq.allowed,
Refused: rq.refused != nil,
Status: rq.out.status,
RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack,
BlockedByBlockRule: rq.blockedByBlockRule,
WAFBlocked: rq.wafBlocked,
}
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
+12 -12
View File
@@ -156,9 +156,9 @@ func wantBareLine(t *testing.T, line logLine) {
// withTimings returns want with the timings of line.
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
want.DurationTotalMS = line.DurationTotalMS
want.DurationChecksMS = line.DurationChecksMS
want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS
want.DurationTotal = line.DurationTotal
want.DurationChecks = line.DurationChecks
want.DurationUpstreamTotal = line.DurationUpstreamTotal
return want
}
@@ -226,10 +226,10 @@ func TestTimingsAreInOrder(t *testing.T) {
// A request passed to the app has every timing; one refused, none of
// the app's; the health check, which runs no check, only the total.
wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms",
"duration_upstream_total_ms")
wantTimings(t, refused, "duration_total_ms", "duration_checks_ms")
wantTimings(t, health, "duration_total_ms")
wantTimings(t, forwarded, "duration_total", "duration_checks",
"duration_upstream_total")
wantTimings(t, refused, "duration_total", "duration_checks")
wantTimings(t, health, "duration_total")
if t.Failed() {
return
@@ -237,9 +237,9 @@ func TestTimingsAreInOrder(t *testing.T) {
// In whole microseconds, as they are logged, so that the sum below is
// exact.
total := microseconds(forwarded.DurationTotalMS)
checks := microseconds(*forwarded.DurationChecksMS)
upstream := microseconds(*forwarded.DurationUpstreamTotalMS)
total := microseconds(forwarded.DurationTotal)
checks := microseconds(*forwarded.DurationChecks)
upstream := microseconds(*forwarded.DurationUpstreamTotal)
// The checks end before the request is handed to the app.
if checks+upstream > total {
@@ -247,9 +247,9 @@ func TestTimingsAreInOrder(t *testing.T) {
total, checks, upstream)
}
if *refused.DurationChecksMS > refused.DurationTotalMS {
if *refused.DurationChecks > refused.DurationTotal {
t.Errorf("refused request's checks took %v of %v milliseconds",
*refused.DurationChecksMS, refused.DurationTotalMS)
*refused.DurationChecks, refused.DurationTotal)
}
}
-42
View File
@@ -1,42 +0,0 @@
package discriminator
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
import (
"context"
"strconv"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
// from what routewatch answered about the client. It refuses nothing, and the
// request does not wait for routewatch. A request that gets no tags from it has
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
// is unset it does nothing. Answers are kept by client and asked for by the
// request's address, and a route without an origin AS gives only the prefix= tag.
type routeWatchTags struct{}
func (routeWatchTags) name() string { return "routewatch_tags" }
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.routeWatch == nil {
return nil
}
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
if missing != "" {
rq.line.RouteWatchTagsMissing = string(missing)
return nil
}
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
if route.ASN != 0 {
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
}
return nil
}
@@ -1,245 +0,0 @@
package discriminator_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
// that says why a request got no tags from routewatch.
const (
routeWatchURL = "SWW_ROUTEWATCH_URL"
tagsMissing = "routewatch_tags_missing"
fromV6 = "2001:db8:1::5"
prefixV6 = "2001:db8::/32"
prefixDE = "203.0.113.0/24"
prefixKP = "198.51.100.0/24"
)
// standInRoute is what the stand-in for routewatch answers about an address.
type standInRoute struct {
Netblock string `json:"netblock"`
ASN int `json:"asn"`
}
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
// routes given by address, and returns its URL.
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
result := map[string]any{"ip": address}
if route, known := routes[address]; known {
result["netblock"], result["asn"] = route.Netblock, route.ASN
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"results": []any{result}},
})
}))
t.Cleanup(server.Close)
return server.URL
}
// waitForRouteWatch waits until routewatch's answer about from, in the client
// group of the given length, is kept, so that the next request gets its tags.
func waitForRouteWatch(
t *testing.T, server *discriminator.Server, from string, bits int,
) {
t.Helper()
addr := netip.MustParseAddr(from)
client := netip.PrefixFrom(addr, bits).Masked()
deadline := time.Now().Add(5 * time.Second)
for {
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
if missing == "" {
return
}
if time.Now().After(deadline) {
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
}
time.Sleep(time.Millisecond)
}
}
func wantTagsMissing(t *testing.T, line logLine, want string) {
t.Helper()
got, _ := line.fields[tagsMissing].(string)
if got != want {
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
}
}
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
waitForRouteWatch(t, server, fromDE, 32)
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
wantTagsMissing(t, line, "")
}
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
})
waitForRouteWatch(t, server, fromKP, 32)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
}
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
waitForRouteWatch(t, server, fromDE, 32)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"prefix="+prefixDE)
}
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
if server.RouteWatch != nil {
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
}
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields[tagsMissing]; present {
t.Errorf("log line has %s", tagsMissing)
}
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v", line.fields["tags"])
}
}
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
t.Parallel()
// A stand-in that is closed at once, so that the port refuses connections.
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet")
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "request_failed" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want request_failed", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
})
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "no_route" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want no_route", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
})
waitForRouteWatch(t, server, fromKP, 32)
waitForRouteWatch(t, server, fromDE, 32)
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
tagLimitPercent: "prefix=" + prefixV6 + ":50",
})
waitForRouteWatch(t, server, fromV6, 64)
// Half of two is one: the second request breaks the limit.
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
+3 -6
View File
@@ -88,8 +88,7 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"25 from "+tagLimitPercent)
}
@@ -109,8 +108,7 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
"50 from "+tagLimitPercent)
// fromKP, which the list does not hold, has the whole limit.
@@ -119,6 +117,5 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
}
+3 -3
View File
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
answer, found := g.kept(client)
if !found {
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
g.metrics.GeoJSUnanswered.Inc()
}
w, waiting := g.waiting[client]
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
}
if w == nil {
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
g.metrics.GeoJSUnanswered.Inc()
return Answer{}, nil // too many clients wait already
}
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
}
if w.late {
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
g.metrics.GeoJSUnanswered.Inc()
return Answer{}, nil
}
+1 -1
View File
@@ -846,7 +846,7 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
t.Helper()
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
got := testutil.ToFloat64(m.GeoJSUnanswered)
if got != want {
t.Errorf("%v requests went without an answer, want %v", got, want)
}
+29 -29
View File
@@ -28,15 +28,15 @@ type Metrics struct {
registry prometheus.Registerer
handler http.Handler
inFlight prometheus.Gauge
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec
requestDuration prometheus.Histogram
upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec
coreRuleSetMatches *prometheus.CounterVec
inFlight prometheus.Gauge
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec
requestDuration prometheus.Histogram
upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec
wafMatches *prometheus.CounterVec
// ruleMatches are made by AddRules, and reputationHits by
// AddReputation.
ruleMatches *prometheus.CounterVec
@@ -45,12 +45,12 @@ type Metrics struct {
asns *busiest
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
// that failed. RequestsWithoutGeoJSAnswer are the requests that needed
// their client's answer, for a setting that acts on it, and went on
// without it because GeoJS had not given it in time.
GeoJSRequests prometheus.Counter
GeoJSFailures prometheus.Counter
RequestsWithoutGeoJSAnswer prometheus.Counter
// that failed. GeoJSUnanswered are the requests that needed their
// client's answer, for a setting that acts on it, and went on without
// it because GeoJS had not given it in time.
GeoJSRequests prometheus.Counter
GeoJSFailures prometheus.Counter
GeoJSUnanswered prometheus.Counter
stateFileWrites *prometheus.CounterVec
stateFileWriteFailures *prometheus.CounterVec
@@ -101,7 +101,7 @@ func New(topN int, instanceName string) *Metrics {
[]string{"window", "kind"}),
offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}),
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
wafMatches: counterVec("sww_waf_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN),
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.",
}),
RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_requests_without_geojs_answer_total",
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_geojs_unanswered_total",
Help: "Requests that needed their client's answer from GeoJS and " +
"went on without it, because GeoJS had not given it in time.",
}),
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
m.requestDuration, m.upstreamDuration,
m.rateLimitHits, m.offences, m.coreRuleSetMatches,
m.rateLimitHits, m.offences, m.wafMatches,
m.countries, m.asns,
m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
m.stateFileWrites, m.stateFileWriteFailures,
m.stateFileLastWrite, m.stateFileSize,
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
@@ -232,7 +232,7 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
return float64(remote.Dropped())
}),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_remote_log_lines_waiting",
Name: "sww_remote_log_buffer_depth",
Help: "Log lines in the buffer, waiting to be sent.",
}, func() float64 {
return float64(remote.Depth())
@@ -355,7 +355,7 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
return float64(queue.Counts(name).Sent)
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alert_send_failures_total",
Name: "sww_alerts_failed_total",
Help: "Requests to the destination that failed.",
ConstLabels: destination,
}, func() float64 {
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
// them.
func (m *Metrics) Offences(r ratelimit.Request) {
for kind, committed := range map[string]bool{
"limit": r.BrokeLimit,
"attack": r.Attack,
"blocked_by_block_rule": r.BlockedByBlockRule,
"blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
"limit": r.BrokeLimit,
"attack": r.Attack,
"blocked_by_block_rule": r.BlockedByBlockRule,
"waf_blocked": r.WAFBlocked,
} {
if committed {
m.offences.WithLabelValues(kind).Inc()
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
m.ruleMatches.WithLabelValues(id, action).Inc()
}
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
// WAFMatched counts a request that matched the Core Rule Set's rule id,
// with SWW_WAF_MODE at mode.
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
func (m *Metrics) WAFMatched(mode string, id int) {
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
}
// StateFileWritten counts a write of the state file name, of size bytes,
+12 -13
View File
@@ -123,12 +123,11 @@ type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
// refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
// block rule refused, and WAFBlocked those the Core Rule Set refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
WAFBlocked int64 `json:"waf_blocked"`
// TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"`
@@ -149,11 +148,11 @@ type Request struct {
// BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
BrokeLimit bool
Attack bool
BlockedByBlockRule bool
BlockedByCoreRuleSet bool
// and WAFBlocked for one the Core Rule Set refused.
BrokeLimit bool
Attack bool
BlockedByBlockRule bool
WAFBlocked bool
}
// New returns a Limiter for limits, with no client counted yet, whose
@@ -328,8 +327,8 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
h.Offences.BlockedByBlockRule++
}
if r.BlockedByCoreRuleSet {
h.Offences.BlockedByCoreRuleSet++
if r.WAFBlocked {
h.Offences.WAFBlocked++
}
}
+37 -42
View File
@@ -42,9 +42,9 @@ const (
// block rule.
ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked"
// ActionClientDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
@@ -55,9 +55,9 @@ const (
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
// ActionOwnEndpoint is a request discriminator answered at one of its own
// ActionAdmin is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/.
ActionOwnEndpoint = "own_endpoint"
ActionAdmin = "admin"
)
// OffenceLimit is the offence a request line names for a request that
@@ -65,9 +65,9 @@ const (
// limit.
const OffenceLimit = "limit"
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
// LimitHitErrorBurst is the limit_hit a request line names for a request
// that broke the error burst.
const LimitHitMinuteRefusals = "minute_refusals"
const LimitHitErrorBurst = "error_burst"
// The discriminator_line a request line names, which says which of
// discriminator's two lines about a question it is.
@@ -141,9 +141,8 @@ type Line struct {
// WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionClientDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
// ActionBlockedByCoreRuleSet, or, in discriminator,
// ActionRequestHeaderTooLarge for the header limits.
// ActionClientIsRateLimited, ActionBlockedByBlockRule, ActionWAFBlocked,
// or, in discriminator, ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by
@@ -155,15 +154,15 @@ type Line struct {
// request.
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
// RateLimitPercent and RateLimitPercentSetting are, for a request the
// rate limits counted whose client a biased threshold gives a percentage
// of the rate limits below 100, that percentage and the setting that gave
// it. BytesLimitPercent and BytesLimitPercentSetting are the same for the
// byte limits.
RateLimitPercent *int64 `json:"rate_limit_percent,omitempty"`
RateLimitPercentSetting string `json:"rate_limit_percent_setting,omitempty"`
BytesLimitPercent *int64 `json:"bytes_limit_percent,omitempty"`
BytesLimitPercentSetting string `json:"bytes_limit_percent_setting,omitempty"`
// LimitPercent and LimitPercentSetting are, for a request the rate
// limits counted whose client a biased threshold gives a percentage of
// the rate limits below 100, that percentage and the setting that gave
// it. BytesPercent and BytesPercentSetting are the same for the byte
// limits.
LimitPercent *int64 `json:"limit_percent,omitempty"`
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
BytesPercent *int64 `json:"bytes_percent,omitempty"`
BytesPercentSetting string `json:"bytes_percent_setting,omitempty"`
// Counts are, for a request the rate limits counted, the client's
// requests as they counted them with this one, and its bytes as the
// byte limits counted them, with this request's once it has ended if
@@ -171,15 +170,15 @@ type Line struct {
Counts ratelimit.Counts `json:"counts,omitzero"`
// RuleIDs are the ids of the rule file rules the request matched.
RuleIDs []string `json:"rule_ids,omitempty"`
// CoreRuleSetRuleIDs are the ids of the Core Rule Set's rules the request
// matched, and CoreRuleSetScore its anomaly score, nil for a request the
// Core Rule Set did not inspect.
CoreRuleSetRuleIDs []int `json:"core_rule_set_rule_ids,omitempty"`
CoreRuleSetScore *int `json:"core_rule_set_score,omitempty"`
// WAFRuleIDs are the ids of the Core Rule Set's rules the request
// matched, and WAFScore its anomaly score, nil for a request the Core
// Rule Set did not inspect.
WAFRuleIDs []int `json:"waf_rule_ids,omitempty"`
WAFScore *int `json:"waf_score,omitempty"`
// LimitHit is the window whose limit the request went over, named as
// Counts names its count: minute, hour or day for a rate limit, and
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
// LimitHitMinuteRefusals for the error burst.
// LimitHitErrorBurst for the error burst.
LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then
// that of the CrowdSec decision list when it does, then the DNSBL zones
@@ -188,25 +187,21 @@ type Line struct {
Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"`
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
// request got no tags from routewatch: no_answer_yet, request_failed or
// no_route.
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
// Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"`
// BanExpires is when the ban the request made, or was refused under,
// ends: a time, or "permanent".
BanExpires string `json:"ban_expires,omitempty"`
// The timings. DurationChecksMS is the time until the checks were done, and
// DurationCoreRuleSetMS the part of it the Core Rule Set took.
// DurationUpstreamTotalMS is what the report of an allowed request gives,
// The timings, in milliseconds. DurationChecks is the time until the
// checks were done, and DurationWAF the part of it the Core Rule Set
// took. DurationUpstreamTotal is what the report of an allowed request gives,
// from the hand-over to the app until the end of its answer. Each but
// DurationTotalMS is nil for a request that did not get that far.
DurationTotalMS float64 `json:"duration_total_ms"`
DurationChecksMS *float64 `json:"duration_checks_ms,omitempty"`
DurationCoreRuleSetMS *float64 `json:"duration_core_rule_set_ms,omitempty"`
DurationUpstreamTotalMS *float64 `json:"duration_upstream_total_ms,omitempty"`
// DurationTotal is nil for a request that did not get that far.
DurationTotal float64 `json:"duration_total"`
DurationChecks *float64 `json:"duration_checks,omitempty"`
DurationWAF *float64 `json:"duration_waf,omitempty"`
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
}
// RProxyLine is rproxy's short line in the request log about one request. The
@@ -226,11 +221,11 @@ type RProxyLine struct {
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
// DurationRequestMS runs from the request's headers being read until the app
// has been sent the whole request, or the answer has started if that is
// sooner. DurationResponseMS runs from then until the answer ends.
DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"`
// DurationRequest runs in milliseconds from the request's headers being read
// until the app has been sent the whole request, or the answer has started
// if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"`
DurationResponse float64 `json:"duration_response"`
// Disposition is the valid disposition discriminator answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
+9 -9
View File
@@ -18,11 +18,11 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
var out bytes.Buffer
err := requestlog.Write(&out, &requestlog.Line{
Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)),
ClientIP: "203.0.113.9",
Status: 200,
Action: requestlog.ActionForward,
DurationTotalMS: requestlog.Milliseconds(1500 * time.Microsecond),
Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)),
ClientIP: "203.0.113.9",
Status: 200,
Action: requestlog.ActionForward,
DurationTotal: requestlog.Milliseconds(1500 * time.Microsecond),
})
if err != nil {
t.Fatalf("write: %v", err)
@@ -43,7 +43,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
want := map[string]any{
"type": "request", "time": "2026-10-03T12:00:00.000Z",
"client_ip": "203.0.113.9", "status": 200.0, "action": "forward",
"duration_total_ms": 1.5,
"duration_total": 1.5,
}
for name, value := range want {
if fields[name] != value {
@@ -55,9 +55,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
"forwarded_for", "content_type", "content_length", "request_headers",
"has_authorization", "has_cookie", "websocket", "response_content_type",
"upstream_status", "cache_control", "location", "aborted", "counts",
"limit_hit", "offence", "ban_expires", "duration_checks_ms",
"limit_hit", "offence", "ban_expires", "duration_checks",
"duration_upstream_connect", "duration_upstream_first_byte",
"duration_upstream_total_ms",
"duration_upstream_total",
}
for _, name := range unset {
_, present := fields[name]
@@ -82,7 +82,7 @@ func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
`"response_bytes":0,"duration_request_ms":0,"duration_response_ms":0,` +
`"response_bytes":0,"duration_request":0,"duration_response":0,` +
`"disposition":"allow"}` + "\n"
if out.String() != want {
t.Errorf("wrote %s, want %s", out.String(), want)
-243
View File
@@ -1,243 +0,0 @@
// Package routewatch asks a routewatch instance about client addresses, in the
// background, and keeps its answers in memory. A routewatch instance knows, for
// an address, the netblock it is announced in and the AS that originates it.
package routewatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/netip"
"net/url"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2/simplelru"
)
const (
// answerKept is how long an answer is used after it came.
answerKept = time.Hour
// failureKept is how long routewatch is not asked again after a query
// fails, so that a dead routewatch is not asked on every request.
failureKept = time.Minute
// queryTimeout is how long one query may take. routewatch answers only
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
queryTimeout = 5 * time.Second
// maxAnswers is how many answers are kept. Past it, the one that came
// longest ago is dropped.
maxAnswers = 100000
// maxQueries is how many queries may be under way at once. Past it, a
// client is not asked about until its next request, so that a swarm of
// new addresses cannot fill the memory.
maxQueries = 100
// maxAnswerBytes is the most of an answer that is read.
maxAnswerBytes = 64 << 10
)
// Missing is why Route gives no route. The request log's field
// routewatch_tags_missing gives these values.
type Missing string
const (
// NoAnswerYet is a client routewatch has not answered about yet.
NoAnswerYet Missing = "no_answer_yet"
// RequestFailed is no answer because a recent query failed.
RequestFailed Missing = "request_failed"
// NoRoute is routewatch knowing no live route for the address.
NoRoute Missing = "no_route"
)
var (
errStatus = errors.New("routewatch answered")
errNoResult = errors.New("the answer holds no result")
errNotNetblock = errors.New("the answer's netblock is not a netblock")
)
// Route is what routewatch knows of an address: the netblock it is announced
// in, and the AS that originates it.
type Route struct {
Netblock netip.Prefix
ASN uint32
}
// Params are what New needs.
type Params struct {
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
URL string
// Now tells the time, normally time.Now in UTC.
Now func() time.Time
// ProcessLog receives the query that fails, and why.
ProcessLog *slog.Logger
}
// Client asks a routewatch instance about clients, in the background, and keeps
// its answers. It is safe for concurrent use.
type Client struct {
params Params
httpClient *http.Client
mu sync.Mutex
// answers are by client. Each is added as it comes and never moved up,
// so that the one that came longest ago is the first dropped.
answers *simplelru.LRU[netip.Prefix, answer]
// asking are the clients whose query is under way.
asking map[netip.Prefix]bool
// retryAt is when routewatch may be asked again after a query failed.
retryAt time.Time
}
// answer is one kept answer: the route, or why there is none, and when it came.
type answer struct {
route Route
missing Missing
came time.Time
}
// New returns a Client with no answer yet.
func New(params Params) *Client {
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
return &Client{
params: params,
httpClient: &http.Client{},
answers: answers,
asking: map[netip.Prefix]bool{},
}
}
// Route returns what routewatch answered about client, an IPv4 address or an
// IPv6 group, or why it has no route. It never waits for a query. A client
// without an answer is asked about in the background, by addr, the address its
// request came from, unless its query is under way, a query failed less than
// failureKept ago, or maxQueries are under way. ctx is the context of the
// client's request, and a query goes on after the request ends.
func (c *Client) Route(
ctx context.Context, client netip.Prefix, addr netip.Addr,
) (Route, Missing) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.params.Now()
kept, found := c.answers.Peek(client)
if found && now.Sub(kept.came) < answerKept {
return kept.route, kept.missing
}
if now.Before(c.retryAt) {
return Route{}, RequestFailed
}
if !c.asking[client] && len(c.asking) < maxQueries {
c.asking[client] = true
go c.ask(context.WithoutCancel(ctx), client, addr)
}
return Route{}, NoAnswerYet
}
// ask asks routewatch about addr, keeps the answer as client's, and notes the
// query as no longer under way. A query that fails gives no answer, and
// routewatch is not asked again for failureKept. Only the first failure of
// those under way is logged.
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
route, missing, err := c.query(ctx, addr)
now := c.params.Now()
c.mu.Lock()
delete(c.asking, client)
firstFailure := err != nil && !now.Before(c.retryAt)
if err == nil {
c.answers.Add(client, answer{route: route, missing: missing, came: now})
} else {
c.retryAt = now.Add(failureKept)
}
c.mu.Unlock()
if firstFailure {
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
}
}
// query asks routewatch's IP info API about addr. An answer other than 200, one
// that cannot be read, and none within queryTimeout, fail. A result with no
// netblock is an address without a live route.
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
if err != nil {
return Route{}, "", fmt.Errorf("make the URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return Route{}, "", fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Accept", "application/json")
res, err := c.httpClient.Do(req)
if err != nil {
// Do's error names the URL, which holds the client's address, which
// is not to be logged: only what went wrong is kept.
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
}
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
}
// readAnswer reads routewatch's answer about one address.
func readAnswer(body io.Reader) (Route, Missing, error) {
var decoded struct {
Data struct {
Results []struct {
Netblock string `json:"netblock"`
ASN uint32 `json:"asn"`
} `json:"results"`
} `json:"data"`
}
err := json.NewDecoder(body).Decode(&decoded)
if err != nil {
return Route{}, "", fmt.Errorf("read the answer: %w", err)
}
if len(decoded.Data.Results) == 0 {
return Route{}, "", errNoResult
}
result := decoded.Data.Results[0]
if result.Netblock == "" {
return Route{}, NoRoute, nil
}
netblock, err := netip.ParsePrefix(result.Netblock)
if err != nil {
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
}
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
}
-333
View File
@@ -1,333 +0,0 @@
package routewatch_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
)
const (
clientAddr = "192.0.2.1"
routedAnswer = `{"status":"ok","data":{"results":[` +
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
slowAnswer = 2500 * time.Millisecond
// waitLimit is longer than the Client allows a query.
waitLimit = 10 * time.Second
)
// clock is a time the tests move by hand.
type clock struct {
mu sync.Mutex
now time.Time
}
func (c *clock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *clock) advance(by time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(by)
}
// fixture is a Client asking a stand-in for routewatch. queries counts what the
// stand-in was asked, paths holds the last path it was asked for, and log holds
// the process log.
type fixture struct {
t *testing.T
server *httptest.Server
client *routewatch.Client
clk *clock
queries *atomic.Int32
path *atomic.Value
log *syncBuffer
}
// syncBuffer is a buffer the background queries can log to while a test reads it.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
t.Helper()
f := &fixture{
t: t,
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
queries: &atomic.Int32{},
path: &atomic.Value{},
log: &syncBuffer{},
}
f.server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
f.queries.Add(1)
f.path.Store(r.URL.Path)
standIn(w, r)
}))
t.Cleanup(f.server.Close)
f.client = routewatch.New(routewatch.Params{
URL: f.server.URL + "/", Now: f.clk.Now,
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
})
return f
}
func answering(body string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}
}
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
addr := netip.MustParseAddr(clientAddr)
client := netip.PrefixFrom(addr, addr.BitLen())
return f.client.Route(context.Background(), client, addr)
}
// waitFor asks until the answer is why, or, while why is "", until there is a
// route, and returns the route.
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
f.t.Helper()
deadline := time.Now().Add(waitLimit)
for {
route, missing := f.route()
if missing == why {
return route
}
if time.Now().After(deadline) {
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
}
time.Sleep(time.Millisecond)
}
}
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
// query, and checks that it does not name the client.
func (f *fixture) wantFailureLoggedWithoutTheClient() {
f.t.Helper()
// The failure is noted before it is logged.
deadline := time.Now().Add(waitLimit)
for !strings.Contains(f.log.String(), "asking routewatch failed") {
if time.Now().After(deadline) {
f.t.Fatalf("process log %q names no failure", f.log.String())
}
time.Sleep(time.Millisecond)
}
if strings.Contains(f.log.String(), clientAddr) {
f.t.Errorf("process log %q names the client", f.log.String())
}
}
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
}
route := f.waitFor("")
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
if route != want {
t.Errorf("route %+v, want %+v", route, want)
}
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
t.Errorf("asked for path %v", got)
}
}
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
t.Parallel()
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(slowAnswer)
_, _ = w.Write([]byte(routedAnswer))
})
f.route()
route := f.waitFor("")
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
if route != want {
t.Errorf("route %+v, want %+v", route, want)
}
}
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(noRouteAnswer))
f.route()
f.waitFor(routewatch.NoRoute)
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries, want 1", got)
}
}
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
t.Parallel()
tests := map[string]http.HandlerFunc{
"500": func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
"not JSON": answering("no"),
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
"bad netblock": answering(
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
}
for name, standIn := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := newFixture(t, standIn)
f.route()
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
})
}
}
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
f.server.Close()
f.route()
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
}
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
t.Parallel()
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
f.route()
f.waitFor(routewatch.RequestFailed)
f.route()
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries while the failure is kept, want 1", got)
}
f.clk.advance(time.Minute)
f.route()
deadline := time.Now().Add(waitLimit)
for f.queries.Load() != 2 {
if time.Now().After(deadline) {
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
}
time.Sleep(time.Millisecond)
}
}
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
t.Parallel()
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
start := time.Now()
f.route()
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Route took %s while routewatch hangs", elapsed)
}
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
}
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
t.Parallel()
release := make(chan struct{})
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
<-release
_, _ = w.Write([]byte(routedAnswer))
})
for range 50 {
f.route()
}
close(release)
f.waitFor("")
for range 50 {
f.route()
}
if got := f.queries.Load(); got != 1 {
t.Errorf("%d queries for repeated requests, want 1", got)
}
f.clk.advance(time.Hour)
f.waitFor(routewatch.NoAnswerYet)
f.waitFor("")
if got := f.queries.Load(); got != 2 {
t.Errorf("%d queries after the answer expired, want 2", got)
}
}
+8 -8
View File
@@ -371,8 +371,8 @@ func (rq *request) finish(ctx context.Context) {
Status: rq.out.status,
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
})
@@ -385,14 +385,14 @@ func (rq *request) finish(ctx context.Context) {
}
report := &discriminator.Report{
RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)),
RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
}
if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
}
rq.h.sendReport(ctx, report)
+6 -6
View File
@@ -727,7 +727,7 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
report := standIn.report(t)
if report.Status != http.StatusForbidden ||
report.ResponseBytes != int64(len(deniedBody)) ||
report.DurationUpstreamTotalMS != 0 {
report.DurationUpstreamTotal != 0 {
t.Errorf("report %+v, of a request denied", report)
}
@@ -871,9 +871,9 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
line := out.requestLine(t)
duration, ok := line["duration_response_ms"].(float64)
duration, ok := line["duration_response"].(float64)
if !ok || duration < 0 {
t.Errorf("line %v, want a duration_response_ms that is not negative", line)
t.Errorf("line %v, want a duration_response that is not negative", line)
}
}
}
@@ -1428,8 +1428,8 @@ func TestReportGivesWhatHappened(t *testing.T) {
if report.RequestID == "" || report.Status != http.StatusOK ||
report.RequestBytes != int64(len(body)) ||
report.ResponseBytes != int64(len(greeting)) ||
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 ||
report.DurationUpstreamTotalMS > report.DurationTotalMS {
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
report.DurationUpstreamTotal > report.DurationTotal {
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
}
@@ -1748,7 +1748,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
}
for _, name := range []string{"duration_request_ms", "duration_response_ms"} {
for _, name := range []string{"duration_request", "duration_response"} {
duration, ok := line[name].(float64)
if !ok || duration <= 0 {
t.Errorf("%s is %v, want a time", name, line[name])
+1 -1
View File
@@ -891,7 +891,7 @@ func missingFromCounter(counter anomaly.Counter) string {
return "netblock"
case scope == anomaly.ScopeASN && counter.ASN == "":
return "asn"
case scope == anomaly.ScopeWatchedNet && counter.Name == "":
case scope == anomaly.ScopeWatch && counter.Name == "":
return "name"
case countsWithoutStart(counter.Minute):
return "minute.start"
+4 -4
View File
@@ -208,7 +208,7 @@ const filledAlertsJSON = `{
}
},
{
"scope": "watched_net",
"scope": "watch",
"netblock": "203.0.113.0/24",
"name": "office",
"hour": {
@@ -831,12 +831,12 @@ func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
{
"an anomaly counter of a named netblock without its name",
`{"version": 1, "anomaly_counters": [` +
`{"scope": "watched_net", "netblock": "203.0.113.0/24"}]}`,
`{"scope": "watch", "netblock": "203.0.113.0/24"}]}`,
`: anomaly_counters entry 1 has no "name"`,
},
{
"an anomaly counter of a named netblock without its netblock",
`{"version": 1, "anomaly_counters": [{"scope": "watched_net", "name": "office"}]}`,
`{"version": 1, "anomaly_counters": [{"scope": "watch", "name": "office"}]}`,
`: anomaly_counters entry 1 has no "netblock"`,
},
{
@@ -1729,7 +1729,7 @@ func newParams(dir string) state.Params {
Dir: dir,
WriteDelay: time.Hour,
CounterInterval: time.Hour,
Ledger: bans.New(bans.Params{
Ledger: bans.New(bans.Rules{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
+2 -2
View File
@@ -22,7 +22,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
@@ -30,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// shutdownTimeout is how long requests in progress may take to finish
@@ -197,7 +197,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *coreruleset.CoreRuleSet,
crs *waf.CoreRuleSet,
) (*discriminator.Server, error) {
var lookupFile *lookup.File
+3 -7
View File
@@ -962,7 +962,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
metrics := metricsText(t, url+"_sww/metrics", token)
for _, series := range []string{
`sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
`sww_remote_log_lines_waiting{instance="fsn1app1/blog"} 1`,
`sww_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
} {
if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics)
@@ -1068,12 +1068,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
metrics := metricsWith(t, url+"_sww/metrics", token,
"\nsww_alerts_sent_total"+ofWebhook+" 1\n")
for _, series := range []string{
"sww_alert_send_failures_total",
"sww_alerts_suppressed_total",
"sww_alerts_dropped_total",
} {
zero := "\n" + series + ofWebhook + " 0\n"
for _, series := range []string{"failed", "suppressed", "dropped"} {
zero := "\nsww_alerts_" + series + "_total" + ofWebhook + " 0\n"
if !strings.Contains(metrics, zero) {
t.Errorf("no %q in the metrics:\n%s", zero, metrics)
}
File renamed without changes.
@@ -1,6 +1,6 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method.
# Searches with ordinary words and symbols.
@@ -1,4 +1,4 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the eight changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
@@ -9,7 +9,7 @@
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
// file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory.
package coreruleset
package waf
import (
_ "embed"
@@ -1,4 +1,4 @@
package coreruleset_test
package waf_test
import (
"net/http"
@@ -12,7 +12,7 @@ import (
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
@@ -23,12 +23,10 @@ var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
// the rules in disabled switched off.
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet {
t.Helper()
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: level, DisabledRules: disabled,
})
crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
@@ -51,7 +49,7 @@ func get(target string, headers ...string) request {
// inspect returns what crs finds in r, sent to git.example by a browser,
// whose Host, User-Agent and Accept r.headers may replace.
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
t.Helper()
result, _ := inspectBody(t, crs, r, "")
@@ -62,8 +60,8 @@ func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.
// inspectBody is inspect for r with body, which is announced with its
// Content-Length unless it is "", and returns what crs read of body too.
func inspectBody(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
) (coreruleset.Result, string) {
t *testing.T, crs *waf.CoreRuleSet, r request, body string,
) (waf.Result, string) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), r.method,
@@ -98,9 +96,7 @@ func inspectBody(
}
// wantResult checks what crs finds in r.
func wantResult(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
) {
func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) {
t.Helper()
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
@@ -110,14 +106,14 @@ func wantResult(
// matched is the result of a request that the rules ids match, each of
// them a critical one, which adds 5 to the score.
func matched(ids ...int) coreruleset.Result {
func matched(ids ...int) waf.Result {
const critical = 5
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
return waf.Result{RuleIDs: ids, Score: critical * len(ids)}
}
// atDefaults returns the Core Rule Set as sww runs it by default.
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
func atDefaults(t *testing.T) *waf.CoreRuleSet {
t.Helper()
return newCoreRuleSet(t, 1, defaultDisabledRules...)
@@ -127,12 +123,11 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
// the eight changes is for, and still finds result in refused, a request
// like it that the change is not for.
func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
result coreruleset.Result,
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
) {
t.Helper()
wantResult(t, crs, passes, coreruleset.Result{})
wantResult(t, crs, passes, waf.Result{})
wantResult(t, crs, refused, result)
}
@@ -167,12 +162,12 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
wantResult(t, crs,
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
coreruleset.Result{})
waf.Result{})
wantResult(t, crs,
request{http.MethodPost, fetch, []string{
fetchType, length, "Content-Encoding: gzip",
}},
coreruleset.Result{})
waf.Result{})
// Every other header on the Core Rule Set's list stays refused.
for _, header := range []string{
@@ -201,7 +196,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
"Content-Type: application/x-git-receive-pack-request",
"Transfer-Encoding: chunked",
}},
coreruleset.Result{})
waf.Result{})
}
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
@@ -255,7 +250,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
for _, value := range []struct {
name string
// result is what a parameter that is not one of gitea's gets.
result coreruleset.Result
result waf.Result
}{
// A file on the list of system files.
{".gitignore", matched(930120)},
@@ -276,7 +271,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
matched(930120, 932160))
wantResult(t, crs, get("/?path=../../etc/passwd"),
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
waf.Result{RuleIDs: []int{930100, 930110}, Score: 20})
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
}
@@ -346,7 +341,7 @@ func TestEmptyHeaderIsRead(t *testing.T) {
// An empty User-Agent is a notice, which adds 2.
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
waf.Result{RuleIDs: []int{920330}, Score: 2})
}
func TestParanoiaLevel(t *testing.T) {
@@ -355,7 +350,7 @@ func TestParanoiaLevel(t *testing.T) {
// Accept-Charset is refused from paranoia level 2.
r := get("/", "Accept-Charset: utf-8")
wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{})
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
}
@@ -367,8 +362,8 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
wantResult(t, newCoreRuleSet(t, 1), r,
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
}
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
@@ -388,8 +383,8 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
// withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path.
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
return coreruleset.New(coreruleset.Params{
func withExclusions(path string) (*waf.CoreRuleSet, error) {
return waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
})
}
@@ -427,7 +422,7 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel()
crs, err := coreruleset.New(coreruleset.Params{
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
})
@@ -435,7 +430,7 @@ func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Fatalf("load the exclusions: %v", err)
}
wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
}
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
@@ -571,10 +566,10 @@ const injection = "1' OR '1'='1"
// readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
func readingBodies(t *testing.T) *waf.CoreRuleSet {
t.Helper()
crs, err := coreruleset.New(coreruleset.Params{
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})
if err != nil {
@@ -608,8 +603,7 @@ func padded(head, tail string, n int) string {
// wantBody checks what crs finds in r with body, and that what it read of
// body is read.
func wantBody(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
want coreruleset.Result,
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result,
read string,
) {
t.Helper()
@@ -633,7 +627,7 @@ func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
{jsonBody, `{"q":"` + injection + `"}`},
{xmlBody, "<q>" + injection + "</q>"},
} {
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "")
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
}
}
@@ -656,7 +650,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
} {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1])
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{},
tc.attackLast[:bodyLimit+1])
}
@@ -666,7 +660,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit)
}
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
@@ -682,7 +676,7 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1)
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger)
}
}
@@ -700,7 +694,7 @@ func TestOtherBodiesAreNotRead(t *testing.T) {
"Content-Type: text/plain",
"Content-Type: application/x-git-receive-pack-request",
} {
wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
wantBody(t, crs, post(header), body, waf.Result{}, "")
}
}
@@ -728,8 +722,8 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.
// It is allowed on a body of any other kind, and on every body while no
// body is read.
fetch := "Content-Type: application/x-git-upload-pack-request"
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "")
}
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
@@ -740,18 +734,18 @@ func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
for _, tc := range []struct {
body string
want coreruleset.Result
want waf.Result
}{
{"path=.gitignore", coreruleset.Result{}},
{"path=.gitignore", waf.Result{}},
{"q=.gitignore", matched(930120)},
{"redirect_uri=" + local, coreruleset.Result{}},
{"redirect_uri=" + local, waf.Result{}},
{"next=" + local, matched(931100, 934110)},
} {
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
}
body := field("path", ".gitignore") + end
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
wantBody(t, crs, post(multipart), body, waf.Result{}, body)
body = field("q", ".gitignore") + end
wantBody(t, crs, post(multipart), body, matched(930120), body)
@@ -875,7 +869,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel()
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
}