Author SHA1 Message Date
clawbot 1f1e97cb4e Request tags, with refusing and limits per tag (closes #175)
check / check (push) Waiting to run
Each request gets sorted tags from the lookup, new address lists, the
reputation sources and the rule matches, logged as `tags`. New settings:
`SWW_TAG_LISTS`, `SWW_TAG_DENY`, `SWW_TAG_LIMIT_PERCENT`. They replace
`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT` and
`SWW_DENIED_COUNTRIES`. New heuristics `tag_lists` and `tag_deny`; the
latter runs before the rate limits and again after the Core Rule Set.

Model: opus-5-5
2026-11-09 00:39:59 +00:00
clawbot 2349b2a081 discriminator: each decision heuristic is its own module (closes #195)
check / check (push) Failing after 1m53s
Each discriminator check is now a type in its own file behind one small interface, listed in `heuristics.go`. Adding a heuristic is one file and one line. Behaviour does not change.

Refusal and ban log lines gain `heuristic` and `heuristic_figures`. A ban after the answer, by `refusal_limit` or `byte_limits`, logs `after_answer_heuristic` and its figures, so one line can name both.

Deviation: four lists, not the planned three, so `refusal_limit` still bans at the decision.
Rule suppressed: `//nolint:lll` on one struct tag.

Model: opus-5-5
2026-11-09 01:14:24 +01:00
clawbot 42e06bf298 Quiet the update-alternatives man page warnings in the image build (closes #222) (#226)
check / check (push) Successful in 5m24s
xz-utils, which nix-bin depends on, links its man pages with update-alternatives when it installs, and the image's dpkg config drops man pages, so each link printed a warning. A config file re-includes only the man1 pages for this install; the same step deletes them, their dangling links in /etc/alternatives and the config file. No man page or package is added or removed.

Model: deepseek-v4-pro
2026-11-07 15:21:18 +01:00
60 changed files with 2398 additions and 1335 deletions

No files matched your search

+11 -2
View File
@@ -136,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
# them over plain HTTP and checks each one against the archive's signed
# package lists, so no CA certificates are needed.
RUN apt-get update \
#
# nix-bin depends on xz-utils, whose install links man pages with
# update-alternatives. The image's dpkg config drops man pages, so each
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
# the man1 pages for this install alone; the same step deletes them again,
# so no man page or package is added.
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
&& find /usr/share/man/man1 ! -type d -delete \
&& find /etc/alternatives -type l -xtype l -delete
# Nix run by root expects a group of build users, which nix-bin does not
# create; with the setting empty, root's builds run without them.
+151 -139
View File
@@ -215,123 +215,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`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `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 the request and byte limits neither count nor refuse. |
| `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`; one more bans it. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `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 limit broken again within this time after the 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`, 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_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
| `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; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `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_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
| `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 one client's requests carry more bytes than this in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this 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 the netblock around a client carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this 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 one AS number carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this 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 the whole service carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this 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 a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this 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`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `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 the request and byte limits neither count nor refuse. |
| `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`; one more bans it. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `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 limit broken again within this time after the 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`, 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 one client's requests carry more bytes than this in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this 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 the netblock around a client carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this 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 one AS number carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this 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 the whole service carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this 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 a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this 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.
@@ -414,8 +413,7 @@ services:
SWW_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits.
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWW_COUNTRY_LIMIT_PERCENT: cn:25
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=cn:25
# Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
@@ -510,28 +508,31 @@ of the request reaches the app.
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
4. The bans.
5. The lookup of the client's AS number and country.
6. The country lists.
7. The reputation sources.
8. The client's limit percentage, from its AS number, its country or a
reputation hit.
9. The rate limits.
10. The trap paths.
11. The rule files.
12. The header limits, and then the Core Rule Set.
13. A path under `/_sww/` is answered `404`, so that the app never sees that
6. The tag lists.
7. The country list.
8. The reputation sources.
9. The tag refusals of `SWW_TAG_DENY`.
10. The client's limit percentage, from its tags, its AS number, its country or
a reputation hit.
11. The rate limits.
12. The trap paths.
13. The rule files.
14. The header limits, and then the Core Rule Set.
15. The tag refusals again, so that `exploitsignature` can refuse too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix.
`rproxy` takes the rest, with `discriminator` counting at the end:
14. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `discriminator` a report with the status
17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `discriminator` a report with the status
and the bytes each way. For an allowed request, `discriminator` counts the
bytes toward the byte limits and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
request over the header limits of step 12 is answered `431`, and bans nobody
request over the header limits of step 14 is answered `431`, and bans nobody
either. `rproxy` itself answers `405` to a method it does not pass on, and the
status for a size or time limit, such as `413` for a body too large.
@@ -550,21 +551,32 @@ status for a size or time limit, such as `413` for a body too large.
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
### Request tags
Each request gets tags from what `discriminator` knows of its client, written in
the `tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
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.
`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).
### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the
rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes the changes; "Attack detection" in
[`SPEC.md`](SPEC.md) lists them.
- Each supported app also has its own fixes, which let its ordinary requests
through. gitea's let its searches, file names and branch names through.
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
apps' fixes, and a hostname it does not list gets none. Unset, every app's
fixes apply to every hostname.
Git Credential Manager. On gitea's own paths, its searches, file names and
branch names get through too. No setting undoes the changes; "Attack
detection" in [`SPEC.md`](SPEC.md) lists them.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
+155 -127
View File
@@ -81,8 +81,8 @@ 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/waf`: the Core Rule Set, with the six changes and the apps'
fixes 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
@@ -119,12 +119,14 @@ to them.
## What happens to a request
`rproxy` runs steps 1, 2 and 14, and sends the report of step 15.
`discriminator` runs steps 3 to 13, and does the counting of step 15. The first
step that produces a final answer ends the checks. Step 15 runs for each request
`rproxy` runs steps 1, 2 and 17, and sends the report of step 18.
`discriminator` runs steps 3 to 16, and does the counting of step 18. The first
step that produces a final answer ends the checks. Step 18 runs for each request
`discriminator` answers with `allow`, `deny` or `close`. `rproxy`'s size and
time limits apply to the whole exchange. Each step names the section that holds
its rules.
its rules. A step that names a heuristic in parentheses runs that heuristic. The
request log's `heuristic` field gives that name. `refusal_limit` and
`byte_limits` give theirs in `after_answer_heuristic` instead.
1. `GET /_sww/healthz` is answered at once by `rproxy`, before any check, so a
health checker never needs an exemption and is never refused.
@@ -141,55 +143,67 @@ its rules.
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 to 12, the byte
limits and the error burst. A client in `SWW_DENY_NETS` is refused; a client
in both lists is let through.
4. Bans. A client under a ban is refused (see "Bans").
3. The static lists. A client in `SWW_ALLOW_NETS` skips steps 4 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.
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
5. The lookup, unless `SWW_LOOKUP_SOURCE` is `off` (see "Lookup of AS number and
country"). The request goes on at once. It waits for the first GeoJS answer,
up to `SWW_LOOKUP_TIMEOUT`, only when one of these needs it:
- a country list;
- the country list;
- a biased threshold;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
tag;
- `SWW_ADD_LOOKUP_HEADERS`.
A request that ends before the answer comes is logged without it.
6. The country lists (see "Country lists").
7. The reputation sources (see "Reputation"). A client on a fetched blocklist
gets `SWW_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
banned. A client listed by a DNS blocklist, or scored at least
`SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWW_REPUTATION_ACTION`.
8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
times the percentage is refused and bans the client.
10. The trap paths (see "Attack detection"). A request for one is refused and
bans the client.
11. The rule files (see "Rule files"). Matching stops at the first rule that
refuses.
12. The header limits (see "Answering in time"), and then the Core Rule Set (see
"Attack detection"). A Core Rule Set match is refused with `403` while
`SWW_WAF_MODE` is `block`, and only logged and alerted while it is `detect`.
13. A path under `/_sww/` is answered `404`, so that the app never sees that
6. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
client adds its tag.
7. The country list (`countries`; see "Country list").
8. The reputation sources (see "Reputation"), in this order:
- A client on a fetched blocklist gets `SWW_BLOCKLIST_ACTION`
(`blocklists`).
- A client on the CrowdSec decision list is banned (`crowdsec`).
- A client listed by a DNS blocklist gets `SWW_REPUTATION_ACTION` (`dnsbl`).
- A client scored at least `SWW_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
`SWW_REPUTATION_ACTION` (`abuseipdb`).
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
in `SWW_TAG_DENY` is refused.
10. The client's limit percentage (see "Biased thresholds").
11. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
request over a limit times the percentage is refused and bans the client.
12. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
refused and bans the client.
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
rule that refuses.
14. The header limits (`header_limits`; see "Answering in time"), and then the
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
and alerted while it is `detect`.
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix.
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
17. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
a request to the app"). With `SWW_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
`X-Client-Country` are added for the app's own logs; a header whose value is
unknown is left out. Whatever that setting says, any such header the client
sent is removed.
15. After the visitor's answer has ended, `rproxy` sends the report (see "The
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
report"). `discriminator` then counts:
- For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as `SWW_BYTES_COUNT` says. For a
WebSocket, what it carried each way counts once it closes. A client whose
total passes a byte limit times the percentage has broken that limit and
is banned. The response in progress is not cut off.
is banned (`byte_limits`). The response in progress is not cut off.
- For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated.
For a request it refused, `discriminator` adds nothing to the byte limits.
It updates the history and the anomaly counters at its decision. It counts
the refusals the error burst counts at the decision too (see "Attack
detection").
the refusals the error burst counts at the decision too (`refusal_limit`;
see "Attack detection").
In `SWW_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `discriminator` makes no ban of its own. A
@@ -279,7 +293,8 @@ no offence:
- `SWW_DENY_NETS`;
- a blocklist or reputation hit whose action is `deny`;
- the country lists.
- the country list;
- `SWW_TAG_DENY`.
Every ban carries notes with what an admin needs to decide whether to lift it,
drawn from what `discriminator` already knows. No lookup is made for the notes
@@ -436,7 +451,7 @@ the same way.
By default there is no minute limit, so a client can download in a burst at full
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
allow several downloads of a few GiB each. A response's bytes are counted when
it ends (see step 15).
it ends (see step 18).
### Lookup of AS number and country
@@ -470,7 +485,7 @@ service that keeps its visitors' addresses on its own host.
until it comes, and its later requests do not wait. Its address is asked about
again in the background.
- A client the lookup cannot place has an unknown country and AS number, which
the country lists and the biased thresholds treat as described there. Nothing
the country list and the biased thresholds treat as described there. Nothing
else treats it differently.
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
@@ -490,12 +505,12 @@ service that keeps its visitors' addresses on its own host.
`file_error` alert and ignored. The database already loaded stays in use. A
client whose address the file does not hold counts as unknown.
### Country lists
### Country list
Both are empty by default: the defaults judge a client by what it does, not by
where it comes from.
`SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` is empty by default: the defaults judge a
client by what it does, not by where it comes from. A `country=` tag in
`SWW_TAG_DENY` refuses one country (see "Request tags").
- `SWW_DENIED_COUNTRIES` refuses every request from a listed country.
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
listed country. Every request from any other country is refused. So is every
request from a client the lookup cannot place, such as internal monitoring or
@@ -504,34 +519,70 @@ where it comes from.
client in whenever GeoJS stops answering. The cost: while GeoJS fails, this
list refuses every new client. No other failure of GeoJS, a reputation source
or an alert destination comes this close to taking the service down.
- Both may be set. `SWW_DENIED_COUNTRIES` then adds nothing, since the exclusive
list already refuses every other country. A code on both lists stops the start
- A code in this list whose `country=` tag is in `SWW_TAG_DENY` stops the start
with a message naming it.
- A refused request is answered with `SWW_BAN_RESPONSE`, as soon as the client's
address has been looked up. It is logged with the action `country_denied`, and
counted in the metrics.
### Request tags
Each request gets tags from what `discriminator` knows of its client and what
the checks found. The operator refuses or slows clients by tag. Tags appear in
the log line as `tags`, sorted, with empty lists left out (see
`docs/request-log.md`).
Each tag and where it comes from:
- `asn=<number>` and `country=<code>`, in lower case, such as `asn=64496` and
`country=de`, from the lookup. A client the lookup cannot place has neither.
- One tag per list of `SWW_TAG_LISTS` that holds the client (`tag_lists`), the
tag being the list's name, such as `torexit` or `vpn=mullvad`. These lists are
fetched and kept like a blocklist. `discriminator` ships none of its own.
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client, whatever the source's action. A zone with a key is named with the
key masked, as the `reputation` field gives it.
- `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`.
A client in `SWW_ALLOW_NETS` skips the checks and gets no tag. `SWW_TAG_DENY`
and `SWW_TAG_LIMIT_PERCENT` are read case-insensitively.
Two settings act on tags:
- `SWW_TAG_DENY` lists tags whose requests are refused with the action `denied`
(`tag_deny`). The check runs twice: just before the rate limits, and again
after the Core Rule Set, so that `exploitsignature` can refuse too.
- `SWW_TAG_LIMIT_PERCENT` lists tags with a percentage from 1 to 99. Their
clients get that percentage of every request and byte limit. The lowest
percentage among a client's tags applies. `exploitsignature` cannot be listed,
since a request gets it only after the rate limits have counted it.
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`.
### Biased thresholds
The biased thresholds lower the limits of clients in listed AS numbers and
countries. The lists are empty by default, for the same reason as the country
lists. The request log and the metrics show which AS numbers and countries a
service's abuse comes from.
The biased thresholds lower the limits of clients by AS number, country and tag.
The lists are empty by default, for the same reason as the country list. The
request log and the metrics show which AS numbers and countries a service's
abuse comes from.
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
Their clients get that percentage of every request and byte limit
(`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT`). A lowered limit is
rounded down. `0` is a zero allowance: the client's first request breaks a
limit and bans it.
- `SWW_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50` (see
"Request tags"). A lowered limit is rounded down.
- `SWW_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
request breaks a limit and bans it.
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` set the byte limits'
percentage alone, when it should differ from the request limits'. For an AS
number they list, they replace its percentage from `SWW_ASN_LIMIT_PERCENT` and
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, the one from
`SWW_COUNTRY_LIMIT_PERCENT`.
number they list, they replace its `asn=` tag's percentage and its entry in
`SWW_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's percentage.
- `SWW_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
abuse-source list can be shared by every `discriminator` in the fleet. It is
fetched and kept like a blocklist (see "Reputation"). Where it and
`SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
fetched and kept like a blocklist (see "Reputation"). Each entry counts as an
`asn=` tag in `SWW_TAG_LIMIT_PERCENT` does. Where both give a percentage, the
lower applies.
- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. A client
without a country gets `SWW_UNKNOWN_LIMIT_PERCENT` in place of a country's.
@@ -542,7 +593,7 @@ service's abuse comes from.
- No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the
operator's choice, through the country lists.
operator's choice, through `SWW_TAG_DENY` or the country list.
### Attack detection
@@ -584,7 +635,7 @@ files, the limits and the bans still apply to the client that sends it.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions; gitea's fixes below hold gitea's.
holds an app's own exclusions; the eighth change below holds gitea's.
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
before the Core Rule Set's rules. The Core Rule Set loads its own
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
@@ -630,7 +681,7 @@ files, the limits and the bans still apply to the client that sends it.
scanner from an ordinary client. In front of a code forge, registry and git
clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
`smallwebwaf` changes the Core Rule Set 4.25.0 in eight ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them:
@@ -649,52 +700,8 @@ every app, and no setting undoes them:
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
`http://127.0.0.1` on the user's own machine. The server never fetches that
address.
4. `Referer` is not checked for a Unix command given without arguments (932340),
for Java starting a process (944110) or for a Java class name (944130).
`Referer` names the page a request comes from, on everything that page loads
and on every link followed from it. The Core Rule Set takes some page
addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the other
rules, except after some gitea searches (gitea's fixes). An app that passes
`Referer` to a shell must check it itself.
5. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
6. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
Each supported app also has its own fixes, which let its ordinary requests
through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
then one or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- The request's `Host` is matched by exact name, without its port and without
regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in full.
Set empty, `SWW_APPS` lists no hostname.
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
the check of any other app that serves the same paths or fields.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
`gitea` is the only app supported. Each app's fixes are in one file, such as
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
gitea's fixes are:
1. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260), on
any path:
4. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260):
- `path`, `files`, `skip-to` and `sub_path`;
- `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`.
@@ -707,18 +714,41 @@ gitea's fixes are:
catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Other parameters keep all three rules, except on the
gitea paths the third fix names.
gitea paths the eighth change names. An app that uses one of these
parameters as a file on the server, or passes it to a shell, must check the
value itself. A rule file can refuse it instead.
2. The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts into `redirect_to` the page to return to after signing
in. The Core Rule Set takes file names in them, such as `package.json`, for
attacks. A browser sends such a cookie with every request until gitea
replaces it. gitea cannot replace it while those requests are refused, so one
refusal would keep the browser out of the whole site. gitea shows the message
with any script removed and returns only to its own pages. Every other cookie
is read in full.
3. On gitea's paths, some fields skip the rules that refused ordinary gitea
5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts into `redirect_to` the page to return to after
signing in. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the
whole site. gitea shows the message with any script removed and returns
only to its own pages. Every other cookie is read in full. An app that
reads a cookie of either name must check it itself.
- `Referer` is not checked for a Unix command given without arguments
(932340), for Java starting a process (944110) or for a Java class name
(944130). `Referer` names the page a request comes from, on everything
that page loads and on every link followed from it. The Core Rule Set
takes some page addresses for attacks:
- a search for `env`;
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
- a file named `java.lang.String.md`.
Every request from such a page would be refused. `Referer` keeps the
other rules, except after some gitea searches (the eighth change). An
app that passes `Referer` to a shell must check it itself.
6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
7. The `from` parameter of a container registry's upload path,
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
a push reuses a layer, `from` names the image the layer comes from. An image
such as `owner/python3-app` would have that push refused.
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/waf/gitea.conf` lists each rule and the request
it refused. The fields are:
@@ -736,7 +766,8 @@ gitea's fixes are:
- packages.
A user's or an organisation's page is any path of one segment, so on
every such path `q` skips these three rules, for example on `/search`.
every such path, on any app, `q` skips these three rules, for example on
`/search`.
Some of these searches also skip the rule for Java class names (944130),
which refused `org.apache.commons.logging`.
@@ -774,14 +805,11 @@ gitea's fixes are:
`actions/upload-artifact@v3` (920450).
Elsewhere these fields keep every rule. The paths are those of gitea at the
site root.
site root. An app other than gitea that serves one of them must check these
fields itself.
On a hostname that gets gitea's fixes, an app other than gitea must check these
fields and cookies itself. Listing each hostname's own apps in `SWW_APPS` keeps
the full check for the others.
The parameter names in these changes and fixes are matched without regard to
case, and the cookie names exactly.
The parameter and cookie names in these changes are matched without regard to
case, as Coraza matches them.
### Ban lengths, response and scope
@@ -920,7 +948,7 @@ left unset sends no alert. An alert needs a destination only the operator can
supply, and what counts as unusual depends on each service's normal traffic,
which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low
`SWW_ASN_LIMIT_PERCENT` for its AS number is the response.
percentage for its `asn=` tag in `SWW_TAG_LIMIT_PERCENT` is the response.
- Requests and bytes are counted per minute and per hour, for each of:
- a client;
@@ -1198,7 +1226,7 @@ the metrics.
### The answer
`discriminator` runs steps 3 to 13 of "What happens to a request", in that
`discriminator` runs steps 3 to 16 of "What happens to a request", in that
order. Every answer but the last below carries `X-Sww-Disposition`.
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
@@ -1242,8 +1270,8 @@ milliseconds. A client without an answer by then counts as unknown. No question
waits on these, which run in the background:
- the DNS blocklists and AbuseIPDB;
- fetching the blocklists, the CrowdSec decision list and the file
`SWW_ASN_LIMIT_PERCENT_URL` names.
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
file `SWW_ASN_LIMIT_PERCENT_URL` names.
In `enforce` mode, a question does not wait for GeoJS when it is a request:
+34 -2
View File
@@ -96,8 +96,8 @@ Every one of these lines has:
- `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
action `deny`;
- `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
action `deny`, or for a tag in `SWW_TAG_DENY`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
@@ -108,6 +108,36 @@ Every one of these lines has:
- `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`.
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
or `byte_limits`.
- `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
`byte_limits` bans when the report comes, so only the `report` line names
it.
- `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.
@@ -139,6 +169,8 @@ Every one of these lines has:
- the CrowdSec decision list's URL;
- DNS blocklist zones;
- `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).
- `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`.
+35 -33
View File
@@ -130,9 +130,11 @@ between the two (see "The image's command" below).
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set:
- the two country lists;
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `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.
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
@@ -144,28 +146,46 @@ between the two (see "The image's command" below).
the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`.
## Country lists
## Country list
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
such as `cn,ru,kp`.
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country on both lists
stops the start.
countries whose clients get through, such as `us,de`. A country whose
`country=` tag is in `SWW_TAG_DENY` stops the start.
## Request tags
Every request gets tags from what `discriminator` knows of its client. See
"Request tags" in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
`torexit,country=cn`. A tag listed twice, an `asn=` tag without a number, or a
`country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted
it.
## Biased thresholds
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
listed twice is refused.
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
`cn:25,ru:50`.
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
same, for the byte limits alone.
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
percentage of the byte limits alone, for the AS numbers or countries listed,
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
country listed twice is refused.
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
lookup cannot place.
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
text file with one AS number and percentage on each line, such as
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
`AS64496:50`. Each entry counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`
does. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection
@@ -198,24 +218,6 @@ between the two (see "The image's command" below).
a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form.
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
fixes of its hostname's apps only (see "Attack detection" in
[`SPEC.md`](../SPEC.md)).
- Entries are separated by semicolons. Each is a hostname, a colon, then one
or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- `gitea` is the only app supported.
- The request's `Host` is matched by exact name, without its port and
without regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in
full. Set empty, it lists no hostname.
- Unset, every app's fixes apply to every hostname.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
+244 -117
View File
@@ -29,7 +29,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// Config is the settings of `sww discriminator`. A time or size limit of
@@ -94,29 +93,30 @@ type Config struct {
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
// DeniedCountries are the countries whose clients are refused
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// empty, are the only countries whose clients are let through
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them.
DeniedCountries []string
// ExclusivelyAllowedCountries, when not empty, are the only countries
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
// two-letter codes in capitals, as GeoJS gives them.
ExclusivelyAllowedCountries []string
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
// the clients of the AS numbers and the countries they list that
// percentage of every rate limit and byte limit
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
// ASNBytesPercent and CountryBytesPercent give those they list a
// percentage of the byte limits in place of that one
// TagLists are the address lists that each add a tag to the requests of
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
// with a tag that percentage of every rate limit and byte limit, from 1
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags in lower case, as requests
// carry them.
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
// 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
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
// while it is unset.
ASNLimitPercent map[string]int64
CountryLimitPercent map[string]int64
// count as asn= tags in TagLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
UnknownLimitPercent int64
@@ -231,11 +231,6 @@ type Config struct {
WAFExemptPaths []string
WAFBodyLimit int64
CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
// request gets every app's fixes.
Apps map[string][]string
// TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
@@ -373,7 +368,7 @@ var (
errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " +
"be logged")
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in")
@@ -404,10 +399,6 @@ var (
"is not a path prefix starting with /, such as /assets/")
errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php")
errNotAppsEntry = errors.New(
"is not a hostname, : and a list of apps, such as git.example.com:gitea")
errNotHostname = errors.New("is not a DNS name such as git.example.com")
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New(
@@ -461,6 +452,18 @@ var (
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New(
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
errNotTagList = errors.New(
"is not a name, = and an http or https URL, such as " +
"torexit=https://example.com/tor.txt")
errNotTagPercentItem = errors.New(
"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")
errExploitTag = errors.New(
"cannot lower limits: the rate limits count a request before its " +
"rules are matched")
)
// FromEnvironment reads the settings with lookupEnv, normally
@@ -493,11 +496,11 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries(
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
@@ -540,7 +543,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath(
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_APPS"),
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
@@ -578,6 +580,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env.checkCountriesAndLookups(cfg)
env.checkASNLimitPercentURL(cfg)
env.checkCrowdSec(cfg)
env.checkTagLists(cfg)
if env.err != nil {
return nil, env.err
@@ -791,25 +794,6 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
return prefixes
}
// apps reads the setting that is the apps each hostname serves. It is nil
// while the setting is unset, and logged as null, unlike a setting set
// empty.
func (e *environment) apps(name string) map[string][]string {
value, set := e.lookup(name)
if !set {
e.settings = append(e.settings, slog.Any(name, nil))
return nil
}
e.settings = append(e.settings, slog.String(name, value))
apps, err := parseApps(value)
e.check(name, err)
return apps
}
// trapPaths reads the setting that is the list of trap paths. It is empty
// by default.
func (e *environment) trapPaths(name string) []string {
@@ -871,6 +855,31 @@ func (e *environment) percents(
return percents
}
// tagLists reads SWW_TAG_LISTS, which is empty by default.
func (e *environment) tagLists(name string) []TagList {
lists, err := parseTagLists(e.value(name, ""))
e.check(name, err)
return lists
}
// tags reads a setting that is a list of tags. It is empty by default.
func (e *environment) tags(name string) []string {
tags, err := parseTags(e.value(name, ""))
e.check(name, err)
return tags
}
// tagPercents reads a setting that is a list of tags, each with a
// percentage. It is empty by default.
func (e *environment) tagPercents(name string) map[string]int64 {
percents, err := parseTagPercents(e.value(name, ""))
e.check(name, err)
return percents
}
// percent reads a setting that is a percentage, from 0 to 100.
func (e *environment) percent(name, defaultValue string) int64 {
percent, err := ParsePercent(e.value(name, defaultValue))
@@ -1005,15 +1014,17 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
}
}
// checkCountriesAndLookups refuses a country on both country lists, and,
// checkCountriesAndLookups refuses a country in
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
// an asn= or country= tag, the biased thresholds,
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) {
if slices.Contains(cfg.TagDeny, countryTagPrefix+strings.ToLower(country)) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists))
}
@@ -1027,11 +1038,10 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string
set bool
}{
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"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},
@@ -1077,6 +1087,40 @@ func (e *environment) checkCrowdSec(cfg *Config) {
}
}
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
// another list too: the file at a URL is read as one kind of list.
func (e *environment) checkTagLists(cfg *Config) {
for _, list := range cfg.TagLists {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
}
}
}
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// 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)
}
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
for tag := range cfg.TagLimitPercent {
if isLookupTag(tag) {
return true
}
}
return false
}
// isLookupTag reports whether tag is an asn= or country= tag.
func isLookupTag(tag string) bool {
return strings.HasPrefix(tag, asnTagPrefix) || strings.HasPrefix(tag, countryTagPrefix)
}
// headerNames reads a setting that is a list of header names, and
// returns them in lower case.
func (e *environment) headerNames(name, defaultValue string) []string {
@@ -1676,65 +1720,6 @@ func parseTrapPaths(value string) ([]string, error) {
return paths, nil
}
// parseApps reads the apps each hostname serves: entries separated by
// semicolons, each a hostname, a colon, and a comma-separated list of the
// names waf.AppNames returns, such as
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
// hostname is a DNS name, without a wildcard. It returns them by hostname
// in lowercase, since a hostname is matched without regard to case. An
// empty value lists no hostname.
func parseApps(value string) (map[string][]string, error) {
apps := map[string][]string{}
if strings.TrimSpace(value) == "" {
return apps, nil
}
for entry := range strings.SplitSeq(value, ";") {
hostname, list, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
if !isZone(hostname) {
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
}
if _, listed := apps[hostname]; listed {
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
}
names, err := parseList(list)
if err != nil {
return nil, err
}
if len(names) == 0 {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
for i, name := range names {
if !slices.Contains(waf.AppNames(), name) {
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
strings.Join(waf.AppNames(), ", "))
}
if slices.Contains(names[:i], name) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
}
apps[hostname] = names
}
return apps, nil
}
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
// rules, each a whole number above zero and outside firstSetupRuleID to
// lastSetupRuleID.
@@ -1841,6 +1826,149 @@ func ParseASN(value string) (string, error) {
return "AS" + strconv.FormatUint(number, 10), nil
}
// The prefixes of the tags the lookup gives a request.
const (
asnTagPrefix = "asn="
countryTagPrefix = "country="
)
// ExploitSignatureTag is the tag of a request a rule file or the Core Rule
// Set matched.
const ExploitSignatureTag = "exploitsignature"
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
// requests of the clients it holds, and the URL it is fetched from.
type TagList struct {
Name string
URL string
}
// parseTagLists reads a comma-separated list of name=URL pairs, such as
// torexit=https://example.com/tor.txt, and returns each list with its name
// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the
// = before http. A name or a URL listed twice is an error.
func parseTagLists(value string) ([]TagList, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
lists := make([]TagList, 0, len(items))
for _, item := range items {
end := strings.Index(item, "=http")
if end <= 0 || !isListURL(item[end+1:]) {
return nil, fmt.Errorf("%q %w", item, errNotTagList)
}
list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]}
if slices.ContainsFunc(lists, func(earlier TagList) bool {
return earlier.Name == list.Name || earlier.URL == list.URL
}) {
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
lists = append(lists, list)
}
return lists, nil
}
// parseTags reads a comma-separated list of tags, which parseTag reads. A
// tag listed twice is an error.
func parseTags(value string) ([]string, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
tags := make([]string, 0, len(items))
for _, item := range items {
tag, err := parseTag(item)
if err != nil {
return nil, err
}
if slices.Contains(tags, tag) {
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
tags = append(tags, tag)
}
return tags, nil
}
// parseTag reads a tag in either case, and returns it as a request carries
// it: in lower case, 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(value)
if digits, isASN := strings.CutPrefix(tag, asnTagPrefix); isASN {
number, err := strconv.ParseUint(digits, 10, 32)
if err != nil {
return "", fmt.Errorf("%q %w", value, errNotASNTag)
}
return asnTagPrefix + strconv.FormatUint(number, 10), nil
}
if code, isCountry := strings.CutPrefix(tag, countryTagPrefix); isCountry {
_, err := parseCountry(code)
if err != nil {
return "", err
}
}
return tag, nil
}
// parseTagPercents reads a comma-separated list of tags, each with : and a
// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed
// twice is an error, and so is ExploitSignatureTag, which a request gets
// only after the rate limits have counted it.
func parseTagPercents(value string) (map[string]int64, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
percents := make(map[string]int64, len(items))
for _, item := range items {
colon := strings.LastIndex(item, ":")
if colon <= 0 {
return nil, fmt.Errorf("%q %w", item, errNotTagPercentItem)
}
tag, err := parseTag(item[:colon])
if err != nil {
return nil, err
}
percent, err := strconv.ParseInt(item[colon+1:], 10, 64)
if err != nil || percent < 1 || percent > 99 {
return nil, fmt.Errorf("%q %w", item[colon+1:], errNotTagPercent)
}
if tag == ExploitSignatureTag {
return nil, fmt.Errorf("%q %w", tag, errExploitTag)
}
if _, listed := percents[tag]; listed {
return nil, fmt.Errorf("%q %w", item[:colon], errListedTwice)
}
percents[tag] = percent
}
return percents, nil
}
// parsePercents reads a comma-separated list of items, each an AS number
// or a country, which parseCode reads, then : and a percentage, such as
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
@@ -2161,8 +2289,7 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest
}
// isZone reports whether each label of zone is as parseZones takes it. It
// also checks the hostnames of SWW_APPS, which are DNS names too.
// isZone reports whether each label of zone is as parseZones takes it.
func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool {
+123 -137
View File
@@ -42,10 +42,10 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
tagLists = "SWW_TAG_LISTS"
tagDeny = "SWW_TAG_DENY"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -88,7 +88,6 @@ const (
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
apps = "SWW_APPS"
trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL"
@@ -275,9 +274,13 @@ func TestDefaults(t *testing.T) {
wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets)
wantCountries(t, deniedCountries, cfg.DeniedCountries)
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
t.Errorf("tag settings %v, %v and %v by default, want all empty",
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
}
hostname, err := os.Hostname()
if err != nil || hostname == "" || cfg.InstanceName != hostname {
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
@@ -328,7 +331,6 @@ func TestValuesAsSet(t *testing.T) {
rateLimitPerMinute: "60",
rateLimitPerHour: "600",
rateLimitPerDay: "6000",
deniedCountries: "cn, RU,kp,Xk",
allowedCountries: "de",
banResponse: "429",
limitBanDuration: "15m",
@@ -371,7 +373,6 @@ func TestValuesAsSet(t *testing.T) {
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
}
@@ -577,92 +578,6 @@ func TestCoreRuleSetSettings(t *testing.T) {
}
}
func TestApps(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want map[string][]string
}{
{environment{}, nil},
{environment{apps: ""}, map[string][]string{}},
{
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
map[string][]string{
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
},
},
} {
cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
}
}
}
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want string
}{
{environment{}, `"` + apps + `":null`},
{environment{apps: ""}, `"` + apps + `":""`},
} {
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) {
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
}
}
}
func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel()
const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{
"git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
},
{
"git.example.com:gitea;GIT.example.com:gitea",
`"git.example.com" is listed twice`,
},
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
} {
t.Run(tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
want := apps + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel()
@@ -1188,7 +1103,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru",
tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU",
}.lookupEnv)
if err == nil {
@@ -1263,12 +1178,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
deniedCountries: "kp",
allowedCountries: "de",
addLookupHeaders: enabled,
tagDeny: "country=kp",
tagLimitPercent: "asn=64496:50",
asnLimitPercentURL: asnURL,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25",
unknownLimitPercent: "99",
@@ -1291,12 +1205,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
}
// Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, any other anomaly threshold, or
// a blocklist.
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// lookup does not give, an anomaly threshold per AS number that is off,
// any other anomaly threshold, or a blocklist.
env := environment{
lookupSource: off, deniedCountries: "", allowedCountries: "",
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL,
}
@@ -1442,34 +1356,96 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
}
}
// torExitTag is a tag these tests name.
const torExitTag = "torexit"
func TestTagSettingsAsSet(t *testing.T) {
t.Parallel()
const torURL = "https://example.com/tor.txt"
// Names and tags in either case, an AS number with leading zeros, and a
// name holding a =.
cfg := fromEnvironment(t, environment{
tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt",
tagDeny: "TorExit, Country=KP, asn=014061",
tagLimitPercent: "vpn:50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
})
wantLists := []config.TagList{
{Name: torExitTag, URL: torURL},
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
}
if !slices.Equal(cfg.TagLists, wantLists) {
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
}
wantDeny := []string{torExitTag, "country=kp", "asn=14061"}
if !slices.Equal(cfg.TagDeny, wantDeny) {
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
}
wantPercents := map[string]int64{
"vpn": 50, "asn=14061": 25, "country=cn": 1, "dnsbl=zen.example": 99,
}
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
}
}
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
t.Parallel()
const lapi = "http://172.17.0.1:8080"
for _, tc := range []struct {
name, listURL string
env environment
}{
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{
"the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
cfg.UnknownLimitPercent != 100 {
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
t.Errorf("biased thresholds %v, %v and %d by default, "+
"want two empty lists and 100",
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
}
// AS numbers and countries in either case, an AS number with leading
// zeros, 0 and 100.
cfg = fromEnvironment(t, environment{
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
countryLimitPercent: "cn:25,RU:50",
asnBytesPercent: "as16276:75",
countryBytesPercent: "ru:10",
unknownLimitPercent: "0",
})
for name, tc := range map[string]struct{ got, want map[string]int64 }{
asnLimitPercent: {
cfg.ASNLimitPercent,
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
},
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
} {
@@ -1493,16 +1469,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
)
for _, tc := range []struct{ name, value, want string }{
{asnLimitPercent, "14061:50", `"14061"` + notASN},
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{asnBytesPercent, "14061:50", `"14061"` + notASN},
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{
countryLimitPercent, "nk:25",
countryBytesPercent, "nk:25",
`"nk" is not a two-letter country code such as de or kp`,
},
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
{unknownLimitPercent, "101", `"101"` + notPercent},
@@ -2090,23 +2066,34 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
{addLookupHeaders, "yes"},
{deniedCountries, "nk"},
{deniedCountries, "kp,,ir"},
{deniedCountries, "prk"},
{deniedCountries, "408"},
{deniedCountries, "k"},
{deniedCountries, "eu"},
{deniedCountries, "un"},
{deniedCountries, "su"},
{allowedCountries, "nk"},
{allowedCountries, "kp,,ir"},
{allowedCountries, "prk"},
{allowedCountries, "408"},
{allowedCountries, "k"},
{allowedCountries, "eu"},
{allowedCountries, "un"},
{allowedCountries, "su"},
{allowedCountries, "ac"},
{allowedCountries, "uk"},
{allowedCountries, "zz"},
{allowedCountries, "de,germany"},
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
{tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, {tagLimitPercent, "vpn:"},
{tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
{tagLists, "a,b=https://example.com/a.txt"},
{tagLists, "a=ftp://example.com/a.txt"},
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
{unknownLimitPercent, "50%"},
@@ -2373,10 +2360,10 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupDBPath: "",
lookupTimeout: "800ms",
addLookupHeaders: "false",
deniedCountries: "",
allowedCountries: "",
asnLimitPercent: "",
countryLimitPercent: "",
tagLists: "",
tagDeny: "",
tagLimitPercent: "",
asnBytesPercent: "",
countryBytesPercent: "",
unknownLimitPercent: "100",
@@ -2419,7 +2406,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafExemptPaths: "",
wafBodyLimit: off,
exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "",
errorBurstThreshold: "30",
logRemoteURL: "",
+57
View File
@@ -0,0 +1,57 @@
package discriminator
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.config.AbuseIPDBKey == "" {
return nil
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return nil
}
rq.abuseIPDBHit = true
rq.addTag("abuseipdb")
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
},
}
}
+14 -2
View File
@@ -91,6 +91,18 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
}
}
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", nil)
req := s.question(siteA, client, "/")
fillHeaders(req, headerFieldLimit+1)
do(t, req)
wantHeuristic(t, s.out.requestLine(t), "header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Parallel()
@@ -323,7 +335,7 @@ func newDiscriminatorInBubble(
) (*discriminator.Server, *output) {
t.Helper()
settings := map[string]string{deniedCountries: "KP"}
settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
@@ -530,7 +542,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return discriminator.New(params)
}, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block,
tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
+25 -79
View File
@@ -1,7 +1,10 @@
package discriminator
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
import (
"net/http"
"context"
"net/netip"
"time"
@@ -18,11 +21,15 @@ func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action}
}
// banned reports whether a ban on a netblock the client is in covers the
// request at now, and notes for the log line when that ban ends. A
// request that makes the ban permanent, or in observe mode would have,
// raises the alert for it.
func (rq *request) banned(now time.Time) bool {
// activeBan refuses a client a ban covers: a ban on a netblock the client is
// in, in force at now. It notes for the log line when that ban ends. A request
// that makes the ban permanent, or in observe mode would have, raises the alert
// for it. Its figures are the ban's netblock and when it ends.
type activeBan struct{}
func (activeBan) name() string { return "active_ban" }
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
check := rq.h.ledger.Check
if rq.h.config.Observe {
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
@@ -38,85 +45,24 @@ func (rq *request) banned(now time.Time) bool {
rq.alertBan(ban)
}
return banned
}
// limitBroken counts the request for the rate limits at now, notes the
// client's counts for the log line, and reports whether the request takes
// the client over a rate limit, as its limit percentage lowers it, which
// breaks it.
func (rq *request) limitBroken(now time.Time) bool {
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if over {
rq.banForLimit(now, hit, rq.h.config.BanResponse)
if !banned {
return nil
}
return over
}
// countBytes counts the request's bytes, as countedBytes gives them, for
// the byte limits, once its report has come, and notes the client's byte totals
// for the log line; its requests stay there as the rate limits counted them.
// Only an allowed request has them counted, and only one the rate limits
// counted; in observe mode, not one that enforce mode would have refused. Bytes
// that take the client over a byte limit, as its limit percentage for the byte
// limits lowers it, break it; the response was passed on whole.
func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" {
return
}
now := rq.h.now()
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if over {
rq.banForLimit(now, hit, rq.out.status)
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
},
}
}
// countRefusal counts the request for the error burst once it has been
// answered, if discriminator refused it after a rule file match, a trap path or a
// Core Rule Set match, and in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
// not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return
// limitFigures are the figures of a heuristic that broke hit, a limit: its
// window, the count and the limit.
func limitFigures(hit ratelimit.Hit) map[string]any {
return map[string]any{
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
}
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return
}
now := rq.h.now()
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
}
// countedBytes returns the request's bytes, once it has ended, as the
+20 -4
View File
@@ -211,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
deniedCountries: "kp",
tagDeny: denyKP,
})
// fromDE's ban covers otherClient, which is refused unasked about.
@@ -228,6 +228,22 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
}
}
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
})
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel()
@@ -250,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -260,7 +276,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied)
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
s.get(fromKP, tc.status, requestlog.ActionDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned)
@@ -275,7 +291,7 @@ func TestBanNotes(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
deniedCountries: "kp",
tagDeny: denyKP,
})
start := clk.Now()
+36 -26
View File
@@ -17,30 +17,31 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits: one of its lists is not empty,
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
// is set. The client's lookup is then needed before its request goes on.
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
}
// limitPercentages returns the client's limit percentages, for the rate
// limits and for the byte limits, by its AS number and country as looked
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// that list it. Each is the lowest of those the settings give it, the
// first of them in the order below when several are lowest: the
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, unless its request skipped the wait for GeoJS,
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
// place of the first three for an AS number or a country they list.
// limits and for the byte limits, by its tags, its AS number and country
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// AbuseIPDB that list it. Each is the lowest of those the settings give
// it, the first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
@@ -65,10 +66,18 @@ func (rq *request) limitPercentages() (percentage, percentage) {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
}
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
fetched)
countryRequests := given(cfg.CountryLimitPercent, country,
"SWW_COUNTRY_LIMIT_PERCENT")
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
otherTags := percentage{percent: whole}
for _, tag := range rq.line.Tags {
if tag != asnTag(asn) && tag != countryTag(country) {
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
}
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
@@ -79,12 +88,13 @@ func (rq *request) limitPercentages() (percentage, percentage) {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
}
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
reputationListed),
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
}
// given returns the percentage percents, the setting named setting, gives
// code, an AS number or a country, or whole when it does not list code.
// code, an AS number, a country or a tag, or whole when it does not list code.
func given(percents map[string]int64, code, setting string) percentage {
percent, listed := percents[code]
if !listed {
+38 -30
View File
@@ -22,8 +22,7 @@ import (
// The biased thresholds.
const (
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -35,6 +34,11 @@ const (
asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=de:50"
countryTagDEQuarter = "country=de:25"
// noCountry is in an AS of its own, AS64500, and in no country.
noCountry = "192.0.2.80"
// fourAMinute is the rate limit these tests set: half of it is 2
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
for _, tc := range []struct {
setting, value, from string
}{
{asnLimitPercent, asnDEHalf, fromDE},
{countryLimitPercent, countryDEHalf, fromDE},
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced},
} {
t.Run(tc.setting, func(t *testing.T) {
@@ -96,8 +100,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
for _, tc := range []struct {
setting, value, from string
}{
{asnLimitPercent, asnDEHalf, fromDE},
{countryLimitPercent, "DE:50", fromDE},
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=DE:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
@@ -151,13 +155,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
},
{
"raising the byte limits back",
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + asnLimitPercent, none, "",
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + tagLimitPercent, none, "",
},
{
"raising the byte limits back, by country",
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
"50 from " + countryLimitPercent, none, "",
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
"50 from " + tagLimitPercent, none, "",
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -184,19 +188,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
// The first request breaks the limit, and bans the client; the log line
// gives the 0.
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != asnLimitPercent {
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"],
asnLimitPercent)
unknownLimitPercent)
}
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
}
func TestLowestPercentageApplies(t *testing.T) {
@@ -211,29 +215,29 @@ func TestLowestPercentageApplies(t *testing.T) {
}{
{
"the country's",
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
fromDE, "25 from " + countryLimitPercent,
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
fromDE, "25 from " + tagLimitPercent,
},
{
"the AS number's",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
fromDE, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
fromDE, "25 from " + tagLimitPercent,
},
{
"the AS number's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
fromDE, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
fromDE, "25 from " + tagLimitPercent,
},
{
"that for a client without a country",
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent,
},
{
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country",
map[string]string{asnLimitPercent: "AS64500:25"},
noCountry, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -304,8 +308,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
setting, value string
waits bool
}{
{asnLimitPercent, asnDEHalf, true},
{countryLimitPercent, countryDEHalf, true},
{tagLimitPercent, asnTagDEHalf, true},
{tagLimitPercent, countryTagDEHalf, true},
{tagDeny, denyKP, true},
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true},
@@ -363,15 +371,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
{
// A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit",
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited,
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
},
{
// The byte limits' percentage, not the rate limits'.
"a byte limit",
map[string]string{
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf,
},
0, http.StatusOK, requestlog.ActionForward,
@@ -487,7 +495,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
}
// percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
// left out.
func percentText(percent *int64, setting string) string {
switch {
+41
View File
@@ -0,0 +1,41 @@
package discriminator
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{}
func (blocklists) name() string { return "blocklists" }
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
if !rq.refusedByBlocklists(listedBy) {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"lists": listedBy},
}
}
// refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists
// it and SWW_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
}
+43
View File
@@ -0,0 +1,43 @@
package discriminator
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// byteLimits counts the request's bytes, as countedBytes gives them, for the
// byte limits, once its report has come, and notes the client's byte totals for
// the log line; its requests stay there as the rate limits counted them. Only
// an allowed request has them counted, and only one the rate limits counted; in
// observe mode, not one that enforce mode would have refused. Bytes that take
// the client over a byte limit, as its limit percentage for the byte limits
// lowers it, break it, which bans the client; the response was passed on whole.
// Its figures are the window, the count and the limit.
type byteLimits struct{}
func (byteLimits) name() string { return "byte_limits" }
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
if !rq.counted || rq.line.WouldAction != "" {
return nil
}
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if !over {
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
+15
View File
@@ -98,6 +98,21 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
}
}
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
line, _ := s.download()
wantAfterAnswerHeuristic(t, line, "", "null")
// Two answers of 70 bytes are over the limit of 99.
line, _ = s.download()
wantHeuristic(t, line, "", "null")
wantAfterAnswerHeuristic(t, line, "byte_limits",
`{"window": "minute", "count": 140, "limit": 99}`)
}
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
t.Parallel()
+22 -10
View File
@@ -1,6 +1,10 @@
package discriminator
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
// and SWW_WAF_ANOMALY_THRESHOLD.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -9,18 +13,22 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
// coreRuleSet inspects the request with the Core Rule Set, unless
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// 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 raises the waf_block alert,
// and in block mode refuses the request, which is an offence its client's
// history counts, and so returns ActionWAFBlocked. It returns "" for a
// request it does not refuse.
func (rq *request) checkCoreRuleSet() string {
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// 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{}
func (coreRuleSet) name() string { return "core_rule_set" }
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
return ""
return nil
}
start := time.Now()
@@ -40,18 +48,22 @@ func (rq *request) checkCoreRuleSet() string {
threshold := cfg.WAFAnomalyThreshold
if threshold == 0 || result.Score < threshold {
return ""
return nil
}
rq.addTag(config.ExploitSignatureTag)
rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect {
return ""
return nil
}
rq.wafBlocked = true
return requestlog.ActionWAFBlocked
return &verdict{
action: requestlog.ActionWAFBlocked,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
}
}
// alertWAFBlock raises the waf_block alert for the request, which the Core
+45 -23
View File
@@ -10,7 +10,6 @@ import (
"testing"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
@@ -22,7 +21,6 @@ const (
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block"
detect = "detect"
)
@@ -63,6 +61,41 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
}
}
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
mode string
status int
action string
}{
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
{detect, detect, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
})
}
}
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
t.Parallel()
@@ -112,6 +145,16 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
}
}
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
}
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel()
@@ -239,27 +282,6 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
}
}
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel()
+23 -9
View File
@@ -1,21 +1,35 @@
package discriminator
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// countryDenied reports whether the country lists refuse the request, by
// the client's country as it was looked up. A client without a country,
// or whose country cannot be found, is refused only by
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
func (rq *request) countryDenied() bool {
denied := rq.h.config.DeniedCountries
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// does not hold the client's country as it was looked up. A client without a
// country, or whose country cannot be found, is refused. Its figures are the
// country and the setting that refused it.
type countries struct{}
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country
if slices.Contains(denied, country) {
return true
if len(allowed) == 0 || slices.Contains(allowed, country) {
return nil
}
return len(allowed) > 0 && !slices.Contains(allowed, country)
return &verdict{
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
}
+23 -6
View File
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
env map[string]string
refused []string
}{
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
{
"exclusively allowed", map[string]string{allowedCountries: "DE"},
[]string{fromKP, unplaced},
},
{
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
"with a country in SWW_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced},
},
} {
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
}
}
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel()
@@ -100,9 +117,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -185,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
env map[string]string
}{
{"no setting needs the lookup", nil},
{"a country list is set", map[string]string{deniedCountries: "kp"}},
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
+50
View File
@@ -0,0 +1,50 @@
package discriminator
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// crowdSec refuses a request when a decision of the CrowdSec decision list on
// the client is in force at now. It then adds the tag crowdsec, notes the
// list, as noteHit does, and bans the client until that decision ends. Its
// figures are the decision's scenario and when it ends.
type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
decision, listed := rq.crowdSecDecisionInForce(now)
if !listed {
return nil
}
rq.addTag("crowdsec")
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{
"scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires),
},
}
}
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
// the client that is in force at now, and reports whether there is one, which
// the heuristic crowdsec refuses the request for.
func (rq *request) crowdSecDecisionInForce(
now time.Time,
) (reputation.Decision, bool) {
return rq.h.lists.CrowdSecDecision(rq.client, now)
}
+16
View File
@@ -95,6 +95,22 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
float64(fetched.Unix()))
}
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
t.Parallel()
s, clk, server, _ := startWithAlerts(t, map[string]string{
crowdSecURL: lapi, crowdSecKey: bouncerKey,
})
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec")
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
t.Parallel()
+29
View File
@@ -0,0 +1,29 @@
package discriminator
// The heuristic deny_nets. It reads SWW_DENY_NETS.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
// the setting the client is in.
type denyNets struct{}
func (denyNets) name() string { return "deny_nets" }
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{figureNetblock: netblock.String()},
}
}
}
return nil
}
+20 -54
View File
@@ -2,6 +2,7 @@ package discriminator
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -52,13 +53,6 @@ const ReportPath = "/_sww/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
const (
// maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request answered longest ago is dropped.
@@ -148,48 +142,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: it is over the header limits, a blocklist lists its client while
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// over a whole rate limit. In observe mode, which refuses nothing, none is.
// rproxy lets a request through when discriminator answers too late.
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
// will refuse it, as a function in that heuristic's file says. In observe mode,
// which refuses nothing, none is. rproxy lets a request through when
// discriminator answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe {
return false
}
lists := rq.h.lists
_, decided := lists.CrowdSecDecision(rq.client, now)
_, decided := rq.crowdSecDecisionInForce(now)
return decided || rq.overHeaderLimits() ||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
(rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether discriminator refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
return decided || overHeaderLimits(rq.headerCounts()) ||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
rq.overAWholeRateLimit(now)
}
// answerQuestion runs the checks on the visitor's request a question
@@ -220,18 +186,18 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here.
defer rq.countRefusal()
defer rq.runAfterAnswer(r.Context())
rq.allow()
return
}
// The last deferred runs first: countRefusal before addToHistory, so that a
// broken error burst is in the client's history.
// The last deferred runs first: runAfterAnswer before addToHistory, so that
// a broken refusal limit is in the client's history.
defer rq.addToHistory()
defer rq.countAnomalies()
defer rq.countRefusal()
defer rq.runAfterAnswer(r.Context())
if refused != nil {
rq.deny(*refused)
@@ -413,7 +379,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
// Remove reports true to one report only, of several that name the id. A
// question still being handled is held as nil.
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(held, report)
h.finishWith(r.Context(), held, report)
}
w.WriteHeader(http.StatusNoContent)
@@ -476,11 +442,11 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first counts the request's bytes, and adds it to its
// client's history, the anomaly counters and the metrics, with the status and
// bytes the report gives. A denied request's counting was finished at its
// decision.
func (h *handler) finishWith(held *decision, report Report) {
// For an allowed request it first runs the heuristics of onReport, and adds the
// request to its client's history, the anomaly counters and the metrics, with
// the status and bytes the report gives. A denied request's counting was
// finished at its decision. ctx is the report's own context.
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
rq := &request{
decision: *held,
h: h,
@@ -497,7 +463,7 @@ func (h *handler) finishWith(held *decision, report Report) {
if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
rq.countBytes()
rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies()
rq.addToHistory()
+2 -2
View File
@@ -414,7 +414,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
geojsURL, asked, release := startHeldGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit,
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
})
first := s.question(siteA, client, "/")
@@ -765,7 +765,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
})
got := s.ask(fromDE, "/")
+41
View File
@@ -0,0 +1,41 @@
package discriminator
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_REPUTATION_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
// answer. Its figure is the zones that list the client, their keys masked.
type dnsbl struct{}
func (dnsbl) name() string { return "dnsbl" }
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
for _, zone := range listedBy {
rq.addTag("dnsbl=" + zone)
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"zones": listedBy},
}
}
+71
View File
@@ -0,0 +1,71 @@
package discriminator
// The heuristic header_limits. It reads no setting: its limits are fixed.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
// headerLimits refuses a request over the header limits, with 431. Its figures
// are the request's header fields and cookies, and the limit of each.
type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
fields, cookies := rq.headerCounts()
if !overHeaderLimits(fields, cookies) {
return nil
}
return &verdict{
action: requestlog.ActionTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
},
}
}
// overHeaderLimits reports whether a request with these counts of header fields
// and cookies, as headerCounts counts them, is over the header limits: it has
// more than maxHeaderFields fields or more than maxCookies cookies.
func overHeaderLimits(fields, cookies int) bool {
return fields > maxHeaderFields || cookies > maxCookies
}
// headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core
// Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) headerCounts() (int, int) {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields, cookies
}
+139
View File
@@ -0,0 +1,139 @@
package discriminator
import (
"context"
"time"
)
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what discriminator does with a
// request.
type heuristic interface {
// name is the heuristic's name, which the log line gives.
name() string
// check looks at the request at now, and returns nil to let it through.
// ctx is the request's own context.
check(ctx context.Context, rq *request, now time.Time) *verdict
}
// verdict is a heuristic refusing a request, or banning its client: the action
// the log line names, one of requestlog's Action constants, and the figures the
// heuristic acted on, by name, such as the count and the limit.
type verdict struct {
action string
figures map[string]any
}
// The names of the figures that more than one heuristic gives.
const (
figureWindow = "window"
figureCount = "count"
figureLimit = "limit"
figureScore = "score"
figureNetblock = "netblock"
)
// The lists of heuristics, each in the order its heuristics run. A client in
// SWW_ALLOW_NETS skips them all.
// beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic {
return []heuristic{
denyNets{},
activeBan{},
}
}
// afterLookup are the heuristics that run once the lookup has been made.
// tag_deny runs twice: before the rate limits, on the tags known by then, and
// last, on the tag rule_files and core_rule_set can add.
func afterLookup() []heuristic {
return []heuristic{
tagLists{},
countries{},
blocklists{},
crowdSec{},
dnsbl{},
abuseIPDB{},
tagDeny{},
rateLimits{},
trapPaths{},
ruleFiles{},
headerLimits{},
coreRuleSet{},
tagDeny{},
}
}
// afterAnswer are the heuristics that run on a request once discriminator has
// answered it, before its decision line is written. They ban the client, and do
// not change what the request got: their verdict's action is not used.
func afterAnswer() []heuristic {
return []heuristic{
refusalLimit{},
}
}
// onReport are the heuristics that run on an allowed request when rproxy's
// report of it comes, before its report line is written. Like afterAnswer, they
// only ban.
func onReport() []heuristic {
return []heuristic{
byteLimits{},
}
}
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
// it is called.
func (rq *request) runAfterAnswer(ctx context.Context) {
rq.runBanningHeuristics(ctx, afterAnswer())
}
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
// and returns the first verdict, or nil when none gives one. The log line's
// heuristic and heuristic_figures name the heuristic that gave it, and give its
// figures. ctx is the request's own context.
func (rq *request) runHeuristics(
ctx context.Context, list []heuristic, now time.Time,
) *verdict {
name, v := rq.firstVerdict(ctx, list, now)
if v != nil {
rq.line.Heuristic = name
rq.line.HeuristicFigures = v.figures
}
return v
}
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
// the time it is called. The log line's after_answer_heuristic and
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
// give its figures, so that heuristic and heuristic_figures still name the one
// that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil {
rq.line.AfterAnswerHeuristic = name
rq.line.AfterAnswerHeuristicFigures = v.figures
}
}
// firstVerdict runs list on the request at now, in order, until a heuristic
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
// nil when none gives one. ctx is the request's own context.
func (rq *request) firstVerdict(
ctx context.Context, list []heuristic, now time.Time,
) (string, *verdict) {
for _, each := range list {
v := each.check(ctx, rq, now)
if v != nil {
return each.name(), v
}
}
return "", nil
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "2",
deniedCountries: "kp",
tagDeny: denyKP,
})
start := clk.Now()
+31 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/netip"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -19,7 +20,8 @@ const (
)
// lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless
// database or through GeoJS, and notes them for the log line and as the
// request's asn= and country= tags, unless
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a
@@ -47,6 +49,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
rq.line.ASN = rq.lookupAnswer.ASN
rq.line.ASName = rq.lookupAnswer.ASName
rq.line.Country = rq.lookupAnswer.Country
if tag := asnTag(rq.line.ASN); tag != "" {
rq.addTag(tag)
}
if tag := countryTag(rq.line.Country); tag != "" {
rq.addTag(tag)
}
}
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
// asn=64496 for AS64496, or "" for no AS number.
func asnTag(asn string) string {
if asn == "" {
return ""
}
return "asn=" + strings.TrimPrefix(asn, "AS")
}
// countryTag returns the tag of country, a code as the lookup gives it, such
// as country=de for DE, or "" for no country.
func countryTag(country string) string {
if country == "" {
return ""
}
return "country=" + strings.ToLower(country)
}
// addLookup adds answer, an answer about a client from the lookup
+3 -3
View File
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
tagDeny: denyKP,
})
requestFromDE := func() *http.Request {
+9 -9
View File
@@ -195,10 +195,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello")
})
env := map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
@@ -322,13 +322,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
deniedCountries: "kp",
trustedProxies: trustLocalhost,
metricsToken: token,
tagDeny: denyKP,
})
// GeoJS fails, so the client counts as coming from an unknown country,
// which SWW_DENIED_COUNTRIES does not refuse.
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
// not refuse it.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK)
+14 -2
View File
@@ -14,6 +14,18 @@ import (
// observe is the value of SWW_MODE for observe mode.
const observe = "observe"
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
mode: observe, denyNets: client,
})
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
}
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel()
@@ -38,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -59,7 +71,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied},
{banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionCountryDenied},
{fromKP, requestlog.ActionDenied},
{fromDE, requestlog.ActionRateLimited},
} {
if !tc.observe {
+8 -8
View File
@@ -155,11 +155,12 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
// The country lists, the headers and the biased thresholds act on
// the answer before the request goes on.
Wait: len(params.Config.DeniedCountries) > 0 ||
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
// The country list, the headers, the tag settings that name an asn=
// or country= tag and the biased thresholds act on the answer before
// the request goes on.
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
biasedThresholdsSet(params.Config),
Answered: h.addLookup,
Now: params.Now,
ProcessLog: params.ProcessLog,
@@ -183,6 +184,7 @@ func newReputation(
lists := reputation.New(reputation.Params{
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
TagLists: cfg.TagLists,
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
})
@@ -210,8 +212,7 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
// or nil while SWW_WAF_MODE is off. It
// 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 waf.New refuses is
@@ -226,7 +227,6 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
})
if err != nil {
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
+3 -1
View File
@@ -69,7 +69,9 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
tagDeny = "SWW_TAG_DENY"
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
denyKP = "country=kp"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
+65
View File
@@ -0,0 +1,65 @@
package discriminator
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
// biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// rateLimits counts the request for the rate limits, unless
// countedByRateLimits exempts it, by the client's limit percentages, which it
// notes for the log line with the client's counts. A request that takes the
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
// refused, and bans the client. A request exempt from the rate limits is exempt
// from the byte limits too. Its figures are the window, the count and the
// limit.
type rateLimits struct{}
func (rateLimits) name() string { return "rate_limits" }
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.counted = rq.countedByRateLimits()
if !rq.counted {
return nil
}
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
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)
rq.line.Counts = counts
if !over {
return nil
}
rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
}
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
// request at now, whatever its client's limit percentages: the rate limits
// count it, and one more request from its client takes it over a whole rate
// limit. It counts nothing.
func (rq *request) overAWholeRateLimit(now time.Time) bool {
return rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
}
+13 -2
View File
@@ -72,6 +72,17 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
}
}
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
}
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel()
@@ -125,7 +136,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
})
// The answers are kept before the requests, so that none waits for
@@ -156,7 +167,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionCountryDenied)
http.StatusForbidden, requestlog.ActionDenied)
}
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
+54
View File
@@ -0,0 +1,54 @@
package discriminator
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
// SWW_BAN_RESPONSE.
import (
"context"
"net/http"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// refusalLimit counts the request once it has been answered, if discriminator
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
func (refusalLimit) name() string { return "refusal_limit" }
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return nil
}
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
return nil
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return nil
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
}
@@ -106,6 +106,25 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
}
}
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
line := blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "", "null")
// The second refusal is the request refusal_limit bans for, and the line
// names both.
line = blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "refusal_limit",
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel()
@@ -172,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
lookupTimeout: "1h",
errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules),
countryLimitPercent: countryDEHalf,
tagLimitPercent: countryTagDEHalf,
})
// Half of the threshold would be one, which the second refusal is over.
+3 -77
View File
@@ -1,91 +1,17 @@
package discriminator
import (
"context"
"time"
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
// abuseipdb.
import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
)
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
// refuses the requests of a client a source lists.
const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead
// (see limitPercentages), and being log, it does nothing more.
func (rq *request) blocklistDenied() bool {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
return rq.blocklisted && rq.h.config.BlocklistAction == deny
}
// crowdSecBanned reports whether a decision of the CrowdSec decision list
// on the client is in force at now. If one is, it notes the list, as
// noteHit does, and bans the client until that decision ends.
func (rq *request) crowdSecBanned(now time.Time) bool {
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
if !listed {
return false
}
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return true
}
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
// noteListed does, and reports whether SWW_REPUTATION_ACTION, being
// deny, refuses the request. Being limit, it lowers the client's limits
// instead (see limitPercentages), and being log, it does nothing more. A
// zone without a verdict on the client is asked about it in the
// background, and the request does not wait for the answer. ctx is the
// request's own context.
func (rq *request) dnsblDenied(ctx context.Context) bool {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
return rq.dnsblListed && rq.h.config.ReputationAction == deny
}
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// its score of the client is a hit, and reports whether
// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// does for a zone. While SWW_ABUSEIPDB_KEY is unset it does nothing. A
// client without a score is checked in the background, by the request's
// address, if its history counts an offence, and the request does not
// wait for the answer. The score is then used for each address of the
// client. ctx is the request's own context.
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
if rq.h.config.AbuseIPDBKey == "" {
return false
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return false
}
rq.abuseIPDBHit = true
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
return rq.h.config.ReputationAction == deny
}
// noteListed notes each of sources, the URLs of the blocklists or the
// DNSBL zones, their keys masked, that list the client, as noteHit does,
// with reason.
+100 -10
View File
@@ -118,11 +118,61 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
}
}
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
heuristic string
env map[string]string
load func(*testing.T, *discriminator.Server)
figures string
}{
{
"blocklists",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
func(t *testing.T, server *discriminator.Server) {
t.Helper()
loadLists(t, server, map[string][]string{dropURL: {client}})
},
`{"lists": ["` + dropURL + `"]}`,
},
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
},
func(_ *testing.T, server *discriminator.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
`{"zones": ["` + dnsblZone + `"]}`,
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
func(_ *testing.T, server *discriminator.Server) {
loadScores(server, map[string]int64{client: 80})
},
`{"score": 80, "min_score": 75}`,
},
} {
t.Run(tc.heuristic, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
}
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
@@ -161,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// percentText gives them, and limitHit its limit_hit.
want, limitHit string
}{
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
// The AS number's, the first of two alike.
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{actionLog, asnDE + ":100", none, ""},
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{actionLog, "asn=64511:25", none, ""},
} {
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
@@ -196,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
const (
fromURL = "25 from " + asnLimitPercentURL
fromSetting = "25 from " + asnLimitPercent
fromSetting = "25 from " + tagLimitPercent
)
for _, tc := range []struct {
@@ -210,16 +260,16 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{
"the file's, lower than the setting's",
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
},
{
"the setting's, lower than the file's",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
fromSetting, fromSetting,
},
{
"the setting's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
fromSetting, fromSetting,
},
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
@@ -432,6 +482,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
}
}
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
load func(*discriminator.Server)
want []string
}{
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
},
func(server *discriminator.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=de", "dnsbl=" + dnsblZone},
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
func(server *discriminator.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=de"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(server)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
tc.want...)
})
}
}
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
t.Parallel()
+34 -85
View File
@@ -138,6 +138,17 @@ func (h *handler) startRequest(
return rq
}
// addTag adds tag, in lower case, to the request's tags, which stay sorted and
// hold each tag once.
func (rq *request) addTag(tag string) {
tag = strings.ToLower(tag)
at, found := slices.BinarySearch(rq.line.Tags, tag)
if !found {
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
}
}
// requestHeaders returns the headers of r that names lists, by name in
// lower case, each with its values joined by ", ". Authorization, Cookie
// and Set-Cookie are never among them, whatever names says.
@@ -160,16 +171,20 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
}
// check is the one place where a request can be refused. It returns nil to let
// the request through. The checks of checkClient decide: a request they refuse
// is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
// or 431 for the header limits. In observe mode nothing is refused, and the log
// line names what enforce mode would have done. ctx is the request's own context.
// the request through. The heuristics of checkClient decide: a request they
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
// and the log line names what enforce mode would have done. ctx is the
// request's own context.
func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx)
v := rq.checkClient(ctx)
if v == nil {
return nil
}
action := v.action
switch {
case action == "":
return nil
case rq.h.config.Observe:
rq.line.WouldAction = action
@@ -183,35 +198,21 @@ func (rq *request) check(ctx context.Context) *refusal {
}
}
// checkClient runs the checks on the request's client, and returns the
// action of the first that refuses the request, or "" when none does. A
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any
// other client, SWW_DENY_NETS comes first, then a ban on its netblock,
// so that a client either refuses is not looked up, then the lookup of
// its AS number and country, then the country lists, then the blocklists,
// then the CrowdSec decision list, which bans the client it lists, then
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
// them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
// checkClient runs the heuristics of beforeLookup on the request, then looks
// up the client's AS number and country, then runs those of afterLookup. It
// returns the first verdict, or nil when none gives one. A client in
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
// own context.
func (rq *request) checkClient(ctx context.Context) *verdict {
if isInside(rq.client, rq.h.config.AllowNets) {
return nil
}
now := rq.h.now()
if isInside(rq.client, cfg.DenyNets) {
return requestlog.ActionDenied
}
if rq.banned(now) {
return requestlog.ActionBanned
v := rq.runHeuristics(ctx, beforeLookup(), now)
if v != nil {
return v
}
rq.lookUp(ctx, now)
@@ -220,59 +221,7 @@ func (rq *request) checkClient(ctx context.Context) string {
// again: a request counted at the time it arrived could be counted
// after a later one of its client's, which the rate limits would take
// for the clock set back.
now = rq.h.now()
if rq.countryDenied() {
return requestlog.ActionCountryDenied
}
if rq.blocklistDenied() {
return requestlog.ActionDenied
}
if rq.crowdSecBanned(now) {
return requestlog.ActionBanned
}
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied
}
rq.counted = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
}
// pathExempt reports whether r is exempt under prefixes,
+43
View File
@@ -1,6 +1,7 @@
package discriminator_test
import (
"encoding/json"
"io"
"math"
"net/http"
@@ -272,6 +273,48 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
}
}
// wantHeuristic checks that line names heuristic as the one that refused the
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
// a line that names none.
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.Heuristic != heuristic ||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic, line.HeuristicFigures, heuristic, figures)
}
}
// wantAfterAnswerHeuristic checks after_answer_heuristic and
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
// heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
}
}
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
// the log line's figures are decoded.
func decodeFigures(t *testing.T, figures string) map[string]any {
t.Helper()
var decoded map[string]any
err := json.Unmarshal([]byte(figures), &decoded)
if err != nil {
t.Fatalf("decode %s: %v", figures, err)
}
return decoded
}
// microseconds is a timing in whole microseconds.
func microseconds(milliseconds float64) int64 {
return int64(math.Round(milliseconds * 1000))
+59
View File
@@ -0,0 +1,59 @@
package discriminator
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// for a clear sign of attack, or in observe mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action.
type ruleFiles struct{}
func (ruleFiles) name() string { return "rule_files" }
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return nil
}
rq.addTag(config.ExploitSignatureTag)
// Only the last rule matched can refuse the request.
last := matched[len(matched)-1]
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
switch last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{action: requestlog.ActionBanned, figures: figures}
default:
return nil
}
}
+29
View File
@@ -93,6 +93,35 @@ func TestEachRuleAction(t *testing.T) {
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
}
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
}
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
}
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel()
-64
View File
@@ -1,64 +0,0 @@
package discriminator
import (
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPath reports whether the request asks for a path in
// SWW_TRAP_PATHS: its path as a path rule sees it, without the query,
// as sent or once percent-decoded, is one of them. Such a request is a
// clear sign of attack, as a ban rule's match is: it bans the client's
// netblock, or in observe mode raises the alert for the ban it would have
// made. The ban's notes give the path as sent.
func (rq *request) trapPath(now time.Time) bool {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return false
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return true
}
// checkRules checks the request against the rules of the rule files at
// now, notes the ids of those it matches in the log line, and returns the
// action of the rule that refuses it, ActionRuleBlocked for a block rule
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans
// the client's netblock for a clear sign of attack, or in observe mode
// raises the alert for the ban it would have made. Either rule's match
// is noted as an offence, for the client's history.
func (rq *request) checkRules(now time.Time) string {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return ""
}
// Only the last rule matched can refuse the request.
switch last := matched[len(matched)-1]; last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
return requestlog.ActionRuleBlocked
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return requestlog.ActionBanned
default:
return ""
}
}
+15 -6
View File
@@ -32,7 +32,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
trustedProxies: trustLocalhost,
allowNets: "198.51.100.0/24",
denyNets: fromKP,
deniedCountries: "kp",
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
@@ -83,9 +83,9 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
})
geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24",
tagDeny: denyKP,
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -104,6 +104,15 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
}
}
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
t.Parallel()
@@ -133,7 +142,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP,
deniedCountries: "kp",
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
@@ -145,7 +154,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
})
}
+30
View File
@@ -0,0 +1,30 @@
package discriminator
// The heuristic tag_deny. It reads SWW_TAG_DENY.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
// the first such tag, in sorted order.
type tagDeny struct{}
func (tagDeny) name() string { return "tag_deny" }
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"tag": tag},
}
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package discriminator_test
import (
"net/http"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=KP"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=kp"}`)
wantTags(t, line, "asn=64511", "country=kp")
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAnASNTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{torList: {fromDE}})
// Each is refused for its tag: none is counted to break the limit of one.
for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "torexit"}`)
}
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute,
tagLists: "vpn=" + vpnList + ",tor=" + torList,
tagLimitPercent: "vpn:50,tor:25",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"25 from "+tagLimitPercent)
}
+24
View File
@@ -0,0 +1,24 @@
package discriminator
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
// the reputation lists.
import (
"context"
"time"
)
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
// holds the client, the tag being the list's name. It refuses nothing, and runs
// first so that every refusal's log line carries these tags.
type tagLists struct{}
func (tagLists) name() string { return "tag_lists" }
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, name := range rq.h.lists.TagsOf(rq.client) {
rq.addTag(name)
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package discriminator_test
import (
"net/http"
"slices"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
const (
tagLists = "SWW_TAG_LISTS"
vpnList = "https://lists.example/vpn.txt"
torList = "https://lists.example/torexit.txt"
)
// wantTags checks the log line's tags.
func wantTags(t *testing.T, line logLine, want ...string) {
t.Helper()
got, _ := line.fields["tags"].([]any)
tags := make([]string, 0, len(got))
for _, tag := range got {
text, _ := tag.(string)
tags = append(tags, text)
}
if !slices.Equal(tags, want) {
t.Errorf("log line has tags %v, want %v", tags, want)
}
}
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
})
loadLists(t, server, map[string][]string{
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
})
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"asn=64496", "country=de", "torexit", "vpn=mullvad")
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
"asn=64511", "country=kp", "torexit")
}
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
t.Parallel()
// With no lookup, a client is not placed and no setting gives it a tag.
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v, want none", line.fields["tags"])
}
}
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, allowedCountries: "de",
})
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=kp", "vpn")
}
+39
View File
@@ -0,0 +1,39 @@
package discriminator
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a
// path rule sees it, without the query, as sent or once percent-decoded, is one
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
// it bans the client's netblock, or in observe mode raises the alert for the
// ban it would have made. The ban's notes give the path as sent, and so does
// its figure.
type trapPaths struct{}
func (trapPaths) name() string { return "trap_paths" }
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return nil
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{
action: requestlog.ActionBanned,
figures: map[string]any{"path": path},
}
}
+10
View File
@@ -83,6 +83,16 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
}
}
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
t.Parallel()
+2 -2
View File
@@ -25,7 +25,7 @@ type busiest struct {
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec
// refused are, by country, the requests the country lists refused; nil
// refused are, by country, the requests the country list refused; nil
// by AS number.
refused *prometheus.CounterVec
@@ -42,7 +42,7 @@ type busiest struct {
func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("sww_country_list_refusals_total",
"Requests the country lists refused, by the client's country.",
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"})
return countries
+28 -5
View File
@@ -1,7 +1,8 @@
// Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
@@ -71,6 +72,8 @@ type Params struct {
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string
ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY).
@@ -152,14 +155,18 @@ func New(params Params) *Lists {
}
// URLs returns the URL of every list: the blocklists' in the order
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
// the CrowdSec decision list's.
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
// tag lists', then the CrowdSec decision list's.
func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs)
if l.params.ASNLimitPercentURL != "" {
urls = append(urls, l.params.ASNLimitPercentURL)
}
for _, list := range l.params.TagLists {
urls = append(urls, list.URL)
}
if l.params.CrowdSecDecisionsURL != "" {
urls = append(urls, l.params.CrowdSecDecisionsURL)
}
@@ -184,6 +191,22 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
return listedBy
}
// TagsOf returns the names of the tag lists whose copy holds addr.
func (l *Lists) TagsOf(addr netip.Addr) []string {
l.mu.Lock()
defer l.mu.Unlock()
var names []string
for _, list := range l.params.TagLists {
if l.lists[list.URL].entries.contain(addr) {
names = append(names, list.Name)
}
}
return names
}
// ASNLimitPercent returns the percentage the copy of the file of
// AS:percent lines gives asn, and whether it lists asn.
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
@@ -551,7 +574,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
}
// parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
// AS number, : and a percentage, such as AS64496:50. An
// AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}}
+15 -2
View File
@@ -38,9 +38,10 @@ const (
// ActionDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny.
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country.
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
// ActionAdmin is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/.
@@ -131,6 +132,16 @@ type Line struct {
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// 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
// name.
Heuristic string `json:"heuristic,omitempty"`
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
// the heuristic that banned the client after discriminator answered the
// 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
// 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
@@ -162,6 +173,8 @@ type Line struct {
// whose verdict lists it, their keys masked, then abuseipdb when its
// score is a hit.
Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted.
Tags []string `json:"tags,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,
+9 -9
View File
@@ -611,14 +611,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE")
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_DENIED_COUNTRIES": "kp",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_TAG_DENY": "country=kp",
// The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would
// refuse them too.
@@ -1280,7 +1280,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_DENIED_COUNTRIES": "",
"SWW_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h",
+7 -52
View File
@@ -1,51 +1,8 @@
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
# They are the fields where gitea sends a search, a file name or a branch name.
# Each exclusion runs with the request, before the rules it switches off, those
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
# Each comment names a request the rule refused.
# A request whose hostname SWW_APPS does not list gitea for skips every rule
# below. Inspect sets tx.app_gitea on the others.
SecRule &TX:app_gitea "@eq 0" \
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
# The query parameters in which gitea sends names within a repository or its
# own records, or a page of its own site, are not checked against the lists of
# system files, shell paths and command names, on any path:
# 930120: .gitignore
# 932160: bin/docker-entrypoint
# 932260: docker-build
SecAction "id:900591,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:path,\
ctl:ruleRemoveTargetById=930120;ARGS:files,\
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932160;ARGS:path,\
ctl:ruleRemoveTargetById=932160;ARGS:files,\
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932260;ARGS:path,\
ctl:ruleRemoveTargetById=932260;ARGS:files,\
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
# The eighth change: 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. Each exclusion runs with the request, before the
# rules it switches off, those of phase 1 included, so this file is loaded before
# the Core Rule Set's rules. Each comment names a request the rule refused.
# gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there:
@@ -198,8 +155,8 @@ SecRule REQUEST_FILENAME \
# 942140: information_schema.sql
# 944110: java/lang/Runtime.java
# 944130: java.lang.String.md
# Each request skips the rules that refused it. Rule 900591 already has path and
# redirect_to skip 930120, 932160 and 932260 on every path.
# Each request skips the rules that refused it. The fourth change already has
# path and redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
@@ -324,5 +281,3 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
+57 -101
View File
@@ -1,9 +1,9 @@
// 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 six changes sww
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// in SPEC.md describes them. It reads no response.
// 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
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response.
//
// sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -16,8 +16,6 @@ import (
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/netip"
"os"
@@ -34,8 +32,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
// directives of every app in apps for the third.
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and
// giteaExclusions for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
@@ -83,28 +81,44 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too.
%s
# The apps' fixes come before the rules for the same reason.
# The eighth change comes before the rules for the same reason.
%s
# The fifth: only the rules for requests are loaded, and no response is
# The sixth: only the rules for requests are loaded, and no response is
# inspected.
Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or
# localhost. Coraza matches a parameter name here without regard to case.
# ARGS holds the fields of a form data or multipart body Coraza reads as
# well as the query parameters, so a field of this name is left out too.
# localhost. Coraza matches a parameter name here, and in the fourth,
# without regard to case. ARGS holds the fields of a form data or multipart
# body Coraza reads as well as the query parameters, so a field of one of
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: Referer is not checked for a Unix command without arguments,
# for Java starting a process, or for a Java class name. 944130 refused
# every page opened from the page of java.lang.String.md.
# The fourth: the query parameters in which gitea sends names within a
# repository or its own records, or a page of its own site, are not
# checked against the lists of system files, shell paths and command
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130
# refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The sixth: a container image push that reuses a layer names the image
# The seventh: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from
# owner/python3-app.
@@ -154,36 +168,18 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
`
// app is an app whose ordinary requests the Core Rule Set would refuse,
// with the fixes that let them pass.
type app struct {
// directives are Coraza exclusions, loaded before the Core Rule Set's
// rules. Their first rule skips the rest unless Inspect sets the
// transaction variable app_ and the app's name, and they end with the
// marker it skips to.
directives string
// cookies are the cookies the Core Rule Set reads a request without.
cookies []string
}
//go:embed gitea.conf
var giteaDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// giteaExclusions are the eighth change: the fields of gitea's own
// requests that skip the rules that refused them there, as Coraza
// directives.
//
//nolint:gochecknoglobals // a constant cannot be a map
var apps = map[string]app{
"gitea": {
directives: giteaDirectives,
cookies: []string{"gitea_flash", "redirect_to"},
},
}
//go:embed gitea.conf
var giteaExclusions string
// AppNames returns the names of the apps whose fixes sww has, in
// alphabetical order.
func AppNames() []string {
return slices.Sorted(maps.Keys(apps))
}
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
//
//nolint:gochecknoglobals // a constant cannot be a list
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
// Params are what New needs.
type Params struct {
@@ -199,10 +195,6 @@ type Params struct {
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
// apps only, or of every app while Apps is nil.
Apps map[string][]string
}
// The ids of sww's own rules, which an exclusions file may not
@@ -294,15 +286,13 @@ func checkExclusions(exclusions string) error {
type CoreRuleSet struct {
waf coraza.WAF
bodyLimit int64
apps map[string][]string
}
// New returns the Core Rule Set with the six changes and the apps' fixes,
// at params' paranoia level, without the rules it switches off, reading
// request bodies up to params' limit, and with params' exclusions file
// loaded before the Core Rule Set's rules. An exclusions file is an error
// if it cannot be read or does not parse, or if checkExclusions refuses
// it.
// New returns the Core Rule Set with the eight changes, at params'
// paranoia level, without the rules it switches off, reading request
// bodies up to params' limit, and with params' exclusions file loaded
// before the Core Rule Set's rules. An exclusions file is an error if it
// cannot be read or does not parse, or if checkExclusions refuses it.
func New(params Params) (*CoreRuleSet, error) {
body := ""
if params.BodyLimit > 0 {
@@ -325,13 +315,8 @@ func New(params Params) (*CoreRuleSet, error) {
}
}
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
fixes.String())
giteaExclusions)
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))
@@ -349,7 +334,7 @@ func New(params Params) (*CoreRuleSet, error) {
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
}
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
}
// Result is what the Core Rule Set found in a request.
@@ -361,12 +346,11 @@ type Result struct {
Score int
}
// Inspect runs the Core Rule Set on r, a request from client, with the
// fixes of the apps appsFor names for r's hostname: on its method, its URL
// with the query, and its headers, the Cookie header without those apps'
// cookies, and on body, r's body as the caller has it, as readBody reads
// it. It returns what it found, what it read of body, and the error that
// ended the reading early, if one did.
// Inspect runs the Core Rule Set on r, a request from client: on its
// method, its URL with the query, and its headers, the Cookie header
// without the cookies in cookiesNotRead, and on body, r's body as the
// caller has it, as readBody reads it. It returns what it found, what it
// read of body, and the error that ended the reading early, if one did.
func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) {
@@ -374,24 +358,13 @@ func (c *CoreRuleSet) Inspect(
// Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }()
// The transaction's variables are set only through Coraza's interface
// for plugins.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
var cookiesNotRead []string
for _, name := range c.appsFor(r.Host) {
state.Variables().TX().Set("app_"+name, []string{"1"})
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
}
tx.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header {
for _, value := range values {
if name == "Cookie" {
value = withoutCookies(value, cookiesNotRead)
value = withoutCookiesNotRead(value)
if value == "" {
continue // it held those cookies alone
}
@@ -484,26 +457,9 @@ func score(tx types.Transaction) int {
return n
}
// appsFor returns the names of the apps whose fixes a request for host,
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
// those SWW_APPS lists for host's name, without the port and in
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// withoutCookies returns value, a Cookie header's, without the cookies
// named in cookiesNotRead.
func withoutCookies(value string, cookiesNotRead []string) string {
// withoutCookiesNotRead returns value, a Cookie header's, without the
// cookies in cookiesNotRead.
func withoutCookiesNotRead(value string) string {
var kept []string
for cookie := range strings.SplitSeq(value, ";") {
+2 -92
View File
@@ -120,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
}
// wantChange checks that crs lets through passes, a gitea request one of
// the six changes or gitea's fixes are for, and still finds result in
// refused, a request like it that they are not for.
// 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 *waf.CoreRuleSet, passes, refused request, result waf.Result,
) {
@@ -381,96 +381,6 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
matched(930120, 932160))
}
// giteaFixed are gitea requests that only gitea's fixes let through, one
// for each kind of fix, each with what the Core Rule Set finds in it
// without them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaFixed = []struct {
request request
result waf.Result
}{
// A search of code, on the path of gitea's search.
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
// A file name, in a parameter gitea sends names in, on any path.
{get("/?path=.gitignore"), matched(930120)},
// A file name, in a cookie of gitea's.
{
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
matched(930120),
},
}
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
// one that serves another app.
const (
giteaHost = "git.example"
otherHost = "blog.example"
)
// withHost returns r sent to host.
func withHost(r request, host string) request {
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
}
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
// giving apps.
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, nil)
for _, fixed := range giteaFixed {
for _, host := range []string{giteaHost, otherHost} {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
// The hostname is matched without the port and without regard to case.
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
t.Parallel()
for _, apps := range []map[string][]string{
{giteaHost: {"gitea"}},
// SWW_APPS set empty lists no hostname.
{},
} {
crs := servingApps(t, apps)
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
}
}
}
}
// withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) {