Author SHA1 Message Date
sneak 21f7bf6f23 SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 1m34s
gitea's fixes (its parameters, its cookies and gitea.conf) now apply
only to a hostname SWW_APPS lists gitea for, or to every hostname while
it is unset. Each app's fixes are one .conf file, whose first rule skips
the rest unless Inspect sets tx.app_<name>, and one entry in apps.

A hostname in SWW_APPS that is not a DNS name, such as *.example.com,
stops the start. Unset, SWW_APPS is logged at start as null; set
empty, as "".

Model: opus-5-5
2026-11-07 13:40:33 +00:00
clawbot beb503b233 Rename the program to sww and its deciding daemon to discriminator (closes #218) (#221)
check / check (push) Failing after 1m34s
2026-11-07 06:51:06 +01:00
clawbot 2e2f6f7175 Core Rule Set: gitea's ordinary requests pass by default (#208)
check / check (push) Successful in 3m29s
2026-11-07 05:21:22 +01:00
82 changed files with 3100 additions and 1477 deletions

No files matched your search

+2 -2
View File
@@ -175,12 +175,12 @@ RUN groupadd --system --gid 65532 sww \
--gid sww --no-create-home --shell /usr/sbin/nologin \ --gid sww --no-create-home --shell /usr/sbin/nologin \
sww sww
# The state files' directory, SWWAF_STATE_DIR by default, where a volume # The state files' directory, SWW_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the # is mounted to keep them across deploys. The run script gives it to the
# sww user at each start of discriminator. # sww user at each start of discriminator.
RUN mkdir /var/lib/sww RUN mkdir /var/lib/sww
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile # The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it. # built FROM this image can copy rule files of its own beside it.
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
+160 -150
View File
@@ -7,8 +7,8 @@ requests of scrapers, scanners and other abusers before they reach an app.
## What it does ## What it does
A web application firewall reads each request on its way to a web app and A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
one image, with two commands that do this together: shipped in one image, with two commands that do this together:
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to - `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
it. It asks `discriminator` about each request, passes the allowed ones to the it. It asks `discriminator` about each request, passes the allowed ones to the
@@ -171,11 +171,11 @@ does.
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about `https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
the request have `"action":"forward"`. the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `discriminator` with - To see a refusal without banning yourself, run `discriminator` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. `SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
`discriminator`'s log lines have `"would_action":"banned"` and `discriminator`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes. "Clients and bans" below describes.
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on - With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
`discriminator`'s network: `discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`. `curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks - `make example-app` builds the image and an app on it, runs them, and checks
@@ -185,16 +185,17 @@ does.
Every setting is an environment variable, read once at start; changing one means Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and restarting the container. `rproxy` reads only the `SWRP_` settings, and
`discriminator` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has `discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
a default, chosen for a service on the open internet. A setting that needs an default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one. account, a key, a token or an alert destination stays off until you give one.
- Any `SWWAF_` setting can be given as a file instead, for secrets and long - Any `SWW_` setting can be given as a file instead, for secrets and long lists:
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. `SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
The file's contents are the value, less one newline at the end. Setting both contents are the value, less one newline at the end. Setting both forms stops
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
touches disk. `_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`. 1024: `512K`, `100M`, `5G`.
@@ -215,7 +216,7 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | | `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
@@ -227,108 +228,110 @@ Every setting the code reads, with its default:
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. | | `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. | | `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | | `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. | | `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. | | `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | | `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | | `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | | `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWWAF_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. | | `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | | `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | | `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | | `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. | | `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | | `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | | `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. | | `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. | | `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | | `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. | | `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | | `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | | `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | | `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. | | `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | | `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | | `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. | | `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | | `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. | | `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. | | `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. | | `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. | | `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | | `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `SWWAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. | | `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | | `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. | | `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. | | `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_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_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. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | | `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_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_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | | `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | | `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | | `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. | | `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWWAF_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_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. | | `SWW_CROWDSEC_LAPI_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. |
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWWAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | | `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | | `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | | `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | | `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWWAF_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_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | | `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. | | `SWW_LOG_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. |
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | | `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | | `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | | `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | | `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. | | `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWWAF_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_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | | `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. | | `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | | `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | | `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | | `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | | `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | | `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | | `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. | | `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. | | `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | | `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | | `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | | `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | | `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | | `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | | `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | | `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. | | `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. | | `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in [`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings. full, and the limits that are fixed rather than settings.
@@ -401,23 +404,23 @@ services:
environment: environment:
SWW_COMMAND: discriminator SWW_COMMAND: discriminator
# Monitoring skips every check. # Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10 SWW_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited. # The office and the automation host are never rate limited.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted. # A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25 SWW_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site. # Tighter limits for everyone else: a small static site.
SWWAF_RATE_LIMIT_PER_MINUTE: "60" SWW_RATE_LIMIT_PER_MINUTE: "60"
SWWAF_RATE_LIMIT_PER_HOUR: "600" SWW_RATE_LIMIT_PER_HOUR: "600"
SWWAF_RATE_LIMIT_PER_DAY: "3000" SWW_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits. # Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25 SWW_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client. # Paths only scanners ask for; one request bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403. # Answer a refused client with 429 rather than 403.
SWWAF_BAN_RESPONSE: "429" SWW_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m SWW_LIMIT_BAN_DURATION: 30m
volumes: volumes:
- discriminator-state:/var/lib/sww - discriminator-state:/var/lib/sww
@@ -453,16 +456,16 @@ services:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWW_COMMAND: discriminator SWW_COMMAND: discriminator
SWWAF_INSTANCE_NAME: fsn1/discriminator SWW_INSTANCE_NAME: fsn1/discriminator
# Refuse nothing yet; log what enforce mode would have done. # Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe SWW_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS. # Look addresses up in a local IPinfo Lite database, never GeoJS.
SWWAF_LOOKUP_SOURCE: file SWW_LOOKUP_SOURCE: file
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file. # Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes: volumes:
- discriminator-state:/var/lib/sww - discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
@@ -504,7 +507,7 @@ of the request reaches the app.
`discriminator` takes the checks: `discriminator` takes the checks:
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`. 3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
4. The bans. 4. The bans.
5. The lookup of the client's AS number and country. 5. The lookup of the client's AS number and country.
6. The country lists. 6. The country lists.
@@ -525,20 +528,20 @@ of the request reaches the app.
and the bytes each way. For an allowed request, `discriminator` counts the and the bytes each way. For an allowed request, `discriminator` counts the
bytes toward the byte limits and adds the request to the client's history. bytes toward the byte limits and adds the request to the client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD` positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
instead. A request over the header limits of step 12 is answered `431`, and bans request over the header limits of step 12 is answered `431`, and bans nobody
nobody either. `rproxy` itself answers `405` to a method it does not pass on, either. `rproxy` itself answers `405` to a method it does not pass on, and the
and the status for a size or time limit, such as `413` for a body too large. status for a size or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
- A client is one IPv4 address or one IPv6 netblock of - A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64. since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client. - Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound The table of clients and the table of bans each have a bound
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too. (`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused - A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin under a ban count toward no limit. A permanent ban ends only when an admin
lifts it. lifts it.
@@ -550,13 +553,20 @@ and the status for a size or time limit, such as `413` for a body too large.
### Attack detection ### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the - The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes them; "Attack detection" in Git Credential Manager. No setting undoes the changes; "Attack detection" in
[`SPEC.md`](SPEC.md) lists them. [`SPEC.md`](SPEC.md) lists them.
- Each supported app also has its own fixes, which let its ordinary requests
through. gitea's let its searches, file names and branch names through.
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
apps' fixes, and a hostname it does not list gets none. Unset, every app's
fixes apply to every hostname.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
while `discriminator` runs. The image ships one file of probes no real visitor while `discriminator` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)). sends (see [`docs/rules.md`](docs/rules.md)).
@@ -564,7 +574,7 @@ and the status for a size or time limit, such as `413` for a body too large.
### State files ### State files
`discriminator` keeps everything in memory, and the five JSON files in `discriminator` keeps everything in memory, and the five JSON files in
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, `SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
renamed over the real one, so a crash leaves a complete file. `discriminator` renamed over the real one, so a crash leaves a complete file. `discriminator`
notices a file edited on disk and takes the edit in; an edit that does not parse notices a file edited on disk and takes the edit in; an edit that does not parse
@@ -577,10 +587,10 @@ is set aside as `<name>.bad`, and never stops the running process (see
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the `Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)). same package each time it is built (see [`docs/image.md`](docs/image.md)).
- runit, a service supervisor, runs `smallwebwaf` as the `sww` user (uid and - runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
gid 65532) and starts it again when it exits. The `run` script of and starts it again when it exits. The `run` script of `discriminator` gives
`discriminator` gives the state directory to the `sww` user at each start, so the state directory to the `sww` user at each start, so a volume needs no
a volume needs no change of owner. change of owner.
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own - `rproxy` and `discriminator` use no token between them. `discriminator`'s own
endpoints, under `/_sww/`, are for your admin tools and your metrics endpoints, under `/_sww/`, are for your admin tools and your metrics
collector, at its own address on the private network; nothing routes the collector, at its own address on the private network; nothing routes the
+386 -273
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,8 +1,8 @@
// Command sww is a web application firewall. `sww rproxy` is // Command sww is a web application firewall. `sww rproxy` is the reverse
// the reverse proxy in front of one app, which asks discriminator about each request. // proxy in front of one app, which asks discriminator about each request.
// `sww discriminator` decides, for many sites, and keeps what lasts. // `sww discriminator` decides, for many sites, and keeps what lasts. `sww
// `sww healthcheck` is the container's health check. Everything it does // healthcheck` is the container's health check. Everything it does is in
// is in internal/sww. // internal/sww.
package main package main
import ( import (
+2 -2
View File
@@ -6,8 +6,8 @@
# A real app names the smallwebwaf image by digest. This one takes the # A real app names the smallwebwaf image by digest. This one takes the
# image script/example-app has just built, or else the one `make docker` # image script/example-app has just built, or else the one `make docker`
# builds. # builds.
ARG SMALLWEBWAF_IMAGE=smallwebwaf ARG SWW_IMAGE=smallwebwaf
FROM ${SMALLWEBWAF_IMAGE} FROM ${SWW_IMAGE}
# Packages the app needs, from the nixpkgs in the image. # Packages the app needs, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.busybox RUN nix-env -iA nixpkgs.busybox
+4 -4
View File
@@ -5,14 +5,14 @@ The endpoints under `/_sww/`, what each takes and what each answers.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_sww/healthz`: `200` and `ok`, to anyone. - `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_sww/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see - `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
[`metrics.md`](metrics.md)). [`metrics.md`](metrics.md)).
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it. - `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
Needs `SWWAF_ADMIN_TOKEN`, as do the three below. Needs `SWW_ADMIN_TOKEN`, as do the three below.
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`, - `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
`duration` and, if you like, `reason`. `netblock` is a netblock such as `duration` and, if you like, `reason`. `netblock` is a netblock such as
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such (`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
wrong: wrong:
- one that is not such an object, or has another field; - one that is not such an object, or has another field;
@@ -38,7 +38,7 @@ in [`SPEC.md`](../SPEC.md)).
## Examples ## Examples
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
as `discriminator:8080` on its network: as `discriminator:8080` on its network:
```sh ```sh
+3 -3
View File
@@ -77,7 +77,7 @@ on one line:
} }
``` ```
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above. one of the events above.
- `host`: the site of the request that raised the alert, since one - `host`: the site of the request that raised the alert, since one
`discriminator` serves many sites. It is left out of `source_failure`, `discriminator` serves many sites. It is left out of `source_failure`,
@@ -122,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
escaped. ntfy gets the text as the message, with these headers: escaped. ntfy gets the text as the message, with these headers:
- `Title`: the title; - `Title`: the title;
- `Authorization`: the token, as `Bearer <token>`, while - `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
`SWWAF_ALERT_NTFY_TOKEN` is set; is set;
- `Priority` and `Tags`: by event, as listed below. - `Priority` and `Tags`: by event, as listed below.
The priority and tag by event: The priority and tag by event:
+1 -1
View File
@@ -29,7 +29,7 @@ keeps the `instance` label as `exported_instance` unless the scrape sets
## Countries and AS numbers ## Countries and AS numbers
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`: For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
- `sww_country_requests_total`, `sww_country_request_bytes_total`, - `sww_country_requests_total`, `sww_country_request_bytes_total`,
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
+5 -5
View File
@@ -44,7 +44,7 @@ Every one of these lines has:
- `time`: when `discriminator` had read the question's body, RFC 3339 with - `time`: when `discriminator` had read the question's body, RFC 3339 with
milliseconds, in UTC. The `report` line gives the same time as the `decision` milliseconds, in UTC. The `report` line gives the same time as the `decision`
line. line.
- `instance`: `SWWAF_INSTANCE_NAME`. - `instance`: `SWW_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out. - `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request. - `method`, `path` and `query`: the visitor's request.
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of - `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
@@ -82,8 +82,8 @@ Every one of these lines has:
- GeoJS had not answered by the time `discriminator` answered. - GeoJS had not answered by the time `discriminator` answered.
- `content_type`: the visitor's `Content-Type`. - `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced. - `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name - `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
in lower case. lower case.
- `has_authorization` and `has_cookie`: true when the request had those headers. - `has_authorization` and `has_cookie`: true when the request had those headers.
Their values are never logged. Their values are never logged.
@@ -96,8 +96,8 @@ Every one of these lines has:
- `action`: what was done: - `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app; - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with - `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
the action `deny`; action `deny`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a - `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client; CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
+171 -146
View File
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why. that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWWAF_` are `discriminator`'s. Those that start The settings that start with `SWW_` are `discriminator`'s. Those that start with
with `SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` `SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
chooses between the two (see "The image's command" below). between the two (see "The image's command" below).
## Forms of values ## Forms of values
@@ -28,243 +28,268 @@ chooses between the two (see "The image's command" below).
## Core ## Core
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and - `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
a port, such as `:9000`. Leave the host out (see "Ports" in port, such as `:9000`. Leave the host out (see "Ports" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance` - `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
name in each line `discriminator` logs, and in each alert and metric, such as in each line `discriminator` logs, and in each alert and metric, such as
`fsn1/discriminator`. Valid UTF-8. No control character while `fsn1/discriminator`. Valid UTF-8. No control character while
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a `SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
not. - `SWW_MODE` (default `enforce`): `enforce` or `observe`.
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`. - `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 group, from 32 to 128.
client's group, from 32 to 128. - `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero. number above zero.
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `discriminator` - `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
keeps, a whole number above zero. keeps, a whole number above zero.
## Tokens ## Tokens
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
`/_sww/clients/<ip>`. At least 32 characters. `/_sww/clients/<ip>`. At least 32 characters.
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least - `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
32 characters. 32 characters.
## State files ## State files
- `SWWAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, - `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
an absolute path that exists and can be written. absolute path that exists and can be written.
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be - `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration. written after a ban is made, lifted or made permanent. A duration.
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
written. A duration. written. A duration.
## Logging ## Logging
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own - `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
messages that are written: `debug`, `info`, `warn` or `error`. messages that are written: `debug`, `info`, `warn` or `error`.
- `SWWAF_LOG_REQUEST_HEADERS` (default - `SWW_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the `accept,accept-language,accept-encoding,content-type,origin,range`): the
request headers recorded in the log line, in either case. `Host` and request headers recorded in the log line, in either case. `Host` and
`Transfer-Encoding` are refused; the host is the field `host`. `Transfer-Encoding` are refused; the host is the field `host`.
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every - `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`, line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
each with a host and a port and nothing more. each with a host and a port and nothing more.
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates - `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
that a `syslog+tls` server's certificate must chain to, instead of the host's a `syslog+tls` server's certificate must chain to, instead of the host's own.
own. A file that cannot be read, or holds no certificate, is refused. A file that cannot be read, or holds no certificate, is refused.
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they - `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
wait to be sent, a whole number above zero. to be sent, a whole number above zero.
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, - `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`. `authpriv`, `ftp`, or `local0` to `local7`.
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in - `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
each syslog record, 1 to 48 printable ASCII characters without a space. syslog record, 1 to 48 printable ASCII characters without a space.
## Metrics ## Metrics
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many - `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
countries get series of their own. A whole number above zero. get series of their own. A whole number above zero.
## Static lists ## Static lists
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your - `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)). (see step 3 in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see - `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
step 3 in [`SPEC.md`](../SPEC.md)). 3 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a client may make in a minute. A count or `off`. The default leaves room for a
person opening two pages at once with an empty cache, up to 60 files each. person opening two pages at once with an empty cache, up to 60 files each.
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may - `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
make in an hour. A count or `off`. It is the main limit against scrapers. A make in an hour. A count or `off`. It is the main limit against scrapers. A
person reading a site for an hour, a page every 20 seconds, makes about 900. A person reading a site for an hour, a page every 20 seconds, makes about 900. A
scraper making one request every 2 seconds makes 1,800 an hour, more than the scraper making one request every 2 seconds makes 1,800 an hour, more than the
limit. limit.
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may - `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
make in a day. A count or `off`. The default is four hours at the hour limit. in a day. A count or `off`. The default is four hours at the hour limit. A
A scraper making one request every 10 seconds makes 8,640 a day, more than the scraper making one request every 10 seconds makes 8,640 a day, more than the
limit. limit.
- A CI runner asks its code forge for work all day and can pass the limits. Put - A CI runner asks its code forge for work all day and can pass the limits. Put
the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office. network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
rate limiting and the byte limits, such as `/assets/`. limiting and the byte limits, such as `/assets/`.
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most (default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
bytes counted for a client in each window. A size or `off`. counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: - `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`. `response`, `request` or `both`.
## Lookup of AS number and country ## Lookup of AS number and country
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set: the start while a setting that needs a lookup is set:
- the two country lists; - the two country lists;
- `SWWAF_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`; - the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its - `SWW_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 client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app - `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
the client's AS number in `X-Client-ASN` and its country in the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`. `X-Client-Country`.
## Country lists ## Country lists
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused, - `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
such as `cn,ru,kp`. such as `cn,ru,kp`.
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country on both lists countries whose clients get through, such as `us,de`. A country on both lists
stops the start. stops the start.
## Biased thresholds ## Biased thresholds
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` - `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
listed twice is refused. listed twice is refused.
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as - `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
`cn:25,ru:50`. `cn:25,ru:50`.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty): - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
the same, for the byte limits alone. same, for the byte limits alone.
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
lookup cannot place. lookup cannot place.
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - `SWW_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 text file with one AS number and percentage on each line, such as
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused. `AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection ## Attack detection
- `SWWAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule - `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
files. - `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file. - `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`. - `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, - `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
from 1 to 4. from 1 to 4.
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a - `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
match. A whole number above zero, or `off`. match. A whole number above zero, or `off`.
- `SWWAF_WAF_DISABLED_RULES` (default - `SWW_WAF_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules `920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
to switch off. Each a whole number above zero. An id from 900000 to 900999 is to switch off. Each a whole number above zero. An id from 900000 to 900999 is
refused: those are the rules that set the Core Rule Set up, and refused: those are the rules that set the Core Rule Set up, and
`smallwebwaf`'s own. `smallwebwaf`'s own.
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
not inspect. not inspect.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body - `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
the Core Rule Set reads, a size of at most `64K`. A question carries no more Core Rule Set reads, a size of at most `64K`. A question carries no more of a
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
file that names a number from 900000 to 900999 outside a comment, alone or in
a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form.
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
fixes of its hostname's apps only (see "Attack detection" in
[`SPEC.md`](../SPEC.md)).
- Entries are separated by semicolons. Each is a hostname, a colon, then one
or more apps separated by commas, for example
`git.example.com:gitea;code.example.com:gitea`.
- `gitea` is the only app supported.
- The request's `Host` is matched by exact name, without its port and
without regard to case. There are no wildcards.
- A hostname not listed gets no app's fixes, so every rule checks it in
full. Set empty, it lists no hostname.
- Unset, every app's fixes apply to every hostname.
- Any of these mistakes stops the start:
- an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
## Ban lengths, response and scope ## Ban lengths, response and scope
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. - `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A - `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
duration. duration.
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends - `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration. within which breaking a limit again lengthens the next ban. A duration.
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; - `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
one that would be longer is permanent. A duration. that would be longer is permanent. A duration.
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of - `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
attack. A duration. attack. A duration.
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client - `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
covers: the prefix length of its netblock, from 0 to 32. covers: the prefix length of its netblock, from 0 to 32.
## Reputation ## Reputation
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as - `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL `https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
without a user or a fragment. A URL listed twice is refused. without a user or a fragment. A URL listed twice is refused.
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its - `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
last try before it is fetched again. A duration of `1h` or more. try before it is fetched again. A duration of `1h` or more.
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`. - `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as - `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus `dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
is refused, whatever its case or key. is refused, whatever its case or key.
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, - `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
port, 53 when none is given. Unset, the host's resolver is used. port, 53 when none is given. Unset, the host's resolver is used.
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A - `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
control character in it is refused. control character in it is refused.
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, - `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
from 0 to 100, that is a hit. from 0 to 100, that is a hit.
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, - `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
a whole number above zero. AbuseIPDB's free accounts may make 1,000. whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine - `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
user or a fragment; its decision list is at `v1/decisions` under it. Set user or a fragment; its decision list is at `v1/decisions` under it. Set
without the key, it is refused. So is a decision list URL that is also in without the key, it is refused. So is a decision list URL that is also in
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`. `SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for - `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused. URL, it is refused.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or - `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits. `log`, for DNS blocklist and AbuseIPDB hits.
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or - `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
AbuseIPDB's score is used. A duration. AbuseIPDB's score is used. A duration.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a - `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
check with AbuseIPDB may take. A duration. with AbuseIPDB may take. A duration.
## Alerting ## Alerting
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each - `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
alert as a JSON object. An `http` or `https` URL without a user or a fragment. as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, - `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
value. A value cannot hold a comma. value cannot hold a comma.
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook - `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
that receives each alert as a message, checked as the webhook URL is. receives each alert as a message, checked as the webhook URL is.
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each - `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
as the webhook URL is. webhook URL is.
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control - `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused. character in it is refused.
- `SWWAF_ALERT_EVENTS` (default - `SWW_ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`): `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md) the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused. but `summary`. Any other name is refused.
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its - `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
repeats are held back. A duration or `off`. repeats are held back. A duration or `off`.
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A - `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
whole number above zero, or `off`. whole number above zero, or `off`.
## Anomaly thresholds ## Anomaly thresholds
@@ -273,25 +298,25 @@ Each scope has four thresholds: the most requests and the most bytes counted in
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
default. The four thresholds per client are: default. The four thresholds per client are:
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`; - `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`. - `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
The other scopes' names have another prefix before the same four endings, such The other scopes' names have another prefix before the same four endings, such
as `REQUESTS_PER_MINUTE`: as `REQUESTS_PER_MINUTE`:
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the - `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
netblock's length: netblock's length:
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 - `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
client; client;
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 - `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client. client.
- `SWWAF_ANOMALY_ASN_...`: per AS number. - `SWW_ANOMALY_ASN_...`: per AS number.
- `SWWAF_ANOMALY_TOTAL_...`: the whole service. - `SWW_ANOMALY_TOTAL_...`: the whole service.
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole. - `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such `SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice. name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files ## Settings given as files
@@ -312,7 +337,7 @@ Then mount its directory into the container read-only, and name the file:
docker run \ docker run \
-v /srv/app/tokens:/etc/sww/tokens:ro \ -v /srv/app/tokens:/etc/sww/tokens:ro \
-e SWW_COMMAND=discriminator \ -e SWW_COMMAND=discriminator \
-e SWWAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ -e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
... ...
``` ```
@@ -336,10 +361,10 @@ A few limits are fixed rather than settings:
## `discriminator`'s answer and `rproxy`'s wait ## `discriminator`'s answer and `rproxy`'s wait
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. `SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
asked in the background (see "Answering in time" under "`discriminator`" in in the background (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
## `rproxy` ## `rproxy`
@@ -392,14 +417,14 @@ check checks: `rproxy` or `discriminator` (see "Deployment" in
Nothing reads these names any more: Nothing reads these names any more:
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. - `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. - `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. - `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. - `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are - `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds. `SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are - `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. `SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have - `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
takes a request line and headers of at most 32 KiB. takes a request line and headers of at most 32 KiB.
+7 -7
View File
@@ -1,13 +1,13 @@
# State files # State files
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
Each has a top-level `version` of 1. a top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
those `discriminator` made. Per entry: `discriminator` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban); - `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`; - `cause`: `attack`, `limit`, `admin` or `crowdsec`;
@@ -93,10 +93,10 @@ GeoJS answers, one per line:
## Sizes ## Sizes
- `clients.json`: about 1 KiB per client, about 20 MiB at the default - `clients.json`: about 1 KiB per client, about 20 MiB at the default
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of `SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
disk writes a day. disk writes a day.
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at - `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
more. more.
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when - `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
+30 -30
View File
@@ -1,12 +1,12 @@
// Package alerts sends alerts on bans, on traffic over an anomaly // Package alerts sends alerts on bans, on traffic over an anomaly
// threshold, on a source that fails and on a file with an error to each // threshold, on a source that fails and on a file with an error to each
// destination set: to the webhook // destination set: to the webhook
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// object" in docs/alerts.md describes, to the Slack incoming // object" in docs/alerts.md describes, to the Slack incoming
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the // webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within // ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past // SWW_ALERT_COOLDOWN is held back, and so is an alert past
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// bounded queue of each destination's own, so that a destination that is // bounded queue of each destination's own, so that a destination that is
// slow or unreachable holds up neither the others nor any request. The // slow or unreachable holds up neither the others nor any request. The
// state is written to alerts.json and read from it by the state package. // state is written to alerts.json and read from it by the state package.
@@ -34,7 +34,7 @@ import (
"time" "time"
) )
// The events an alert is for, as SWWAF_ALERT_EVENTS names them. // The events an alert is for, as SWW_ALERT_EVENTS names them.
const ( const (
// EventBan is a ban sww made. // EventBan is a ban sww made.
EventBan = "ban" EventBan = "ban"
@@ -45,7 +45,7 @@ const (
// threshold. // threshold.
EventAnomaly = "anomaly" EventAnomaly = "anomaly"
// EventWAFBlock is a request the Core Rule Set scored at or over // EventWAFBlock is a request the Core Rule Set scored at or over
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode. // detect mode.
EventWAFBlock = "waf_block" EventWAFBlock = "waf_block"
// EventReputationHit is a request whose client a blocklist, the // EventReputationHit is a request whose client a blocklist, the
@@ -61,14 +61,14 @@ const (
// cannot be read, or a state file that cannot be written. // cannot be read, or a state file that cannot be written.
EventFileError = "file_error" EventFileError = "file_error"
// EventSummary is the summary sent as an hour ends: of the alerts held // EventSummary is the summary sent as an hour ends: of the alerts held
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held // back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
// back by the cooldowns dropped as it ends, which no alert let through // back by the cooldowns dropped as it ends, which no alert let through
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for // has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWWAF_ALERT_EVENTS does not name it. // those repeats. SWW_ALERT_EVENTS does not name it.
EventSummary = "summary" EventSummary = "summary"
) )
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its // Events returns every event SWW_ALERT_EVENTS can name, which is its
// default. // default.
func Events() []string { func Events() []string {
return []string{ return []string{
@@ -80,12 +80,12 @@ func Events() []string {
// The destinations alerts are sent to, as the metrics and alerts.json // The destinations alerts are sent to, as the metrics and alerts.json
// name them. // name them.
const ( const (
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names. // DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
DestinationWebhook = "webhook" DestinationWebhook = "webhook"
// DestinationSlack is the Slack incoming webhook // DestinationSlack is the Slack incoming webhook
// SWWAF_ALERT_SLACK_WEBHOOK_URL names. // SWW_ALERT_SLACK_WEBHOOK_URL names.
DestinationSlack = "slack" DestinationSlack = "slack"
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names. // DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
DestinationNtfy = "ntfy" DestinationNtfy = "ntfy"
) )
@@ -121,26 +121,26 @@ var (
// NtfyURL set, no alert is sent. // NtfyURL set, no alert is sent.
type Params struct { type Params struct {
// WebhookURL is where each alert is posted as JSON // WebhookURL is where each alert is posted as JSON
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). // are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
WebhookURL *url.URL WebhookURL *url.URL
WebhookHeaders http.Header WebhookHeaders http.Header
// SlackURL is the Slack incoming webhook each alert is posted to as a // SlackURL is the Slack incoming webhook each alert is posted to as a
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. // message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
SlackURL *url.URL SlackURL *url.URL
// NtfyURL is the ntfy topic each alert is published to // NtfyURL is the ntfy topic each alert is published to
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless // (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN). // empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
NtfyURL *url.URL NtfyURL *url.URL
NtfyToken string NtfyToken string
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS). // Events are the events alerts are sent for (SWW_ALERT_EVENTS).
Events []string Events []string
// Cooldown is how long a repeat of an alert is held back // Cooldown is how long a repeat of an alert is held back
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts // (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit. // sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
Cooldown time.Duration Cooldown time.Duration
MaxPerHour int MaxPerHour int
// Instance is SWWAF_INSTANCE_NAME, which every alert gives. // Instance is SWW_INSTANCE_NAME, which every alert gives.
Instance string Instance string
// Now tells the time of an alert, normally time.Now in UTC. // Now tells the time of an alert, normally time.Now in UTC.
Now func() time.Time Now func() time.Time
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
} }
if params.WebhookURL != nil { if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL", q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage) params.WebhookURL, q.webhookMessage)
} }
if params.SlackURL != nil { if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL", q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage) params.SlackURL, slackMessage)
} }
if params.NtfyURL != nil { if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL", q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage) params.NtfyURL, q.ntfyMessage)
} }
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
} }
// Raise sends alert, which names its event and what is particular to it, // Raise sends alert, which names its event and what is particular to it,
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event // unless no destination is set or SWW_ALERT_EVENTS leaves its event
// out. It gives alert the instance and the time. An alert that repeats // out. It gives alert the instance and the time. An alert that repeats
// the last one let through less than Cooldown before is held back and // the last one let through less than Cooldown before is held back and
// counted. The next one let through gives that count, unless an hour of // counted. The next one let through gives that count, unless an hour of
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
} }
// WouldSend reports whether Raise would let an alert for event on // WouldSend reports whether Raise would let an alert for event on
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses // netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
// event, no alert for event on netblock was let through less than // event, no alert for event on netblock was let through less than
// Cooldown before, and fewer than MaxPerHour alerts have been let through // Cooldown before, and fewer than MaxPerHour alerts have been let through
// in the hour under way. Unlike Raise, it counts nothing. // in the hour under way. Unlike Raise, it counts nothing.
@@ -606,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
if heldBack > 0 { if heldBack > 0 {
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+ reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack, "past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour)) q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
} }
if repeats > 0 { if repeats > 0 {
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+ reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats)) "SWW_ALERT_COOLDOWN that no later alert gives", repeats))
} }
if len(reasons) > 0 { if len(reasons) > 0 {
+11 -11
View File
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
if q.WouldSend(alerts.EventBan, netblock(1)) || if q.WouldSend(alerts.EventBan, netblock(1)) ||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) { q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out") t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
} }
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) { if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
onlyNtfy.NtfyURL = parseURL(ntfyURL) onlyNtfy.NtfyURL = parseURL(ntfyURL)
for setting, params := range map[string]alerts.Params{ for setting, params := range map[string]alerts.Params{
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack, "SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWWAF_ALERT_NTFY_URL": onlyNtfy, "SWW_ALERT_NTFY_URL": onlyNtfy,
} { } {
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
t.Errorf("a ban alert would not be sent with only %s set", setting) t.Errorf("a ban alert would not be sent with only %s set", setting)
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
"as_name": "", "as_name": "",
"country": "", "country": "",
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " + "reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows", "past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
"detail": map[string]any{ "detail": map[string]any{
"hour": started, "hour": started,
"count": float64(3), "count": float64(3),
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
wantCounts(t, q, 0, int64(len(want)), 0, 0) wantCounts(t, q, 0, int64(len(want)), 0, 0)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) { `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
t.Errorf("process log %q names no failure", log.String()) t.Errorf("process log %q names no failure", log.String())
} }
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
wantCounts(t, q, 1, 3, 0, 1) wantCounts(t, q, 1, 3, 0, 1)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) { `"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
t.Errorf("process log %q names no alert given up", log.String()) t.Errorf("process log %q names no alert given up", log.String())
} }
}) })
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") { strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
} }
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
} }
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " + const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + "past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" "by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary) wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary) wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "hooks.slack.example") || strings.Contains(logged, "hooks.slack.example") ||
strings.Contains(logged, "T0123") { strings.Contains(logged, "T0123") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
@@ -1254,7 +1254,7 @@ type eventMessage struct {
title, priorityAndTag, text string title, priorityAndTag, text string
} }
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS // anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
// names, the first in observe mode, each with its message. // names, the first in observe mode, each with its message.
func anAlertForEachEvent() []eventMessage { func anAlertForEachEvent() []eventMessage {
return []eventMessage{ return []eventMessage{
+12 -12
View File
@@ -28,16 +28,16 @@ const maxCounters = 20000
// alerts name them. // alerts name them.
const ( const (
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of // ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX. // SWW_IPV6_GROUP_PREFIX.
ScopeClient = "client" ScopeClient = "client"
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX // ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
// or SWWAF_ANOMALY_NET_V6_PREFIX long. // or SWW_ANOMALY_NET_V6_PREFIX long.
ScopeNet = "net" ScopeNet = "net"
// ScopeASN is an AS number. // ScopeASN is an AS number.
ScopeASN = "asn" ScopeASN = "asn"
// ScopeTotal is the whole service. // ScopeTotal is the whole service.
ScopeTotal = "total" ScopeTotal = "total"
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS. // ScopeWatch is a named netblock of SWW_WATCH_NETS.
ScopeWatch = "watch" ScopeWatch = "watch"
) )
@@ -62,7 +62,7 @@ type Thresholds struct {
BytesPerHour int64 BytesPerHour int64
} }
// NamedNetblock is a netblock SWWAF_WATCH_NETS names. // NamedNetblock is a netblock SWW_WATCH_NETS names.
type NamedNetblock struct { type NamedNetblock struct {
Name string Name string
Netblock netip.Prefix Netblock netip.Prefix
@@ -70,14 +70,14 @@ type NamedNetblock struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*, // The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and // SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
// SWWAF_WATCH_*. // SWW_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds Client, Net, ASN, Total, Watch Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a // NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX). // client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWWAF_WATCH_NETS. // NamedNetblocks are SWW_WATCH_NETS.
NamedNetblocks []NamedNetblock NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts. // Alerts receive the anomaly alerts.
Alerts *alerts.Queue Alerts *alerts.Queue
@@ -107,13 +107,13 @@ type Request struct {
Host string Host string
// Client is the client's address, and ClientGroup the client it is // Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of // counted as: its IPv4 address, or the IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX its address is in. // SWW_IPV6_GROUP_PREFIX its address is in.
Client netip.Addr Client netip.Addr
ClientGroup netip.Prefix ClientGroup netip.Prefix
// ASN, ASName and Country are the client's as looked up, each "" when // ASN, ASName and Country are the client's as looked up, each "" when
// unknown. // unknown.
ASN, ASName, Country string ASN, ASName, Country string
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them. // Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
Bytes int64 Bytes int64
} }
+2 -2
View File
@@ -123,7 +123,7 @@ type Notes struct {
LimitPercentSetting string `json:"limit_percent_setting,omitempty"` LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id // RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target; TrapPath is, for // of the rule file rule that matched, and its target; TrapPath is, for
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the // one for a request for a path in SWW_TRAP_PATHS, that path as the
// client sent it. // client sent it.
RuleID string `json:"rule_id,omitempty"` RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"` Target string `json:"target,omitempty"`
@@ -199,7 +199,7 @@ type Ledger struct {
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6 // v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of // netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its // them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX, // netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock. // or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int v4Lengths, v6Lengths []int
} }
+333 -228
View File
@@ -1,6 +1,6 @@
// Package config reads sww's settings, and is the one place they are // Package config reads sww's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of `sww // read. Every setting is an environment variable. The settings of `sww
// discriminator` start with SWWAF_, every one of them has a default, and most can be // discriminator` start with SWW_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each // given as a file instead. Those of `sww rproxy` start with SWRP_. Each
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file, // of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. SWW_COMMAND chooses which of the two // since rproxy never touches disk. SWW_COMMAND chooses which of the two
@@ -29,90 +29,91 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// Config is the settings of `sww discriminator`. A time or size limit of // Config is the settings of `sww discriminator`. A time or size limit of
// zero is off. // zero is off.
type Config struct { type Config struct {
// ListenAddr is where discriminator listens (SWWAF_LISTEN_ADDR). // ListenAddr is where discriminator listens (SWW_LISTEN_ADDR).
ListenAddr string ListenAddr string
// InstanceName is the name every log line and alert gives as instance, // InstanceName is the name every log line and alert gives as instance,
// and every metric carries as its label instance (SWWAF_INSTANCE_NAME), // and every metric carries as its label instance (SWW_INSTANCE_NAME),
// by default the host's name, which docker sets to the first 12 // by default the host's name, which docker sets to the first 12
// characters of the container's id. // characters of the container's id.
InstanceName string InstanceName string
// Observe is true in observe mode, when SWWAF_MODE is observe rather // Observe is true in observe mode, when SWW_MODE is observe rather
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country // than enforce: a request that SWW_DENY_NETS, a ban, the country
// lists, a rate limit, a rule or the Core Rule Set would refuse is // lists, a rate limit, a rule or the Core Rule Set would refuse is
// allowed instead, and no ban is made. // allowed instead, and no ban is made.
Observe bool Observe bool
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client // IPv6GroupPrefix is the length of the IPv6 netblock that is one client
// (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128. // (SWW_IPV6_GROUP_PREFIX), from 32 to 128.
IPv6GroupPrefix int IPv6GroupPrefix int
// MaxTrackedClients is the most clients the table of clients holds, in // MaxTrackedClients is the most clients the table of clients holds, in
// memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS). // memory and in clients.json (SWW_MAX_TRACKED_CLIENTS).
MaxTrackedClients int MaxTrackedClients int
// AllowNets are the netblocks whose clients skip every check // AllowNets are the netblocks whose clients skip every check
// (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the // (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS). // rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS).
// DenyNets are those whose clients are always refused // DenyNets are those whose clients are always refused
// (SWWAF_DENY_NETS). // (SWW_DENY_NETS).
AllowNets []netip.Prefix AllowNets []netip.Prefix
RateLimitExemptNets []netip.Prefix RateLimitExemptNets []netip.Prefix
DenyNets []netip.Prefix DenyNets []netip.Prefix
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
// most requests a client may make in a minute, an hour and a day // most requests a client may make in a minute, an hour and a day
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and // (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and
// SWWAF_RATE_LIMIT_PER_DAY). // SWW_RATE_LIMIT_PER_DAY).
RateLimitPerMinute int64 RateLimitPerMinute int64
RateLimitPerHour int64 RateLimitPerHour int64
RateLimitPerDay int64 RateLimitPerDay int64
// RateLimitExemptPaths are the path prefixes whose requests the rate // RateLimitExemptPaths are the path prefixes whose requests the rate
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS). // limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS).
// Each starts with /. // Each starts with /.
RateLimitExemptPaths []string RateLimitExemptPaths []string
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the // BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
// most bytes a client's requests may carry in a minute, an hour and a // most bytes a client's requests may carry in a minute, an hour and a
// day (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and // day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and
// SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count // SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWWAF_BYTES_COUNT): response, request or both. // toward them (SWW_BYTES_COUNT): response, request or both.
BytesLimitPerMinute int64 BytesLimitPerMinute int64
BytesLimitPerHour int64 BytesLimitPerHour int64
BytesLimitPerDay int64 BytesLimitPerDay int64
BytesCount string BytesCount string
// LookupSource is where each client's AS number and country are // LookupSource is where each client's AS number and country are
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. // looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up // LookupDBPath is the lookup database, the IPinfo Lite file looked up
// in while LookupSource is file (SWWAF_LOOKUP_DB_PATH), and "" for any // in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to LookupTimeout for its client's // other source. A request waits up to LookupTimeout for its client's
// first answer from GeoJS while a setting needs it // first answer from GeoJS while a setting needs it
// (SWWAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true // (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
// when the app is passed the client's AS number and country in headers // when the app is passed the client's AS number and country in headers
// (SWWAF_ADD_LOOKUP_HEADERS). // (SWW_ADD_LOOKUP_HEADERS).
LookupSource string LookupSource string
LookupDBPath string LookupDBPath string
LookupTimeout time.Duration LookupTimeout time.Duration
AddLookupHeaders bool AddLookupHeaders bool
// DeniedCountries are the countries whose clients are refused // DeniedCountries are the countries whose clients are refused
// (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not // (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// empty, are the only countries whose clients are let through // empty, are the only countries whose clients are let through
// (SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in // (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them. // capitals, as GeoJS gives them.
DeniedCountries []string DeniedCountries []string
ExclusivelyAllowedCountries []string ExclusivelyAllowedCountries []string
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give // The biased thresholds. ASNLimitPercent and CountryLimitPercent give
// the clients of the AS numbers and the countries they list that // the clients of the AS numbers and the countries they list that
// percentage of every rate limit and byte limit // percentage of every rate limit and byte limit
// (SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT). // (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
// ASNBytesPercent and CountryBytesPercent give those they list a // ASNBytesPercent and CountryBytesPercent give those they list a
// percentage of the byte limits in place of that one // percentage of the byte limits in place of that one
// (SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT). Each holds // (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by // 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. // country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without // UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWWAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is // a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages // where a file of AS:percent lines is fetched from, whose percentages
// count as those of ASNLimitPercent do (SWWAF_ASN_LIMIT_PERCENT_URL), "" // count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
// while it is unset. // while it is unset.
ASNLimitPercent map[string]int64 ASNLimitPercent map[string]int64
CountryLimitPercent map[string]int64 CountryLimitPercent map[string]int64
@@ -121,30 +122,30 @@ type Config struct {
UnknownLimitPercent int64 UnknownLimitPercent int64
ASNLimitPercentURL string ASNLimitPercentURL string
// BlocklistURLs are where the blocklists are fetched from // BlocklistURLs are where the blocklists are fetched from
// (SWWAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is // (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// fetched again BlocklistRefresh after it was last fetched or tried // fetched again BlocklistRefresh after it was last fetched or tried
// (SWWAF_BLOCKLIST_REFRESH), which is never less than an hour. // (SWW_BLOCKLIST_REFRESH), which is never less than an hour.
// BlocklistAction is what is done with a client a blocklist lists // BlocklistAction is what is done with a client a blocklist lists
// (SWWAF_BLOCKLIST_ACTION): deny, limit or log; for limit, // (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit,
// BlocklistLimitPercent is the percentage of every limit it gets. // BlocklistLimitPercent is the percentage of every limit it gets.
BlocklistURLs []string BlocklistURLs []string
BlocklistRefresh time.Duration BlocklistRefresh time.Duration
BlocklistAction string BlocklistAction string
BlocklistLimitPercent int64 BlocklistLimitPercent int64
// DNSBLZones are the DNSBL zones clients are asked about // DNSBLZones are the DNSBL zones clients are asked about
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or // (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or
// the host's resolver while that is the zero AddrPort. // the host's resolver while that is the zero AddrPort.
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked // AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
// with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score // with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score
// of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and // of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and
// at most AbuseIPDBDailyBudget checks are made a day // at most AbuseIPDBDailyBudget checks are made a day
// (SWWAF_ABUSEIPDB_DAILY_BUDGET). // (SWW_ABUSEIPDB_DAILY_BUDGET).
// ReputationAction is what is done with a client a zone's verdict lists, // ReputationAction is what is done with a client a zone's verdict lists,
// or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log; // or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log;
// for limit, ReputationLimitPercent is the percentage of every limit it // for limit, ReputationLimitPercent is the percentage of every limit it
// gets. A verdict or a score is used for ReputationCacheTTL after it was // gets. A verdict or a score is used for ReputationCacheTTL after it was
// fetched (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take // fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off. // ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string DNSBLZones []string
DNSBLResolver netip.AddrPort DNSBLResolver netip.AddrPort
AbuseIPDBKey string AbuseIPDBKey string
@@ -155,94 +156,102 @@ type Config struct {
ReputationCacheTTL time.Duration ReputationCacheTTL time.Duration
ReputationTimeout time.Duration ReputationTimeout time.Duration
// CrowdSecDecisionsURL is where the decision list of the CrowdSec // CrowdSecDecisionsURL is where the decision list of the CrowdSec
// engine whose local API SWWAF_CROWDSEC_LAPI_URL names is fetched from: // engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from:
// that URL with v1/decisions added to its path, "" while it is unset and // that URL with v1/decisions added to its path, "" while it is unset and
// none is. CrowdSecKey is the key the engine is asked with // none is. CrowdSecKey is the key the engine is asked with
// (SWWAF_CROWDSEC_LAPI_KEY). // (SWW_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string CrowdSecDecisionsURL string
CrowdSecKey string CrowdSecKey string
// BanResponse is the status a refused client is answered with, 403 // BanResponse is the status a refused client is answered with, 403
// or 429, or 0 to close the connection without an answer // or 429, or 0 to close the connection without an answer
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that // (SWW_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the // breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
// country lists. // country lists.
BanResponse int BanResponse int
// LimitBanDuration is the ban for a first broken rate limit // LimitBanDuration is the ban for a first broken rate limit
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within // (SWW_LIMIT_BAN_DURATION). A limit broken again within
// LimitBanRepeatWindow after the last ban ended bans for three times // LimitBanRepeatWindow after the last ban ended bans for three times
// as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that // as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that
// would be longer than MaxBanDuration is permanent instead // would be longer than MaxBanDuration is permanent instead
// (SWWAF_MAX_BAN_DURATION). None of them can be off. // (SWW_MAX_BAN_DURATION). None of them can be off.
LimitBanDuration time.Duration LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration MaxBanDuration time.Duration
// AttackBanDuration is the ban for a first clear sign of attack // AttackBanDuration is the ban for a first clear sign of attack
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off. // (SWW_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration AttackBanDuration time.Duration
// MaxBans is the most bans held (SWWAF_MAX_BANS). // MaxBans is the most bans held (SWW_MAX_BANS).
MaxBans int MaxBans int
// BanScopeV4Prefix is the length of the netblock around an IPv4 // BanScopeV4Prefix is the length of the netblock around an IPv4
// client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX). // client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX).
BanScopeV4Prefix int BanScopeV4Prefix int
// StateDir is the directory of the state files, an absolute path // StateDir is the directory of the state files, an absolute path
// (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban // (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWWAF_STATE_WRITE_DELAY), and every state file every // is made (SWW_STATE_WRITE_DELAY), and every state file every
// StateCounterInterval (SWWAF_STATE_COUNTER_INTERVAL). Neither can be // StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be
// off. // off.
StateDir string StateDir string
StateWriteDelay time.Duration StateWriteDelay time.Duration
StateCounterInterval time.Duration StateCounterInterval time.Duration
// LogRequestHeaders are the request headers whose values the request // LogRequestHeaders are the request headers whose values the request
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS). // log gives, in lower case (SWW_LOG_REQUEST_HEADERS).
LogRequestHeaders []string LogRequestHeaders []string
// LogLevel is the least severe of the process's own messages that are // LogLevel is the least severe of the process's own messages that are
// written (SWWAF_LOG_LEVEL). It holds back no request log line. // written (SWW_LOG_LEVEL). It holds back no request log line.
LogLevel slog.Level LogLevel slog.Level
// AdminToken is the bearer token an admin sends for the ban endpoints // AdminToken is the bearer token an admin sends for the ban endpoints
// and /_sww/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is // and /_sww/clients/<ip> (SWW_ADMIN_TOKEN), "" while it is
// unset and they are off. // unset and they are off.
AdminToken string AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics // MetricsToken is the bearer token a scraper sends for the metrics
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off. // (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off.
// MetricsTopN is how many AS numbers and how many countries get series // MetricsTopN is how many AS numbers and how many countries get series
// of their own in the metrics (SWWAF_METRICS_TOP_N). // of their own in the metrics (SWW_METRICS_TOP_N).
MetricsToken string MetricsToken string
MetricsTopN int MetricsTopN int
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read // RulesDir is the directory of the rule files (SWW_RULES_DIR), read
// unless RulesEnabled is false (SWWAF_RULES_ENABLED). // unless RulesEnabled is false (SWW_RULES_ENABLED).
RulesDir string RulesDir string
RulesEnabled bool RulesEnabled bool
// WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff, // WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff,
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia // WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and // level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and
// WAFAnomalyThreshold the anomaly score at which a request is a match // WAFAnomalyThreshold the anomaly score at which a request is a match
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules // (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES), // are the ids of its rules switched off (SWW_WAF_DISABLED_RULES),
// WAFExemptPaths the path prefixes it does not inspect // WAFExemptPaths the path prefixes it does not inspect
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body // (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
// (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string WAFMode string
WAFParanoiaLevel int WAFParanoiaLevel int
WAFAnomalyThreshold int WAFAnomalyThreshold int
WAFDisabledRules []int WAFDisabledRules []int
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64 WAFBodyLimit int64
CoreRuleSetExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
// request gets every app's fixes.
Apps map[string][]string
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWW_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute // ErrorBurstThreshold is the most requests of a client within a minute
// that discriminator may refuse after a rule file or Core Rule Set match; one // that discriminator may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off. // more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64 ErrorBurstThreshold int64
// LogRemoteURL is where every line on stdout is also sent // LogRemoteURL is where every line on stdout is also sent
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent. // (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's // LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
// certificate must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil while // certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while
// it is unset and the host's own are used. LogRemoteBuffer is the most // it is unset and the host's own are used. LogRemoteBuffer is the most
// lines held while they wait to be sent (SWWAF_LOG_REMOTE_BUFFER). // lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER).
// LogRemoteFacility is the number of the syslog facility // LogRemoteFacility is the number of the syslog facility
// (SWWAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME // (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWWAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records // (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// the lines are sent in. // the lines are sent in.
LogRemoteURL *url.URL LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool LogRemoteTLSCAs *x509.CertPool
@@ -250,18 +259,18 @@ type Config struct {
LogRemoteFacility int LogRemoteFacility int
LogRemoteAppName string LogRemoteAppName string
// AlertWebhookURL is where each alert is posted as JSON // AlertWebhookURL is where each alert is posted as JSON
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). // are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
// AlertSlackWebhookURL is the Slack incoming webhook each alert is // AlertSlackWebhookURL is the Slack incoming webhook each alert is
// posted to as a message (SWWAF_ALERT_SLACK_WEBHOOK_URL), and // posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and
// AlertNtfyURL the ntfy topic each is published to // AlertNtfyURL the ntfy topic each is published to
// (SWWAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken, // (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
// unless empty, is sent to ntfy with each (SWWAF_ALERT_NTFY_TOKEN). // unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN).
// With none of the three URLs set, no alert is sent. AlertEvents are // With none of the three URLs set, no alert is sent. AlertEvents are
// the events alerts are sent for (SWWAF_ALERT_EVENTS). A repeat of an // the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an
// alert within AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and // alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and
// so is an alert past AlertMaxPerHour in an hour, for the hour's // so is an alert past AlertMaxPerHour in an hour, for the hour's
// summary (SWWAF_ALERT_MAX_PER_HOUR); 0 is off for both. // summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both.
AlertWebhookURL *url.URL AlertWebhookURL *url.URL
AlertWebhookHeaders http.Header AlertWebhookHeaders http.Header
AlertSlackWebhookURL *url.URL AlertSlackWebhookURL *url.URL
@@ -271,14 +280,14 @@ type Config struct {
AlertCooldown time.Duration AlertCooldown time.Duration
AlertMaxPerHour int AlertMaxPerHour int
// The anomaly thresholds, which only raise alerts: the most requests // The anomaly thresholds, which only raise alerts: the most requests
// and bytes a minute and an hour per client (SWWAF_ANOMALY_CLIENT_*), // and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*),
// per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number // per netblock around a client (SWW_ANOMALY_NET_*), per AS number
// (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*) // (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*)
// and per named netblock (SWWAF_WATCH_*), each 0 while it is off. // and per named netblock (SWW_WATCH_*), each 0 while it is off.
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the // AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
// netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and // netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and
// SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks // SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWWAF_WATCH_NETS). // (SWW_WATCH_NETS).
AnomalyClient anomaly.Thresholds AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds AnomalyASN anomaly.Thresholds
@@ -297,7 +306,7 @@ type Config struct {
// off. // off.
const off = "off" const off = "off"
// The values of SWWAF_WAF_MODE. // The values of SWW_WAF_MODE.
const ( const (
// WAFModeOff runs no request through the Core Rule Set. // WAFModeOff runs no request through the Core Rule Set.
WAFModeOff = off WAFModeOff = off
@@ -307,8 +316,8 @@ const (
WAFModeBlock = "block" WAFModeBlock = "block"
) )
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the // fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
// lookup database, the file SWWAF_LOOKUP_DB_PATH names. // lookup database, the file SWW_LOOKUP_DB_PATH names.
const fileSource = "file" const fileSource = "file"
const ( const (
@@ -318,7 +327,7 @@ const (
gibibyte = 1 << 30 gibibyte = 1 << 30
ipv4Bits = 32 ipv4Bits = 32
ipv6Bits = 128 ipv6Bits = 128
// minIPv6GroupPrefix is the shortest SWWAF_IPV6_GROUP_PREFIX, the // minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the
// netblock a provider is usually given: a shorter one would make one // netblock a provider is usually given: a shorter one would make one
// client of the customers of several providers. // client of the customers of several providers.
minIPv6GroupPrefix = 32 minIPv6GroupPrefix = 32
@@ -335,7 +344,7 @@ const (
// masked is what the log shows for a token that is set, and in place of // masked is what the log shows for a token that is set, and in place of
// a secret in another setting. // a secret in another setting.
masked = "********" masked = "********"
// defaultListenAddr is the default of SWWAF_LISTEN_ADDR and // defaultListenAddr is the default of SWW_LISTEN_ADDR and
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL. // SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
defaultListenAddr = ":8080" defaultListenAddr = ":8080"
defaultUpstreamURL = "http://127.0.0.1:8081" defaultUpstreamURL = "http://127.0.0.1:8081"
@@ -364,7 +373,7 @@ var (
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " + "is taken out of every request by Go's HTTP server, so it can never " +
"be logged") "be logged")
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too") errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
errNotLookupSource = errors.New("is not geojs, file or off") errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up") errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New("it names the file to look clients up in")
@@ -395,6 +404,10 @@ var (
"is not a path prefix starting with /, such as /assets/") "is not a path prefix starting with /, such as /assets/")
errNotTrapPath = errors.New( errNotTrapPath = errors.New(
"is not a path starting with / and without a ?, such as /wp-login.php") "is not a path starting with / and without a ?, such as /wp-login.php")
errNotAppsEntry = errors.New(
"is not a hostname, : and a list of apps, such as git.example.com:gitea")
errNotHostname = errors.New("is not a DNS name such as git.example.com")
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
errNotWAFMode = errors.New("is not off, detect or block") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
errNotRuleID = errors.New( errNotRuleID = errors.New(
@@ -432,7 +445,7 @@ var (
errNotListURL = errors.New( errNotListURL = errors.New(
"is not an http or https URL without a user or a fragment, " + "is not an http or https URL without a user or a fragment, " +
"such as https://www.spamhaus.org/drop/drop.txt") "such as https://www.spamhaus.org/drop/drop.txt")
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too") errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too")
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
errNotAction = errors.New( errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log") "is not deny, limit:<percent> such as limit:25, or log")
@@ -447,7 +460,7 @@ var (
errNeedsLAPIKey = errors.New("the engine answers no request without it") errNeedsLAPIKey = errors.New("the engine answers no request without it")
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New( errAnotherList = errors.New(
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too") "is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
@@ -460,102 +473,105 @@ var (
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR"), ListenAddr: env.address("SWW_LISTEN_ADDR"),
InstanceName: env.instanceName(), InstanceName: env.instanceName(),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWW_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"), IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"),
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"), MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), AllowNets: env.netblocks("SWW_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), DenyNets: env.netblocks("SWW_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"), RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"), BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"), LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries( ExclusivelyAllowedCountries: env.countries(
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN), ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry), CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN), ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry), CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"), UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"),
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"),
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"),
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), DNSBLZones: env.zones("SWW_DNSBL_ZONES"),
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"), AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"),
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"), ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"),
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"), ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"), CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"),
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/sww"), StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"),
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"),
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"),
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range"), "accept,accept-language,accept-encoding,content-type,origin,range"),
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"),
AdminToken: env.token("SWWAF_ADMIN_TOKEN"), AdminToken: env.token("SWW_ADMIN_TOKEN"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"), MetricsToken: env.token("SWW_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"),
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/sww/rules.d"), RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"), WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"),
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"), WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"),
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"), "920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), CoreRuleSetExclusionsFile: env.filePath(
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), Apps: env.apps("SWW_APPS"),
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"), TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"), ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"), LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"),
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"), LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"),
AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"), LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"),
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"),
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"),
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"),
AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWW_ALERT_EVENTS",
strings.Join(alerts.Events(), ",")), strings.Join(alerts.Events(), ",")),
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"), AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"),
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWWAF_WATCH_"), AnomalyWatch: env.thresholds("SWW_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"), AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"),
AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"), AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"),
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), WatchNets: env.namedNetblocks("SWW_WATCH_NETS"),
} }
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME", cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME",
cfg.InstanceName, cfg.LogRemoteURL != nil) cfg.InstanceName, cfg.LogRemoteURL != nil)
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action( cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
"SWWAF_BLOCKLIST_ACTION", "deny") "SWW_BLOCKLIST_ACTION", "deny")
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action( cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
"SWWAF_REPUTATION_ACTION", "limit:25") "SWW_REPUTATION_ACTION", "limit:25")
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil) env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
env.checkLookupDBPath(cfg) env.checkLookupDBPath(cfg)
@@ -588,17 +604,17 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
} }
} }
// DiscriminatorListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a // DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in discriminator's container needs // file, as FromEnvironment does. The health check in discriminator's container needs
// no other setting, so it reads no other, nor a file that another names. // no other setting, so it reads no other, nor a file that another names.
func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) { func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR") listenAddr := env.address("SWW_LISTEN_ADDR")
return listenAddr, env.err return listenAddr, env.err
} }
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a // InstanceName reads only SWW_INSTANCE_NAME, which may be given as a
// file, as FromEnvironment does, so that the line saying a setting is // file, as FromEnvironment does, so that the line saying a setting is
// invalid carries it too. A file that cannot be read gives the default // invalid carries it too. A file that cannot be read gives the default
// here, and FromEnvironment then stops the start over it. // here, and FromEnvironment then stops the start over it.
@@ -775,6 +791,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
return prefixes return prefixes
} }
// apps reads the setting that is the apps each hostname serves. It is nil
// while the setting is unset, and logged as null, unlike a setting set
// empty.
func (e *environment) apps(name string) map[string][]string {
value, set := e.lookup(name)
if !set {
e.settings = append(e.settings, slog.Any(name, nil))
return nil
}
e.settings = append(e.settings, slog.String(name, value))
apps, err := parseApps(value)
e.check(name, err)
return apps
}
// trapPaths reads the setting that is the list of trap paths. It is empty // trapPaths reads the setting that is the list of trap paths. It is empty
// by default. // by default.
func (e *environment) trapPaths(name string) []string { func (e *environment) trapPaths(name string) []string {
@@ -956,30 +991,30 @@ func (e *environment) lookupSource(name, defaultValue string) string {
return source return source
} }
// checkLookupDBPath refuses SWWAF_LOOKUP_SOURCE=file without // checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without
// SWWAF_LOOKUP_DB_PATH, and SWWAF_LOOKUP_DB_PATH with any other source: // SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source:
// one source at a time. // one source at a time.
func (e *environment) checkLookupDBPath(cfg *Config) { func (e *environment) checkLookupDBPath(cfg *Config) {
switch { switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf( e.check("SWW_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) "is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) "is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
} }
} }
// checkCountriesAndLookups refuses a country on both country lists, and, // checkCountriesAndLookups refuses a country on both country lists, and,
// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked // while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds, // up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
// SWWAF_ASN_LIMIT_PERCENT_URL among them, of which // SWW_ASN_LIMIT_PERCENT_URL among them, of which
// SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a // SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number. // limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) { func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries { for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) { if slices.Contains(cfg.DeniedCountries, country) {
e.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists)) fmt.Errorf("%q %w", country, errOnBothLists))
} }
} }
@@ -992,52 +1027,52 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string name string
set bool set bool
}{ }{
{"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0}, {"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0}, {"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders}, {"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWWAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0}, {"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWWAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0}, {"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0}, {"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0}, {"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100}, {"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWWAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""}, {"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0}, {"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0}, {"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
{"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0}, {"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, {"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
} { } {
if setting.set { if setting.set {
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w",
errNeedsLookups)) errNeedsLookups))
} }
} }
} }
// checkASNLimitPercentURL refuses SWWAF_ASN_LIMIT_PERCENT_URL naming a // checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a
// blocklist too: the file at a URL is fetched as one list or the other. // blocklist too: the file at a URL is fetched as one list or the other.
func (e *environment) checkASNLimitPercentURL(cfg *Config) { func (e *environment) checkASNLimitPercentURL(cfg *Config) {
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) { if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
e.check("SWWAF_ASN_LIMIT_PERCENT_URL", e.check("SWW_ASN_LIMIT_PERCENT_URL",
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs)) fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
} }
} }
// checkCrowdSec refuses SWWAF_CROWDSEC_LAPI_URL without // checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without
// SWWAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list // SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
// that is fetched as another list too. // that is fetched as another list too.
func (e *environment) checkCrowdSec(cfg *Config) { func (e *environment) checkCrowdSec(cfg *Config) {
decisionsURL := cfg.CrowdSecDecisionsURL decisionsURL := cfg.CrowdSecDecisionsURL
switch { switch {
case decisionsURL != "" && cfg.CrowdSecKey == "": case decisionsURL != "" && cfg.CrowdSecKey == "":
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf( e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) "is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
case decisionsURL == "" && cfg.CrowdSecKey != "": case decisionsURL == "" && cfg.CrowdSecKey != "":
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf( e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) "is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
decisionsURL == cfg.ASNLimitPercentURL): decisionsURL == cfg.ASNLimitPercentURL):
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
decisionsURL, errAnotherList)) decisionsURL, errAnotherList))
} }
} }
@@ -1189,6 +1224,16 @@ func (e *environment) logRemoteURL(name string) *url.URL {
return remote return remote
} }
// filePath reads a setting that is the path of a file, "" while it is
// unset. Its value names a file already, so, unlike the other settings,
// it has no _FILE form. Whoever uses the file reads it.
func (e *environment) filePath(name string) string {
path, _ := e.lookupEnv(name)
e.settings = append(e.settings, slog.String(name, path))
return path
}
// certificates reads a setting that is the path of a file of PEM // certificates reads a setting that is the path of a file of PEM
// certificates. Unset or empty, it is nil. Its value names a file // certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form. // already, so, unlike the other settings, it has no _FILE form.
@@ -1226,14 +1271,14 @@ func (e *environment) facility(name, defaultValue string) int {
return number return number
} }
// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It // instanceName reads SWW_INSTANCE_NAME, by default the host's name. It
// must be valid UTF-8: the metrics library panics on a label that is not. // must be valid UTF-8: the metrics library panics on a label that is not.
func (e *environment) instanceName() string { func (e *environment) instanceName() string {
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
value := e.value("SWWAF_INSTANCE_NAME", hostname) value := e.value("SWW_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) { if !utf8.ValidString(value) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
} }
return value return value
@@ -1255,7 +1300,7 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
case set: case set:
e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
case sending: case sending:
e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w", e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w",
value, errNotAppName)) value, errNotAppName))
} }
@@ -1263,19 +1308,19 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
} }
// checkInstanceNameForNtfy refuses an instance name that holds a control // checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent // character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one. // the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) { func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf( e.check("SWW_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", "%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
instanceName, errControlCharacter)) instanceName, errControlCharacter))
} }
} }
// webhookURL reads a setting that is a URL each alert is posted to: // webhookURL reads a setting that is a URL each alert is posted to:
// SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or // SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or
// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted // SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
// there. The log shows ******** in place of its path and query, and an // there. The log shows ******** in place of its path and query, and an
// error shows none of it, since a webhook or an ntfy topic can carry its // error shows none of it, since a webhook or an ntfy topic can carry its
// secret there. // secret there.
@@ -1631,6 +1676,65 @@ func parseTrapPaths(value string) ([]string, error) {
return paths, nil return paths, nil
} }
// parseApps reads the apps each hostname serves: entries separated by
// semicolons, each a hostname, a colon, and a comma-separated list of the
// names waf.AppNames returns, such as
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
// hostname is a DNS name, without a wildcard. It returns them by hostname
// in lowercase, since a hostname is matched without regard to case. An
// empty value lists no hostname.
func parseApps(value string) (map[string][]string, error) {
apps := map[string][]string{}
if strings.TrimSpace(value) == "" {
return apps, nil
}
for entry := range strings.SplitSeq(value, ";") {
hostname, list, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
if !isZone(hostname) {
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
}
if _, listed := apps[hostname]; listed {
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
}
names, err := parseList(list)
if err != nil {
return nil, err
}
if len(names) == 0 {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
}
for i, name := range names {
if !slices.Contains(waf.AppNames(), name) {
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
strings.Join(waf.AppNames(), ", "))
}
if slices.Contains(names[:i], name) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
}
apps[hostname] = names
}
return apps, nil
}
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set // parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
// rules, each a whole number above zero and outside firstSetupRuleID to // rules, each a whole number above zero and outside firstSetupRuleID to
// lastSetupRuleID. // lastSetupRuleID.
@@ -1724,7 +1828,7 @@ func parseCountry(value string) (string, error) {
// ParseASN reads an AS number such as AS64496, in either case, and // ParseASN reads an AS number such as AS64496, in either case, and
// returns it as the lookup gives it: AS and the number, in capitals and // returns it as the lookup gives it: AS and the number, in capitals and
// without leading zeros. The file SWWAF_ASN_LIMIT_PERCENT_URL names is // without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is
// read with it too. // read with it too.
func ParseASN(value string) (string, error) { func ParseASN(value string) (string, error) {
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS") digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
@@ -1778,7 +1882,7 @@ func parsePercents(
} }
// ParsePercent reads a percentage, a whole number from 0 to 100. The file // ParsePercent reads a percentage, a whole number from 0 to 100. The file
// SWWAF_ASN_LIMIT_PERCENT_URL names is read with it too. // SWW_ASN_LIMIT_PERCENT_URL names is read with it too.
func ParsePercent(value string) (int64, error) { func ParsePercent(value string) (int64, error) {
percent, err := strconv.ParseInt(value, 10, 64) percent, err := strconv.ParseInt(value, 10, 64)
if err != nil || percent < 0 || percent > 100 { if err != nil || percent < 0 || percent > 100 {
@@ -1998,7 +2102,7 @@ const (
maxLabelLength = 63 maxLabelLength = 63
// labelChars are the characters a label of a DNS zone may hold. // labelChars are the characters a label of a DNS zone may hold.
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-" labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
// dnsPort is the port a resolver is asked on when SWWAF_DNSBL_RESOLVER // dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER
// gives none. // gives none.
dnsPort = 53 dnsPort = 53
) )
@@ -2057,7 +2161,8 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest return masked + "." + rest
} }
// isZone reports whether each label of zone is as parseZones takes it. // isZone reports whether each label of zone is as parseZones takes it. It
// also checks the hostnames of SWW_APPS, which are DNS names too.
func isZone(zone string) bool { func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") { for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool { badChar := strings.ContainsFunc(label, func(char rune) bool {
+192 -100
View File
@@ -23,98 +23,100 @@ import (
// The settings, by name. // The settings, by name.
const ( const (
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWW_LISTEN_ADDR"
mode = "SWWAF_MODE" mode = "SWW_MODE"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS" denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWWAF_BYTES_COUNT" bytesCount = "SWW_BYTES_COUNT"
lookupSource = "SWWAF_LOOKUP_SOURCE" lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWWAF_DENIED_COUNTRIES" deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWWAF_BLOCKLIST_URLS" blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH" blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
blocklistAction = "SWWAF_BLOCKLIST_ACTION" blocklistAction = "SWW_BLOCKLIST_ACTION"
dnsblZones = "SWWAF_DNSBL_ZONES" dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" dnsblResolver = "SWW_DNSBL_RESOLVER"
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE" abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET" abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWWAF_REPUTATION_ACTION" reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL" reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT" reputationTimeout = "SWW_REPUTATION_TIMEOUT"
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
banResponse = "SWWAF_BAN_RESPONSE" banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWWAF_MAX_BAN_DURATION" maxBanDuration = "SWW_MAX_BAN_DURATION"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS" maxBans = "SWW_MAX_BANS"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
stateDir = "SWWAF_STATE_DIR" stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N" metricsTopN = "SWW_METRICS_TOP_N"
instanceName = "SWWAF_INSTANCE_NAME" instanceName = "SWW_INSTANCE_NAME"
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
logLevel = "SWWAF_LOG_LEVEL" logLevel = "SWW_LOG_LEVEL"
rulesDir = "SWWAF_RULES_DIR" rulesDir = "SWW_RULES_DIR"
rulesEnabled = "SWWAF_RULES_ENABLED" rulesEnabled = "SWW_RULES_ENABLED"
wafMode = "SWWAF_WAF_MODE" wafMode = "SWW_WAF_MODE"
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL" wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
trapPaths = "SWWAF_TRAP_PATHS" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" apps = "SWW_APPS"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" trapPaths = "SWW_TRAP_PATHS"
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER" logRemoteURL = "SWW_LOG_REMOTE_URL"
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY" logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME" logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS" logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL" alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertNtfyURL = "SWWAF_ALERT_NTFY_URL" alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
alertEvents = "SWWAF_ALERT_EVENTS" alertNtfyURL = "SWW_ALERT_NTFY_URL"
alertCooldown = "SWWAF_ALERT_COOLDOWN" alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" alertEvents = "SWW_ALERT_EVENTS"
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" alertCooldown = "SWW_ALERT_COOLDOWN"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
watchNets = "SWWAF_WATCH_NETS" anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS"
) )
// The anomaly thresholds: each of the prefixes below, which name a scope, // The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends. // followed by each of the four ends.
const ( const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_" anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_" anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_" anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_" anomalyTotal = "SWW_ANOMALY_TOTAL_"
watch = "SWWAF_WATCH_" watch = "SWW_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -155,21 +157,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged return logged
} }
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and // defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN. // defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
const ( const (
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," + defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
"source_failure,file_error" "source_failure,file_error"
defaultAlertCooldown = "15m" defaultAlertCooldown = "15m"
) )
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL. // defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h" const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES. // defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS. // defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range" "content-type,origin,range"
@@ -194,8 +196,8 @@ const (
otherToken = "fedcba9876543210fedcba9876543210" otherToken = "fedcba9876543210fedcba9876543210"
) )
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and // instance is an SWW_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines. // remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
const ( const (
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514" remoteURL = "syslog+udp://192.0.2.1:514"
@@ -207,7 +209,7 @@ const off = "off"
// enabled is true, as a setting's value. // enabled is true, as a setting's value.
const enabled = "true" const enabled = "true"
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and // defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database. // fileSource the source that is the lookup database.
const ( const (
defaultLookupSource = "geojs" defaultLookupSource = "geojs"
@@ -539,13 +541,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "32K", wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 32 << 10, WAFBodyLimit: 32 << 10,
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
}, },
{ {
@@ -566,6 +569,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold, WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths, WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
WAFBodyLimit: cfg.WAFBodyLimit, WAFBodyLimit: cfg.WAFBodyLimit,
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want) t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
@@ -573,6 +577,92 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestApps(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want map[string][]string
}{
{environment{}, nil},
{environment{apps: ""}, map[string][]string{}},
{
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
map[string][]string{
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
},
},
} {
cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
}
}
}
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
env environment
want string
}{
{environment{}, `"` + apps + `":null`},
{environment{apps: ""}, `"` + apps + `":""`},
} {
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) {
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
}
}
}
func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel()
const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{
"git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
},
{
"git.example.com:gitea;GIT.example.com:gitea",
`"git.example.com" is listed twice`,
},
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
} {
t.Run(tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
want := apps + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestWAFBodyLimitOf64K(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1201,7 +1291,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
// Set empty, the lists need nothing looked up, and nor does // Set empty, the lists need nothing looked up, and nor does
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, any other anomaly threshold, or // threshold per AS number that is off, any other anomaly threshold, or
// a blocklist. // a blocklist.
env := environment{ env := environment{
@@ -1439,7 +1529,7 @@ const (
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
) )
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them. // The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
const ( const (
actionDeny = "deny" actionDeny = "deny"
actionLimit = "limit" actionLimit = "limit"
@@ -1535,7 +1625,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv) }.lookupEnv)
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too` want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1838,7 +1928,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " + notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080" "such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` + anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too` `SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -2328,6 +2418,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafDisabledRules: defaultWAFDisabledRules, wafDisabledRules: defaultWAFDisabledRules,
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "",
apps: "", // logged as null, which decodes as ""
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+1 -1
View File
@@ -51,7 +51,7 @@ func (rq *request) answerHealth() {
// answerAdmin answers a request for one of discriminator's own endpoints. Each // answerAdmin answers a request for one of discriminator's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics // needs a token, sent as Authorization: Bearer <token>: the metrics
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is // SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other // refused with 401. An endpoint whose token is unset answers 404, as any other
// path does. // path does.
func (rq *request) answerAdmin() { func (rq *request) answerAdmin() {
+4 -4
View File
@@ -17,8 +17,8 @@ import (
) )
const ( const (
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer // adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
// how a request carries it. // how a request carries it.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
adminBearer = "Bearer " + adminSecret adminBearer = "Bearer " + adminSecret
@@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
} }
} }
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens. // adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
type adminEndpoint struct { type adminEndpoint struct {
method, path, body string method, path, body string
} }
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN // adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
// opens: listing the bans, banning otherClient for an hour, lifting the // opens: listing the bans, banning otherClient for an hour, lifting the
// bans on otherClient, and asking about otherClient. // bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint { func adminEndpoints() []adminEndpoint {
+2 -2
View File
@@ -15,8 +15,8 @@ import (
) )
const ( const (
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
// alertInstance is the instance every alert of these tests gives. // alertInstance is the instance every alert of these tests gives.
alertInstance = "fsn1app1/blog" alertInstance = "fsn1app1/blog"
) )
+11 -11
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a // The anomaly thresholds: the prefix of a scope followed by the end of a
// count. // count.
const ( const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_" anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_" anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_" anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_" anomalyTotal = "SWW_ANOMALY_TOTAL_"
anomalyWatch = "SWWAF_WATCH_" anomalyWatch = "SWW_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -35,9 +35,9 @@ const (
// The other anomaly settings. // The other anomaly settings.
const ( const (
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWWAF_WATCH_NETS" watchNets = "SWW_WATCH_NETS"
) )
const ( const (
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWWAF_DENY_NETS denied = "192.0.2.20" // in SWW_DENY_NETS
) )
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
+3 -3
View File
@@ -26,7 +26,7 @@ const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time // answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// discriminator has to answer. // discriminator has to answer.
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT. // defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
@@ -166,7 +166,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
for _, env := range []map[string]string{ for _, env := range []map[string]string{
{wafMode: off}, {wafMode: off},
{wafMode: "detect"}, {wafMode: "detect"},
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"}, {wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
} { } {
t.Run(fmt.Sprint(env), func(t *testing.T) { t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -399,7 +399,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action string action string
}{ }{
{ {
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client}, name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
}, },
{ {
+7 -7
View File
@@ -12,7 +12,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged // banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
// with action. // with action.
func (rq *request) banResponse(action string) *refusal { func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
@@ -85,8 +85,8 @@ func (rq *request) countBytes() {
// countRefusal counts the request for the error burst once it has been // countRefusal counts the request for the error burst once it has been
// answered, if discriminator refused it after a rule file match, a trap path or a // answered, if discriminator 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 // Core Rule Set match, and in observe mode if enforce mode would have: more than
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute // SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is // break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
// not counted, and nothing is while the threshold is off. // not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() { func (rq *request) countRefusal() {
cfg := rq.h.config cfg := rq.h.config
@@ -121,7 +121,7 @@ func (rq *request) countRefusal() {
// countedBytes returns the request's bytes, once it has ended, as the // countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body // byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. // bytes, the request's, or both, as SWW_BYTES_COUNT says.
func (rq *request) countedBytes() int64 { func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes response, request := rq.out.bytes, rq.requestBytes
@@ -138,7 +138,7 @@ func (rq *request) countedBytes() int64 {
// banForLimit bans the client's netblock at now for a broken limit, the // banForLimit bans the client's netblock at now for a broken limit, the
// one hit names, notes the offence for the log line and counts the hit in // one hit names, notes the offence for the log line and counts the hit in
// the metrics. status is what the client was sent, or is sent: // the metrics. status is what the client was sent, or is sent:
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for // SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the // one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a // 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 // rate limit or a byte limit; the error burst is not lowered. The ban sets
@@ -277,7 +277,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
// wouldAlertBan reports whether the alert for a ban on netblock for cause // wouldAlertBan reports whether the alert for a ban on netblock for cause
// made at now would be sent. In observe mode the ban the request would // made at now would be sent. In observe mode the ban the request would
// have made is worked out only then, at most once per // have made is worked out only then, at most once per
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the // SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
// netblock's requests, which can mean going through every client. // netblock's requests, which can mean going through every client.
func (rq *request) wouldAlertBan( func (rq *request) wouldAlertBan(
netblock netip.Prefix, now time.Time, cause string, netblock netip.Prefix, now time.Time, cause string,
@@ -343,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
} }
// netblock is the netblock a ban on client covers: its IPv4 address, // netblock is the netblock a ban on client covers: its IPv4 address,
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup // widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// counts it in. // counts it in.
func (h *handler) netblock(client netip.Addr) netip.Prefix { func (h *handler) netblock(client netip.Addr) netip.Prefix {
addr := client.Unmap() addr := client.Unmap()
+11 -11
View File
@@ -26,7 +26,7 @@ const (
userAgent = "ban-test/1.0" userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban. // permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent" permanent = "permanent"
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection. // banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
banResponseClose = "close" banResponseClose = "close"
) )
@@ -89,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
} }
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within // A first ban lasts SWW_LIMIT_BAN_DURATION; one within
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. // SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10*time.Minute + time.Hour) clk.advance(10*time.Minute + time.Hour)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// Later than that, SWWAF_LIMIT_BAN_DURATION again. // Later than that, SWW_LIMIT_BAN_DURATION again.
clk.advance(30*time.Minute + time.Hour + time.Second) clk.advance(30*time.Minute + time.Hour + time.Second)
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10 * time.Minute) clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is // 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
// permanent. // permanent.
clk.advance(30 * time.Minute) clk.advance(30 * time.Minute)
@@ -148,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
// In the IPv4 cases, client breaks the limit; these two are next to it. // In the IPv4 cases, client breaks the limit; these two are next to it.
const ( const (
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS allowed = "203.0.113.60" // in SWW_ALLOW_NETS
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -164,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
nil, []string{otherClient, exempt}, nil, []string{otherClient, exempt},
}, },
{ {
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets", "the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
map[string]string{banScopeV4Prefix: "24"}, client, map[string]string{banScopeV4Prefix: "24"}, client,
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed}, []string{otherClient, exempt}, []string{"203.0.112.9", allowed},
}, },
@@ -173,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"}, []string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
}, },
{ {
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets", "the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1", map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"}, []string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
}, },
@@ -231,10 +231,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWWAF_BAN_RESPONSE at its default setting string // "" leaves SWW_BAN_RESPONSE at its default
status int // 0 is the connection closed without an answer status int // 0 is the connection closed without an answer
}{ }{
{"", http.StatusForbidden}, {"", http.StatusForbidden},
+16 -16
View File
@@ -18,7 +18,7 @@ type percentage struct {
// biasedThresholdsSet reports whether a biased threshold can lower a // biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits: one of its lists is not empty, // client's limits: one of its lists is not empty,
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL // SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
// is set. The client's lookup is then needed before its request goes on. // is set. The client's lookup is then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool { func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 || return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB // up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// that list it. Each is the lowest of those the settings give it, the // that list it. Each is the lowest of those the settings give it, the
// first of them in the order below when several are lowest: the // first of them in the order below when several are lowest: the
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file // percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one // SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, unless its request skipped the wait for GeoJS, // country, unless its request skipped the wait for GeoJS,
// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the // SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client // percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the // a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// percentage of SWWAF_REPUTATION_ACTION while it is limit. For the byte // percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the // limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
// place of the first three for an AS number or a country they list. // place of the first three for an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
@@ -47,36 +47,36 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole} unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait { if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"} unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
} }
fetched := percentage{percent: whole} fetched := percentage{percent: whole}
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed { if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"} fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
} }
blocklisted := percentage{percent: whole} blocklisted := percentage{percent: whole}
if rq.blocklisted && cfg.BlocklistAction == "limit" { if rq.blocklisted && cfg.BlocklistAction == "limit" {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"} blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
} }
reputationListed := percentage{percent: whole} reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" { if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"} reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
} }
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"), asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
fetched) fetched)
countryRequests := given(cfg.CountryLimitPercent, country, countryRequests := given(cfg.CountryLimitPercent, country,
"SWWAF_COUNTRY_LIMIT_PERCENT") "SWW_COUNTRY_LIMIT_PERCENT")
asnBytes, countryBytes := asnRequests, countryRequests asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed { if _, listed := cfg.ASNBytesPercent[asn]; listed {
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT") asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
} }
if _, listed := cfg.CountryBytesPercent[country]; listed { if _, listed := cfg.CountryBytesPercent[country]; listed {
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT") countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
} }
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed), return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
+7 -7
View File
@@ -22,11 +22,11 @@ import (
// The biased thresholds. // The biased thresholds.
const ( const (
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
) )
const ( const (
@@ -230,7 +230,7 @@ func TestLowestPercentageApplies(t *testing.T) {
noCountry, "25 from " + unknownLimitPercent, noCountry, "25 from " + unknownLimitPercent,
}, },
{ {
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100. // SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country", "the AS number's, for a client without a country",
map[string]string{asnLimitPercent: "AS64500:25"}, map[string]string{asnLimitPercent: "AS64500:25"},
noCountry, "25 from " + asnLimitPercent, noCountry, "25 from " + asnLimitPercent,
@@ -487,7 +487,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
} }
// percentText gives a limit percentage and the setting that gave it as // percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are // text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
// left out. // left out.
func percentText(percent *int64, setting string) string { func percentText(percent *int64, setting string) string {
switch { switch {
+8 -8
View File
@@ -19,13 +19,13 @@ import (
// The byte limit settings. // The byte limit settings.
const ( const (
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWWAF_BYTES_COUNT" bytesCount = "SWW_BYTES_COUNT"
) )
// The values of SWWAF_BYTES_COUNT. // The values of SWW_BYTES_COUNT.
const ( const (
countResponse = "response" countResponse = "response"
countRequest = "request" countRequest = "request"
@@ -40,7 +40,7 @@ const (
// byteLimit is the byte limit these tests set, as a setting: a request // byteLimit is the byte limit these tests set, as a setting: a request
// with a body and its answer, 100 bytes, go over it. // with a body and its answer, 100 bytes, go over it.
byteLimit = "99" byteLimit = "99"
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE. // minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
minuteBytes = "minute_bytes" minuteBytes = "minute_bytes"
) )
@@ -212,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _ := startWithAnswers(t, map[string]string{ s, _ := startWithAnswers(t, map[string]string{
+1 -1
View File
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
// clientGroup is the client a request is counted toward: its IPv4 // clientGroup is the client a request is counted toward: its IPv4
// address, or its IPv6 group, the netblock its IPv6 address is in of the // address, or its IPv6 group, the netblock its IPv6 address is in of the
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser // length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4. // usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix { func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap() addr = addr.Unmap()
+4 -4
View File
@@ -10,10 +10,10 @@ import (
) )
// checkCoreRuleSet inspects the request with the Core Rule Set, unless // checkCoreRuleSet inspects the request with the Core Rule Set, unless
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as // SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the // 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 // log line, and the rules in the metrics. A score at or over
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
// and in block mode refuses the request, which is an offence its client's // and in block mode refuses the request, which is an offence its client's
// history counts, and so returns ActionWAFBlocked. It returns "" for a // history counts, and so returns ActionWAFBlocked. It returns "" for a
// request it does not refuse. // request it does not refuse.
@@ -55,7 +55,7 @@ func (rq *request) checkCoreRuleSet() string {
} }
// alertWAFBlock raises the waf_block alert for the request, which the Core // alertWAFBlock raises the waf_block alert for the request, which the Core
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its // Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// detail gives the rule ids, the score, the method and the path with the // 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, // query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode. // or observe in observe mode.
@@ -82,7 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
ASN: rq.line.ASN, ASN: rq.line.ASN,
ASName: rq.line.ASName, ASName: rq.line.ASName,
Country: rq.line.Country, Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail, Detail: detail,
}) })
} }
+34 -11
View File
@@ -10,17 +10,19 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and // The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
// its two modes that inspect requests. // its two modes that inspect requests.
const ( const (
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -28,7 +30,7 @@ const (
// formData is the type of a form's body. // formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded" const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a // sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
// setting. // setting.
const ( const (
sizeLimit = 1 << 10 sizeLimit = 1 << 10
@@ -37,7 +39,7 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only // sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default // the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWWAF_WAF_ANOMALY_THRESHOLD. // SWW_WAF_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1" const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantWAF checks the request log line's waf_rule_ids and waf_score, and // wantWAF checks the request log line's waf_rule_ids and waf_score, and
@@ -125,7 +127,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
) { ) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafMode: block,
@@ -134,7 +136,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
}) })
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// exempts, are not inspected. // exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantWAF(t, line, nil)
@@ -153,7 +155,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
requestlog.ActionRuleBlocked) requestlog.ActionRuleBlocked)
wantWAF(t, line, nil) wantWAF(t, line, nil)
// With SWWAF_WAF_MODE off, no request is. // With SWW_WAF_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantWAF(t, line, nil)
@@ -237,6 +239,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
@@ -468,7 +491,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
Host: appHost, Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"), Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail, Detail: detail,
}) })
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// countryDenied reports whether the country lists refuse the request, by // countryDenied reports whether the country lists refuse the request, by
// the client's country as it was looked up. A client without a country, // the client's country as it was looked up. A client without a country,
// or whose country cannot be found, is refused only by // or whose country cannot be found, is refused only by
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
func (rq *request) countryDenied() bool { func (rq *request) countryDenied() bool {
denied := rq.h.config.DeniedCountries denied := rq.h.config.DeniedCountries
allowed := rq.h.config.ExclusivelyAllowedCountries allowed := rq.h.config.ExclusivelyAllowedCountries
+3 -3
View File
@@ -245,11 +245,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
action string action string
}{ }{
{ {
"not in SWWAF_ALLOW_NETS", "", "not in SWW_ALLOW_NETS", "",
http.StatusForbidden, requestlog.ActionCountryDenied, http.StatusForbidden, requestlog.ActionCountryDenied,
}, },
{ {
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8", "in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
http.StatusOK, requestlog.ActionForward, http.StatusOK, requestlog.ActionForward,
}, },
} { } {
@@ -279,7 +279,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP, // startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and // each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs // what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an // the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a // 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 // hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown. // leave the client unknown.
+2 -2
View File
@@ -18,8 +18,8 @@ import (
// test puts in the copy of its decision list, at decisionsURL, that it // test puts in the copy of its decision list, at decisionsURL, that it
// needs, as reputation.json would at start. // needs, as reputation.json would at start.
const ( const (
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
lapi = "http://crowdsec.example:8080" lapi = "http://crowdsec.example:8080"
decisionsURL = lapi + "/v1/decisions" decisionsURL = lapi + "/v1/decisions"
bouncerKey = "crowdsec-key-0123456789abcdef" bouncerKey = "crowdsec-key-0123456789abcdef"
+7 -8
View File
@@ -39,7 +39,7 @@ const (
// headers and body. // headers and body.
DispositionDeny = "deny" DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection // DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close. // without an answer, which SWW_BAN_RESPONSE asks for when it is close.
// discriminator closing the question's connection instead would read as // discriminator closing the question's connection instead would read as
// discriminator being down, and rproxy would then let the request through. // discriminator being down, and rproxy would then let the request through.
DispositionClose = "close" DispositionClose = "close"
@@ -149,7 +149,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refusedWhateverTheAnswer reports whether the request is one that does not wait // refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about // for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: it is over the header limits, a blocklist lists its client while // its client: it is over the header limits, a blocklist lists its client while
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is // SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// over a whole rate limit. In observe mode, which refuses nothing, none is. // over a whole rate limit. In observe mode, which refuses nothing, none is.
// rproxy lets a request through when discriminator answers too late. // rproxy lets a request through when discriminator answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
@@ -306,10 +306,9 @@ func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the // asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for discriminator, // log line are to see it: without the headers rproxy adds for discriminator,
// with // with body, which has been read of r, and with the length the visitor
// body, which has been read of r, and with the length the visitor announced, // announced, as -1 when it announced none. The question's own length covers
// as -1 when it announced none. The question's own length covers only the part // only the part of the body it carries.
// of the body it carries.
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request { func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
sent := r.Clone(r.Context()) sent := r.Clone(r.Context())
announced := sent.Header.Get(ContentLengthHeader) announced := sent.Header.Get(ContentLengthHeader)
@@ -336,7 +335,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
} }
// allow answers a question with DispositionAllow and, while // allow answers a question with DispositionAllow and, while
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country. // SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
func (rq *request) allow() { func (rq *request) allow() {
rq.allowed = true rq.allowed = true
@@ -362,7 +361,7 @@ func (rq *request) deny(r refusal) {
rq.refused = &r rq.refused = &r
if r.status == 0 { if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's // SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
// status stays 0: the visitor is sent nothing. // status stays 0: the visitor is sent nothing.
rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK) rq.out.ResponseWriter.WriteHeader(http.StatusOK)
+3 -3
View File
@@ -39,7 +39,7 @@ const (
// reportBodyLimit is the longest body SPEC.md lets a report have. // reportBodyLimit is the longest body SPEC.md lets a report have.
reportBodyLimit = 1 << 20 reportBodyLimit = 1 << 20
// anomalyClientPerMinute is the anomaly threshold these tests set. // anomalyClientPerMinute is the anomaly threshold these tests set.
anomalyClientPerMinute = "SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE" anomalyClientPerMinute = "SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE"
) )
// The fields of a report, as "discriminator" in SPEC.md names them. // The fields of a report, as "discriminator" in SPEC.md names them.
@@ -181,7 +181,7 @@ func (s *Discriminator) history() ratelimit.History {
} }
// metrics returns what the metrics endpoint answers, for a server started with // metrics returns what the metrics endpoint answers, for a server started with
// the token as SWWAF_METRICS_TOKEN. // the token as SWW_METRICS_TOKEN.
func (s *Discriminator) metrics() string { func (s *Discriminator) metrics() string {
s.t.Helper() s.t.Helper()
@@ -1485,7 +1485,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) { func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
t.Parallel() t.Parallel()
s := startDiscriminator(t, "", map[string]string{"SWWAF_LOG_LEVEL": "error"}) s := startDiscriminator(t, "", map[string]string{"SWW_LOG_LEVEL": "error"})
// A refused question would write a process warning, held back at error. // A refused question would write a process warning, held back at error.
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
+5 -5
View File
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
// refused is a request the tests here send, which discriminator refuses after a rule // refused is a request the tests here send, which discriminator refuses after a rule
// file match. // file match.
@@ -48,7 +48,7 @@ func (r refused) send(s *sender, from string) logLine {
} }
// startForErrorBurst is startWithClock with testRules, the metrics token and // startForErrorBurst is startWithClock with testRules, the metrics token and
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. // SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst( func startForErrorBurst(
t *testing.T, threshold string, env map[string]string, t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *discriminator.Server) { ) (*sender, *clock, *discriminator.Server) {
@@ -258,15 +258,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS allowed = "192.0.2.60" // in SWW_ALLOW_NETS
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _, _ := startForErrorBurst(t, "1", map[string]string{ s, _, _ := startForErrorBurst(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt, allowNets: allowed, rateLimitExemptNets: exempt,
}) })
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted. // A client in SWW_ALLOW_NETS is not refused, and so not counted.
for range 3 { for range 3 {
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import (
) )
// The headers in which an answer of allow gives rproxy the client's AS number // The headers in which an answer of allow gives rproxy the client's AS number
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes // and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
// every header name in this form, as it sends it and as it receives it, so // every header name in this form, as it sends it and as it receives it, so
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive. // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
const ( const (
@@ -20,7 +20,7 @@ const (
// lookUp looks up the client's AS number and country, in the lookup // lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless // database or through GeoJS, and notes them for the log line, unless
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or // SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
// country list or a biased threshold, a new client's request waits for it, // country list or a biased threshold, a new client's request waits for it,
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// and country. // and country.
type asnAndCountry struct{ asn, asName, country string } type asnAndCountry struct{ asn, asName, country string }
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the // fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
// lookup database. // lookup database.
const fileSource = "file" const fileSource = "file"
+8 -8
View File
@@ -17,9 +17,9 @@ import (
) )
const ( const (
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N" metricsTopN = "SWW_METRICS_TOP_N"
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a // token is the SWW_METRICS_TOKEN the tests set, and bearer how a
// request carries it. // request carries it.
token = "0123456789abcdef0123456789abcdef" token = "0123456789abcdef0123456789abcdef"
bearer = "Bearer " + token bearer = "Bearer " + token
@@ -113,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
func TestMetricsCountLimitsAndBans(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{ s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token, metricsToken: token,
@@ -124,7 +124,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
maxBanDuration: "2h", maxBanDuration: "2h",
}) })
// SWWAF_BAN_RESPONSE=close sends no status at all. // SWW_BAN_RESPONSE=close sends no status at all.
s.get(denied, 0, requestlog.ActionDenied) s.get(denied, 0, requestlog.ActionDenied)
// A first broken limit bans for an hour. // A first broken limit bans for an hour.
@@ -145,7 +145,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0) wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent. // than SWW_MAX_BAN_DURATION, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionRateLimited)
@@ -328,12 +328,12 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
}) })
// GeoJS fails, so the client counts as coming from an unknown country, // GeoJS fails, so the client counts as coming from an unknown country,
// which SWWAF_DENIED_COUNTRIES does not refuse. // which SWW_DENIED_COUNTRIES does not refuse.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so // The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended. // GeoJS's failure can come after its request has ended.
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
+3 -3
View File
@@ -11,19 +11,19 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// observe is the value of SWWAF_MODE for observe mode. // observe is the value of SWW_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
denied = "192.0.2.50" // in SWWAF_DENY_NETS denied = "192.0.2.50" // in SWW_DENY_NETS
banned = otherClient // under a ban read from bans.json banned = otherClient // under a ban read from bans.json
) )
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWWAF_MODE at its default setting string // "" leaves SWW_MODE at its default
observe bool observe bool
}{ }{
{"", false}, {"", false},
+34 -23
View File
@@ -4,6 +4,7 @@
package discriminator package discriminator
import ( import (
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -32,15 +33,15 @@ const adminPrefix = "/_sww/"
const HealthPath = "/_sww/healthz" const HealthPath = "/_sww/healthz"
// MetricsPath is where the metrics are, for a request that carries // MetricsPath is where the metrics are, for a request that carries
// SWWAF_METRICS_TOKEN. // SWW_METRICS_TOKEN.
const MetricsPath = "/_sww/metrics" const MetricsPath = "/_sww/metrics"
// BansPath is where an admin lists and adds bans, and, followed by / and // BansPath is where an admin lists and adds bans, and, followed by / and
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN. // a client's address, lifts them, with SWW_ADMIN_TOKEN.
const BansPath = "/_sww/bans" const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what discriminator knows of a client, by // ClientsPath is where an admin asks what discriminator knows of a client, by
// the client's address after it, with SWWAF_ADMIN_TOKEN. // the client's address after it, with SWW_ADMIN_TOKEN.
const ClientsPath = "/_sww/clients/" const ClientsPath = "/_sww/clients/"
// Params are what New needs. // Params are what New needs.
@@ -53,14 +54,17 @@ type Params struct {
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
// GeoJSURL is where clients' AS numbers and countries are looked up // GeoJSURL is where clients' AS numbers and countries are looked up
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. // while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
GeoJSURL string GeoJSURL string
// AbuseIPDBURL is where clients are checked with AbuseIPDB while // AbuseIPDBURL is where clients are checked with AbuseIPDB while
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. // SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
AbuseIPDBURL string AbuseIPDBURL string
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWW_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWW_WAF_MODE is not off, and nil while it is.
CoreRuleSet *waf.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -72,14 +76,14 @@ type Params struct {
// permanent, for each count over an anomaly threshold, for each request // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over // AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up. // or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server discriminator runs, with the parts whose state the state // Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the // files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. // AbuseIPDB's scores and checks spent, and the metrics.
type Server struct { type Server struct {
@@ -145,7 +149,7 @@ func newHandler(params Params) *handler {
dnsbl: dnsbl, dnsbl: dnsbl,
abuseIPDB: abuseIPDB, abuseIPDB: abuseIPDB,
rules: params.Rules, rules: params.Rules,
coreRuleSet: newCoreRuleSet(params.Config), coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
@@ -171,7 +175,7 @@ func newHandler(params Params) *handler {
// newReputation returns the lists fetched from URLs, the DNSBL zones' // newReputation returns the lists fetched from URLs, the DNSBL zones'
// verdicts and AbuseIPDB's scores, as the settings in params name them, // verdicts and AbuseIPDB's scores, as the settings in params name them,
// with none fetched, asked for or checked yet, and adds their metrics to // with none fetched, asked for or checked yet, and adds their metrics to
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set. // m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
func newReputation( func newReputation(
params Params, m *metrics.Metrics, params Params, m *metrics.Metrics,
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
@@ -203,29 +207,36 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at // up to SWW_WAF_BODY_LIMIT, with the exclusions in
// most QuestionBodyMaxBytes less one byte: a question's body of // SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
// or nil while SWW_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. // JSON or XML body over its limit. An exclusions file that waf.New refuses is
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { // an error in every mode. While the mode is off, a set file is loaded with the
if cfg.WAFMode == config.WAFModeOff { // Core Rule Set only to check it.
return nil func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
// The Core Rule Set is built in, and the settings cannot break it: return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
// the paranoia level is from 1 to 4, and the id of no rule switches
// nothing off.
panic(err)
} }
return coreRuleSet if cfg.WAFMode == config.WAFModeOff {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
return coreRuleSet, nil
} }
// handler is discriminator's handler. It holds what every request shares; what // handler is discriminator's handler. It holds what every request shares; what
+40 -34
View File
@@ -55,33 +55,33 @@ const (
// The settings the tests set. // The settings the tests set.
const ( const (
mode = "SWWAF_MODE" mode = "SWW_MODE"
trustedProxies = "SWRP_TRUSTED_PROXIES" trustedProxies = "SWRP_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS" denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWWAF_LOOKUP_SOURCE" lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWWAF_DENIED_COUNTRIES" deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWWAF_BAN_RESPONSE" banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWWAF_MAX_BAN_DURATION" maxBanDuration = "SWW_MAX_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS" maxBans = "SWW_MAX_BANS"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
instanceName = "SWWAF_INSTANCE_NAME" instanceName = "SWW_INSTANCE_NAME"
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
rulesDir = "SWWAF_RULES_DIR" rulesDir = "SWW_RULES_DIR"
wafMode = "SWWAF_WAF_MODE" wafMode = "SWW_WAF_MODE"
) )
// off is the value that switches a setting off. // off is the value that switches a setting off.
@@ -294,8 +294,8 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and // startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns discriminator's server as well, whose // bans made by the time now tells, and returns discriminator's server as well, whose
// Addr is where discriminator listens. Unless env sets SWWAF_RULES_DIR, it is an empty // Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app, // directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
// the label instance of every metric. // the label instance of every metric.
func startProxyWithClock( func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
@@ -391,13 +391,13 @@ func startRProxy(
} }
// newDiscriminator makes discriminator's server, without starting it, and // newDiscriminator makes discriminator's server, without starting it, and
// returns it, what // returns it, what it writes, and the queue of the alerts it raises, as the
// it writes, and the queue of the alerts it raises, as the settings in env make // settings in env make it. No alert is sent from the queue: they wait in it,
// it. No alert is sent from the queue: they wait in it, for the test to look at. // for the test to look at.
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup // SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB // database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless // at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
// env sets it, so that only the tests of the Core Rule Set have their requests // env sets it, so that only the tests of the Core Rule Set have their requests
// inspected by it. // inspected by it.
func newDiscriminator( func newDiscriminator(
@@ -464,6 +464,11 @@ func newDiscriminatorWith(
} }
} }
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
if err != nil {
t.Fatalf("Core Rule Set: %v", err)
}
server := build(discriminator.Params{ server := build(discriminator.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
@@ -471,6 +476,7 @@ func newDiscriminatorWith(
GeoJSURL: geojsURL, GeoJSURL: geojsURL,
AbuseIPDBURL: abuseIPDBURL, AbuseIPDBURL: abuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: coreRuleSet,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's // minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
// limit_hit names it. // limit_hit names it.
const minute = "minute" const minute = "minute"
@@ -75,7 +75,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel() t.Parallel()
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two // With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
// /64s of one /48, are one client, and the second's request breaks the // /64s of one /48, are one client, and the second's request breaks the
// limit; the third, in the next /48, is another client. // limit; the third, in the next /48, is another client.
const ( const (
@@ -118,7 +118,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{ s, _, server := startWithClock(t, geojsURL, map[string]string{
@@ -151,7 +151,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path // A ban, SWW_DENY_NETS and the country lists still refuse a path
// under a prefix. // under a prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
+6 -6
View File
@@ -10,12 +10,12 @@ import (
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
) )
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that // deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
// refuses the requests of a client a source lists. // refuses the requests of a client a source lists.
const deny = "deny" const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as // blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny, // noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead // refuses the request. Being limit, it lowers the client's limits instead
// (see limitPercentages), and being log, it does nothing more. // (see limitPercentages), and being log, it does nothing more.
func (rq *request) blocklistDenied() bool { func (rq *request) blocklistDenied() bool {
@@ -43,7 +43,7 @@ func (rq *request) crowdSecBanned(now time.Time) bool {
} }
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as // dnsblDenied notes the DNSBL zones whose verdict lists the client, as
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being // noteListed does, and reports whether SWW_REPUTATION_ACTION, being
// deny, refuses the request. Being limit, it lowers the client's limits // deny, refuses the request. Being limit, it lowers the client's limits
// instead (see limitPercentages), and being log, it does nothing more. A // instead (see limitPercentages), and being log, it does nothing more. A
// zone without a verdict on the client is asked about it in the // zone without a verdict on the client is asked about it in the
@@ -59,8 +59,8 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when // abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// its score of the client is a hit, and reports whether // its score of the client is a hit, and reports whether
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied // SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A // does for a zone. While SWW_ABUSEIPDB_KEY is unset it does nothing. A
// client without a score is checked in the background, by the request's // 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 // address, if its history counts an offence, and the request does not
// wait for the answer. The score is then used for each address of the // wait for the answer. The score is then used for each address of the
@@ -81,7 +81,7 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
rq.abuseIPDBHit = true rq.abuseIPDBHit = true
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE") "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
return rq.h.config.ReputationAction == deny return rq.h.config.ReputationAction == deny
} }
+16 -16
View File
@@ -23,12 +23,12 @@ import (
// The reputation settings. // The reputation settings.
const ( const (
blocklistURLs = "SWWAF_BLOCKLIST_URLS" blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistAction = "SWWAF_BLOCKLIST_ACTION" blocklistAction = "SWW_BLOCKLIST_ACTION"
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
) )
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION: // The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
// limitHalf gives a listed client half of every limit, and limitQuarter a // limitHalf gives a listed client half of every limit, and limitQuarter a
// quarter. // quarter.
const ( const (
@@ -127,7 +127,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
// fromKP's country refuses it before the list is looked at, and fromDE, // fromKP's country refuses it before the list is looked at, and fromDE,
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor // in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
// alerted. // alerted.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)) wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -224,7 +224,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
}, },
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{ {
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits", "SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none, map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
}, },
} { } {
@@ -312,9 +312,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The DNSBL settings. // The DNSBL settings.
const ( const (
dnsblZones = "SWWAF_DNSBL_ZONES" dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" dnsblResolver = "SWW_DNSBL_RESOLVER"
reputationAction = "SWWAF_REPUTATION_ACTION" reputationAction = "SWW_REPUTATION_ACTION"
) )
// The DNSBL zones these tests name, which are never asked about the // The DNSBL zones these tests name, which are never asked about the
@@ -370,7 +370,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for _, from := range []string{fromDE, fromKP} { for _, from := range []string{fromDE, fromKP} {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWWAF_DNSBL_ZONES names them. // In the order SWW_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...) wantReputation(t, line, listedBy[from]...)
wantPercent(t, "limit_percent", line.LimitPercent, wantPercent(t, "limit_percent", line.LimitPercent,
line.LimitPercentSetting, tc.percent) line.LimitPercentSetting, tc.percent)
@@ -416,7 +416,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
// The blocklist refuses fromKP before its verdict is looked at, and // The blocklist refuses fromKP before its verdict is looked at, and
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted // fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
// for the zone, nor alerted, nor asked about. // for the zone, nor alerted, nor asked about.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied), wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
dropURL) dropURL)
@@ -609,7 +609,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
// The AbuseIPDB settings, and accountKey, the key the tests set. // The AbuseIPDB settings, and accountKey, the key the tests set.
const ( const (
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
accountKey = "abuseipdb-key-0123456789abcdef" accountKey = "abuseipdb-key-0123456789abcdef"
) )
@@ -800,7 +800,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey, rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action, reputationAction: tc.action,
}) })
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. // At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74}) loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
for i := range 3 { for i := range 3 {
@@ -851,7 +851,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
// The alert is made as a DNSBL zone's is, with the score besides. // The alert is made as a DNSBL zone's is, with the score besides.
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" || waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) || waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
queue.Suppressed() != 1 { queue.Suppressed() != 1 {
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+ t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
@@ -922,7 +922,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
abuseIPDBKey: accountKey, reputationAction: actionLog, abuseIPDBKey: accountKey, reputationAction: actionLog,
}) })
// Every source lists client, and none otherClient, whose score is // Every source lists client, and none otherClient, whose score is
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default. // under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
loadLists(t, server, map[string][]string{dropURL: {client}}) loadLists(t, server, map[string][]string{dropURL: {client}})
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil}) loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
loadScores(server, map[string]int64{client: score, otherClient: 74}) loadScores(server, map[string]int64{client: score, otherClient: 74})
@@ -971,7 +971,7 @@ func verdictsFetched() time.Time {
} }
// loadVerdicts puts into server's DNSBL, for each client listedBy names, // loadVerdicts puts into server's DNSBL, for each client listedBy names,
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at // a verdict of each zone SWW_DNSBL_ZONES names, fetched at
// verdictsFetched, as reputation.json would at start: one that lists the // verdictsFetched, as reputation.json would at start: one that lists the
// client from each zone listedBy gives for it, and one that does not from // client from each zone listedBy gives for it, and one that does not from
// each other zone. // each other zone.
+13 -13
View File
@@ -44,7 +44,7 @@ type decision struct {
lookedUp bool lookedUp bool
lookupAnswer lookup.Answer lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and // skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it, // found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits. // so that not waiting never lowers its client's limits.
skippedLookupWait bool skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes // counted is true for a request the rate limits counted, whose bytes
@@ -161,7 +161,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
// check is the one place where a request can be refused. It returns nil to let // check is the one place where a request can be refused. It returns nil to let
// the request through. The checks of checkClient decide: a request they refuse // the request through. The checks of checkClient decide: a request they refuse
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, // is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
// or 431 for the header limits. In observe mode nothing is refused, and the log // or 431 for the header limits. In observe mode nothing is refused, and the log
// line names what enforce mode would have done. ctx is the request's own context. // line names what enforce mode would have done. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
@@ -185,17 +185,17 @@ func (rq *request) check(ctx context.Context) *refusal {
// checkClient runs the checks on the request's client, and returns the // checkClient runs the checks on the request's client, and returns the
// action of the first that refuses the request, or "" when none does. A // action of the first that refuses the request, or "" when none does. A
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any // client in SWW_ALLOW_NETS skips them, and is not looked up. For any
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock, // other client, SWW_DENY_NETS comes first, then a ban on its netblock,
// so that a client either refuses is not looked up, then the lookup of // so that a client either refuses is not looked up, then the lookup of
// its AS number and country, then the country lists, then the blocklists, // its AS number and country, then the country lists, then the blocklists,
// then the CrowdSec decision list, which bans the client it lists, then // then the CrowdSec decision list, which bans the client it lists, then
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of // the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
// them refuses is not counted for the rate limits. Then come the // them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the // rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that // request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit // every other request is counted, each of them by the client's limit
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header // percentages, then SWW_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits // limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context. // is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string { func (rq *request) checkClient(ctx context.Context) string {
@@ -266,8 +266,8 @@ func (rq *request) checkClient(ctx context.Context) string {
} }
// countedByRateLimits reports whether the rate limits count the request: its // countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt // client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS. // under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool { func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config cfg := rq.h.config
@@ -276,7 +276,7 @@ func (rq *request) countedByRateLimits() bool {
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its // SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
// path as sent, the path the app receives, not percent-decoded, starts // path as sent, the path the app receives, not percent-decoded, starts
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an // with one of prefixes, so that /%61ssets/x is not under /assets/ for an
// app whose router matches the path as received. A request whose decoded // app whose router matches the path as received. A request whose decoded
@@ -378,9 +378,9 @@ func (rq *request) addToHistory() {
// countAnomalies counts the request, which has ended, and its bytes, as // countAnomalies counts the request, which has ended, and its bytes, as
// countedBytes gives them, for the anomaly thresholds, whatever was done // countedBytes gives them, for the anomaly thresholds, whatever was done
// with it: a request refused, one from a client in SWWAF_ALLOW_NETS or // with it: a request refused, one from a client in SWW_ALLOW_NETS or
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in // SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its // SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
// client's AS number when answerAtTheEnd gives one. With every anomaly // client's AS number when answerAtTheEnd gives one. With every anomaly
// threshold off, the default, it does nothing. // threshold off, the default, it does nothing.
func (rq *request) countAnomalies() { func (rq *request) countAnomalies() {
+2 -2
View File
@@ -18,7 +18,7 @@ import (
const ( const (
// requestIDHeader carries the request's id. // requestIDHeader carries the request's id.
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
// instance is the SWWAF_INSTANCE_NAME a test sets. // instance is the SWW_INSTANCE_NAME a test sets.
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
// limits count it as. // limits count it as.
@@ -195,7 +195,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
func TestTimingsAreInOrder(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
// The pause sets the times apart; a hold-up of the test only lengthens // The pause sets the times apart; a hold-up of the test only lengthens
+6 -6
View File
@@ -34,14 +34,14 @@ func TestEachRuleAction(t *testing.T) {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line, "noted") wantRuleIDs(t, line, "noted")
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and // A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
// bans no one. // bans no one.
line = s.request(client, "/blocked", http.StatusForbidden, line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionRuleBlocked)
wantRuleIDs(t, line, "noted", "blocked") wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for // A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
// seven days, the default. // seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
wantRuleIDs(t, line, "noted", "probe") wantRuleIDs(t, line, "noted", "probe")
@@ -101,7 +101,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
attackBanDuration: "1h", attackBanDuration: "1h",
}) })
// The first probe bans for SWWAF_ATTACK_BAN_DURATION. // The first probe bans for SWW_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
@@ -122,8 +122,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS allowed = "192.0.2.60" // in SWW_ALLOW_NETS
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _, server := startWithClock(t, "", map[string]string{ s, _, server := startWithClock(t, "", map[string]string{
@@ -133,7 +133,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// A client in SWWAF_ALLOW_NETS is not checked. // A client in SWW_ALLOW_NETS is not checked.
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line) wantRuleIDs(t, line)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
) )
// trapPath reports whether the request asks for a path in // trapPath reports whether the request asks for a path in
// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query, // SWW_TRAP_PATHS: its path as a path rule sees it, without the query,
// as sent or once percent-decoded, is one of them. Such a request is a // as sent or once percent-decoded, is one of them. Such a request is a
// clear sign of attack, as a ban rule's match is: it bans the client's // clear sign of attack, as a ban rule's match is: it bans the client's
// netblock, or in observe mode raises the alert for the ban it would have // netblock, or in observe mode raises the alert for the ban it would have
+2 -2
View File
@@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
calls.Add(1) calls.Add(1)
}) })
geojsURL, asked := startGeoJS(t) geojsURL, asked := startGeoJS(t)
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which // fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
// comes after it. // comes after it.
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
@@ -36,7 +36,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one // Neither SWW_DENY_NETS, the country lists nor the limit of one
// request a minute refuses the client, and its country is not looked // request a minute refuses the client, and its country is not looked
// up. // up.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
+4 -4
View File
@@ -14,14 +14,14 @@ import (
// trapPaths is the setting's name, and trapPathList what the tests set it // trapPaths is the setting's name, and trapPathList what the tests set it
// to. // to.
const ( const (
trapPaths = "SWWAF_TRAP_PATHS" trapPaths = "SWW_TRAP_PATHS"
trapPathList = "/wp-login.php,/xmlrpc.php" trapPathList = "/wp-login.php,/xmlrpc.php"
) )
func TestTrapPathBansAsABanRuleDoes(t *testing.T) { func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
// A block rule for the same path: the trap path comes first. // A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{ s, clk, server := startWithClock(t, "", map[string]string{
@@ -38,7 +38,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward) s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
} }
// A client in SWWAF_ALLOW_NETS is not checked. // A client in SWW_ALLOW_NETS is not checked.
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
// The query is not part of the path. // The query is not part of the path.
@@ -104,7 +104,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, trapPaths: trapPathList,
"SWWAF_RULES_ENABLED": "false", "SWW_RULES_ENABLED": "false",
}) })
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
+3 -3
View File
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
// FileParams are what OpenFile needs. // FileParams are what OpenFile needs.
type FileParams struct { type FileParams struct {
// Path is the lookup database, the IPinfo Lite file in its .mmdb form // Path is the lookup database, the IPinfo Lite file in its .mmdb form
// (SWWAF_LOOKUP_DB_PATH). // (SWW_LOOKUP_DB_PATH).
Path string Path string
// Now tells the time, normally time.Now. // Now tells the time, normally time.Now.
Now func() time.Time Now func() time.Time
@@ -221,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) { func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err) return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
} }
reader, err := maxminddb.OpenBytes(data) reader, err := maxminddb.OpenBytes(data)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
} }
return reader, nil return reader, nil
+3 -3
View File
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
writeFile(t, notDatabase, "not a lookup database\n") writeFile(t, notDatabase, "not a lookup database\n")
for path, want := range map[string]string{ for path, want := range map[string]string{
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing + missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
": no such file or directory", ": no such file or directory",
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase + notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
" is not a .mmdb file: error opening database: invalid MaxMind DB file", " is not a .mmdb file: error opening database: invalid MaxMind DB file",
} { } {
_, err := OpenFile(FileParams{ _, err := OpenFile(FileParams{
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
Reason: "the lookup database cannot be read, and the one read before stays in use", Reason: "the lookup database cannot be read, and the one read before stays in use",
Detail: map[string]any{ Detail: map[string]any{
"file": path, "file": path,
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error opening database: invalid MaxMind DB file", "error opening database: invalid MaxMind DB file",
}, },
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
// Package lookup looks up each client's AS number and country, through // Package lookup looks up each client's AS number and country, through
// the GeoJS web service or in the lookup database, the IPinfo Lite file // the GeoJS web service or in the lookup database, the IPinfo Lite file
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at // SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
// most 100,000 clients and for 7 days each, and are written to // most 100,000 clients and for 7 days each, and are written to
// lookups.json and read from it by the state package. // lookups.json and read from it by the state package.
package lookup package lookup
@@ -66,7 +66,7 @@ type Params struct {
URL string URL string
// Timeout is how long a request waits for its client's first answer, // Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned // and how long a request to GeoJS may take before it is abandoned
// (SWWAF_LOOKUP_TIMEOUT). // (SWW_LOOKUP_TIMEOUT).
Timeout time.Duration Timeout time.Duration
// Wait is true when a setting needs each request's answer before the // Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one. // request goes on. Otherwise no request waits for one.
+10 -10
View File
@@ -62,8 +62,8 @@ type Metrics struct {
// New returns the metrics, with the Go runtime's and the process's own. // New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries and how many AS numbers get series of their // topN is how many countries and how many AS numbers get series of their
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName // own (SWW_METRICS_TOP_N). Every metric carries instanceName
// (SWWAF_INSTANCE_NAME) as its label instance. // (SWW_INSTANCE_NAME) as its label instance.
// //
//nolint:funlen // a few lines for each metric, a list that grows with them //nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics { func New(topN int, instanceName string) *Metrics {
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
offences: counterVec("sww_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
wafMatches: counterVec("sww_waf_matches_total", wafMatches: counterVec("sww_waf_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+ "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}), "and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN), countries: newCountries(topN),
asns: newASNs(topN), asns: newASNs(topN),
@@ -214,13 +214,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
} }
// AddRemoteLog adds the metrics of sending the log lines to // AddRemoteLog adds the metrics of sending the log lines to
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the // SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
// lines sent, those dropped, and those waiting in the buffer. // lines sent, those dropped, and those waiting in the buffer.
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_remote_log_lines_sent_total", Name: "sww_remote_log_lines_sent_total",
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.", Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
}, func() float64 { }, func() float64 {
return float64(remote.Sent()) return float64(remote.Sent())
}), }),
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb, // AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
// read from abuseIPDB as the metrics are asked for: the checks made, those // read from abuseIPDB as the metrics are asked for: the checks made, those
// that failed, and how many checks the day's budget has left. It is // that failed, and how many checks the day's budget has left. It is
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set. // called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource} source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
@@ -322,7 +322,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_reputation_daily_budget_remaining", Name: "sww_reputation_daily_budget_remaining",
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.", Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
return float64(abuseIPDB.BudgetLeft()) return float64(abuseIPDB.BudgetLeft())
@@ -363,8 +363,8 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alerts_suppressed_total", Name: "sww_alerts_suppressed_total",
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " + Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.", "alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Suppressed()) return float64(queue.Suppressed())
@@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) {
} }
// WAFMatched counts a request that matched the Core Rule Set's rule id, // WAFMatched counts a request that matched the Core Rule Set's rule id,
// with SWWAF_WAF_MODE at mode. // with SWW_WAF_MODE at mode.
func (m *Metrics) WAFMatched(mode string, id int) { func (m *Metrics) WAFMatched(mode string, id int) {
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
} }
+2 -2
View File
@@ -2,7 +2,7 @@
// and bytes counted over a minute, an hour and a day, as the "Counting // and bytes counted over a minute, an hour and a day, as the "Counting
// method" section of SPEC.md describes, which tell when a request takes // method" section of SPEC.md describes, which tell when a request takes
// the client over a rate limit or a byte limit, and each client's history // the client over a rate limit or a byte limit, and each client's history
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are // since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
// kept, in memory, and written to clients.json and read from it by the // kept, in memory, and written to clients.json and read from it by the
// state package. // state package.
package ratelimit package ratelimit
@@ -156,7 +156,7 @@ type Request struct {
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past // table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
// it, the least recently seen client is dropped, with its history, and // it, the least recently seen client is dropped, with its history, and
// starts afresh if it comes back. // starts afresh if it comes back.
func New(limits Limits, maxClients int) *Limiter { func New(limits Limits, maxClients int) *Limiter {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const limit = 3 const limit = 3
// tableSize is the most clients the tests' tables hold, the default of // tableSize is the most clients the tests' tables hold, the default of
// SWWAF_MAX_TRACKED_CLIENTS. // SWW_MAX_TRACKED_CLIENTS.
const tableSize = 20000 const tableSize = 20000
// whole is the percentage of each limit a client gets when nothing lowers // whole is the percentage of each limit a client gets when nothing lowers
+8 -8
View File
@@ -1,5 +1,5 @@
// Package remotelog sends the lines sww writes on stdout to the // Package remotelog sends the lines sww writes on stdout to the
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section // remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
// of SPEC.md describes: each line as the message of an RFC 5424 syslog // of SPEC.md describes: each line as the message of an RFC 5424 syslog
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow // record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
// or unreachable endpoint never holds up a request or stdout. // or unreachable endpoint never holds up a request or stdout.
@@ -24,7 +24,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme. // The forms of SWW_LOG_REMOTE_URL, by its scheme.
const ( const (
SchemeUDP = "syslog+udp" SchemeUDP = "syslog+udp"
SchemeTCP = "syslog+tcp" SchemeTCP = "syslog+tcp"
@@ -56,18 +56,18 @@ const (
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or // URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
// SchemeTLS, a host and a port. // SchemeTLS, a host and a port.
URL *url.URL URL *url.URL
// RootCAs are the certificates a SchemeTLS endpoint's certificate // RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's. // must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent // Buffer is the most lines held while they wait to be sent
// (SWWAF_LOG_REMOTE_BUFFER). // (SWW_LOG_REMOTE_BUFFER).
Buffer int Buffer int
// Facility is the number of the records' syslog facility // Facility is the number of the records' syslog facility
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME // (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWWAF_LOG_REMOTE_APP_NAME). // (SWW_LOG_REMOTE_APP_NAME).
Facility int Facility int
AppName string AppName string
} }
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
} }
} }
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed", processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
"error", err.Error(), "connecting_again_in", delay.String()) "error", err.Error(), "connecting_again_in", delay.String())
select { select {
+1 -1
View File
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
var fields map[string]any var fields map[string]any
err := json.Unmarshal([]byte(line), &fields) err := json.Unmarshal([]byte(line), &fields)
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" { if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
t.Fatalf("logged %q", line) t.Fatalf("logged %q", line)
} }
+5 -5
View File
@@ -58,17 +58,17 @@ type Checks struct {
// AbuseIPDBParams are what NewAbuseIPDB needs. // AbuseIPDBParams are what NewAbuseIPDB needs.
type AbuseIPDBParams struct { type AbuseIPDBParams struct {
// URL is where clients are checked, normally AbuseIPDBURL, with Key, // URL is where clients are checked, normally AbuseIPDBURL, with Key,
// the account's key (SWWAF_ABUSEIPDB_KEY). // the account's key (SWW_ABUSEIPDB_KEY).
URL string URL string
Key string Key string
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), // MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
// and DailyBudget the most checks made in a day, in UTC // and DailyBudget the most checks made in a day, in UTC
// (SWWAF_ABUSEIPDB_DAILY_BUDGET). // (SWW_ABUSEIPDB_DAILY_BUDGET).
MinScore int64 MinScore int64
DailyBudget int DailyBudget int
// CacheTTL is how long a score is used after it was fetched // CacheTTL is how long a score is used after it was fetched
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWWAF_REPUTATION_TIMEOUT). // (SWW_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
+7 -7
View File
@@ -53,15 +53,15 @@ type Verdict struct {
// DNSBLParams are what NewDNSBL needs. // DNSBLParams are what NewDNSBL needs.
type DNSBLParams struct { type DNSBLParams struct {
// Zones are the DNSBL zones clients are asked about in // Zones are the DNSBL zones clients are asked about in
// (SWWAF_DNSBL_ZONES). // (SWW_DNSBL_ZONES).
Zones []string Zones []string
// Resolver is the resolver they are asked through // Resolver is the resolver they are asked through
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the // (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
// host's, as /etc/resolv.conf names it. // host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched // CacheTTL is how long a verdict is used after it was fetched
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWWAF_REPUTATION_TIMEOUT). // (SWW_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
} }
} }
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them. // Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
func (d *DNSBL) Zones() []string { func (d *DNSBL) Zones() []string {
return slices.Clone(d.params.Zones) return slices.Clone(d.params.Zones)
} }
// ListedBy returns the zones whose verdict on addr, a client's address, // ListedBy returns the zones whose verdict on addr, a client's address,
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key // lists it, in the order SWW_DNSBL_ZONES names them, each with its key
// masked, as config.MaskZoneKey masks it, since they go to the request // masked, as config.MaskZoneKey masks it, since they go to the request
// log, the alerts and the metrics. A verdict is used until CacheTTL has // log, the alerts and the metrics. A verdict is used until CacheTTL has
// passed since it was fetched. Each zone without one is asked about addr // passed since it was fetched. Each zone without one is asked about addr
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
} }
// Load keeps verdicts, read from reputation.json, in place of those it // Load keeps verdicts, read from reputation.json, in place of those it
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and, // keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or // past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
// more is neither used nor written, as for any verdict. // more is neither used nor written, as for any verdict.
func (d *DNSBL) Load(verdicts []Verdict) { func (d *DNSBL) Load(verdicts []Verdict) {
+4 -4
View File
@@ -40,8 +40,8 @@ const (
// zone and otherZone are the DNSBL zones the tests name. // zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example" zone = "dnsbl.example"
otherZone = "other.example" otherZone = "other.example"
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their // cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf // SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the // 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. // DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
dnsbl := reputation.NewDNSBL(p) dnsbl := reputation.NewDNSBL(p)
// The last verdict still in use, one fetched a TTL ago, and one of a // The last verdict still in use, one fetched a TTL ago, and one of a
// zone SWWAF_DNSBL_ZONES does not name. // zone SWW_DNSBL_ZONES does not name.
inUse := reputation.Verdict{ inUse := reputation.Verdict{
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
Fetched: now.Add(-cacheTTL + time.Nanosecond), Fetched: now.Add(-cacheTTL + time.Nanosecond),
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
} }
//nolint:paralleltest // one at a time, as the comment at the top of this file says //nolint:paralleltest // one at a time, as the comment at the top of this file says
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) { func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
resolver := &resolverStandIn{answers: map[string]answer{ resolver := &resolverStandIn{answers: map[string]answer{
listedName: {addrs: []string{listing}}, listedName: {addrs: []string{listing}},
}} }}
+12 -12
View File
@@ -1,10 +1,10 @@
// Package reputation fetches the lists the settings name by URL: the // Package reputation fetches the lists the settings name by URL: the
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines // blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec // SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of // engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails, // each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of // and when each was last tried. It also asks the DNSBL zones of
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks // SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// clients with AbuseIPDB, and keeps their scores and the checks spent // clients with AbuseIPDB, and keeps their scores and the checks spent
// today. The state package writes all of these to reputation.json and // today. The state package writes all of these to reputation.json and
// reads them from it, so that a restart keeps them too. // reads them from it, so that a restart keeps them too.
@@ -66,18 +66,18 @@ type List struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and // BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
// ASNLimitPercentURL the file of AS:percent lines // ASNLimitPercentURL the file of AS:percent lines
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset. // (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string BlocklistURLs []string
ASNLimitPercentURL string ASNLimitPercentURL string
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while // CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey // SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWWAF_CROWDSEC_LAPI_KEY). // (SWW_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string CrowdSecDecisionsURL string
CrowdSecKey string CrowdSecKey string
// Refresh is how long after a list was last fetched or tried it is // Refresh is how long after a list was last fetched or tried it is
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision // fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
// list, which is fetched again crowdSecRefresh after. // list, which is fetched again crowdSecRefresh after.
Refresh time.Duration Refresh time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -152,7 +152,7 @@ func New(params Params) *Lists {
} }
// URLs returns the URL of every list: the blocklists' in the order // URLs returns the URL of every list: the blocklists' in the order
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then // SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
// the CrowdSec decision list's. // the CrowdSec decision list's.
func (l *Lists) URLs() []string { func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs) urls := slices.Clone(l.params.BlocklistURLs)
@@ -168,7 +168,7 @@ func (l *Lists) URLs() []string {
} }
// ListedBy returns the URLs of the blocklists whose copy lists addr, in // ListedBy returns the URLs of the blocklists whose copy lists addr, in
// the order SWWAF_BLOCKLIST_URLS names them. // the order SWW_BLOCKLIST_URLS names them.
func (l *Lists) ListedBy(addr netip.Addr) []string { func (l *Lists) ListedBy(addr netip.Addr) []string {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -551,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
} }
// parsePercents reads the lines of the file of AS:percent lines, each an // parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An // AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
// AS number listed more than once gets the lowest of its percentages. // AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) { func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}} found := entries{percents: map[string]int64{}}
+3 -3
View File
@@ -34,8 +34,8 @@ const (
dropURL = "https://lists.example/drop.txt" dropURL = "https://lists.example/drop.txt"
torURL = "https://lists.example/tor.txt" torURL = "https://lists.example/tor.txt"
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their // refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
// SWWAF_ALERT_COOLDOWN, longer than it. // SWW_ALERT_COOLDOWN, longer than it.
refresh = 24 * time.Hour refresh = 24 * time.Hour
cooldown = 48 * time.Hour cooldown = 48 * time.Hour
// drop is a blocklist as the Spamhaus DROP list is written, with an // drop is a blocklist as the Spamhaus DROP list is written, with an
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
}} }}
lists := start(t, servers, params(torURL, dropURL)) lists := start(t, servers, params(torURL, dropURL))
// In the order SWWAF_BLOCKLIST_URLS names them. // In the order SWW_BLOCKLIST_URLS names them.
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL) wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
wantListedBy(t, lists, "203.0.113.8", dropURL) wantListedBy(t, lists, "203.0.113.8", dropURL)
}) })
+6 -6
View File
@@ -33,12 +33,12 @@ const (
// rule. // rule.
ActionRuleBlocked = "rule_blocked" ActionRuleBlocked = "rule_blocked"
// ActionWAFBlocked is a request refused because the Core Rule Set // ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. // scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked" ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWWAF_REPUTATION_ACTION is deny. // SWW_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
@@ -112,7 +112,7 @@ type Line struct {
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
// ContentLength is the length of its body the request announced. // ContentLength is the length of its body the request announced.
ContentLength int64 `json:"content_length,omitempty"` ContentLength int64 `json:"content_length,omitempty"`
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that // RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
// the request carried, by name in lower case. // the request carried, by name in lower case.
RequestHeaders map[string]string `json:"request_headers,omitempty"` RequestHeaders map[string]string `json:"request_headers,omitempty"`
HasAuthorization bool `json:"has_authorization,omitempty"` HasAuthorization bool `json:"has_authorization,omitempty"`
@@ -250,8 +250,8 @@ func Milliseconds(d time.Duration) float64 {
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
// JSON lines on w, marked "type":"process", with the time in the same form // JSON lines on w, marked "type":"process", with the time in the same form
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; // It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
+3 -4
View File
@@ -146,10 +146,9 @@ func newTransport() *http.Transport {
} }
// newDiscriminatorTransport returns what carries questions and reports to // newDiscriminatorTransport returns what carries questions and reports to
// discriminator. // discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade // a visitor's Upgrade header, or a Connection header other than close or
// header, or a Connection header other than close or keep-alive, and that would // keep-alive, and that would read as discriminator giving no answer.
// read as discriminator giving no answer.
func newDiscriminatorTransport() *http.Transport { func newDiscriminatorTransport() *http.Transport {
transport := newTransport() transport := newTransport()
transport.Protocols = new(http.Protocols) transport.Protocols = new(http.Protocols)
+5 -6
View File
@@ -62,7 +62,7 @@ const (
countryHeader = "X-Client-Country" countryHeader = "X-Client-Country"
contentLengthHeader = "Content-Length" contentLengthHeader = "Content-Length"
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny. // fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
fromDiscriminatorHeader = "X-From-Servewaf" fromDiscriminatorHeader = "X-From-Discriminator"
// addedHeaderValue is the value of a header a test adds to an answer, to see it // addedHeaderValue is the value of a header a test adds to an answer, to see it
// reach the visitor. // reach the visitor.
addedHeaderValue = "yes" addedHeaderValue = "yes"
@@ -268,8 +268,8 @@ func (s *Discriminator) question(t *testing.T) question {
} }
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that // startDiscriminatorAnsweringReports starts a stand-in for discriminator that
// answers each // answers each question as answer does and each report as report does, and
// question as answer does and each report as report does, and returns its URL. // returns its URL.
func startDiscriminatorAnsweringReports( func startDiscriminatorAnsweringReports(
t *testing.T, answer, report http.HandlerFunc, t *testing.T, answer, report http.HandlerFunc,
) string { ) string {
@@ -1033,9 +1033,8 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
} }
// startDiscriminatorWriting starts a stand-in for discriminator that reads // startDiscriminatorWriting starts a stand-in for discriminator that reads
// each question and // each question and answers it with text, as it is, and returns its URL. It
// answers it with text, as it is, and returns its URL. It can send what Go's // can send what Go's server would not.
// server would not.
func startDiscriminatorWriting(t *testing.T, text string) string { func startDiscriminatorWriting(t *testing.T, text string) string {
t.Helper() t.Helper()
+6 -6
View File
@@ -1,5 +1,5 @@
// Package rules reads the rule files: the plain text files in // Package rules reads the rule files: the plain text files in
// SWWAF_RULES_DIR, one rule to a line, that each request is checked // SWW_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read // against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again once the directory has had no change for a short // at start, and again once the directory has had no change for a short
// time after one is edited, added or removed. // time after one is edited, added or removed.
@@ -40,7 +40,7 @@ const (
// extension ends the name of every rule file. // extension ends the name of every rule file.
const extension = ".rules" const extension = ".rules"
// quietTime is how long SWWAF_RULES_DIR must go without a change before // quietTime is how long SWW_RULES_DIR must go without a change before
// the rule files are read again, so that a file still being written, such // the rule files are read again, so that a file still being written, such
// as one saved in place, appended to or copied in with scp, is read only // as one saved in place, appended to or copied in with scp, is read only
// once whole. // once whole.
@@ -100,9 +100,9 @@ type Rule struct {
// Params are what Load needs. // Params are what Load needs.
type Params struct { type Params struct {
// Dir is the directory of the rule files (SWWAF_RULES_DIR). // Dir is the directory of the rule files (SWW_RULES_DIR).
Dir string Dir string
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read // Enabled is SWW_RULES_ENABLED: while it is false, no file is read
// and no rule loaded. // and no rule loaded.
Enabled bool Enabled bool
// ProcessLog receives how many rules were read, and the error in a // ProcessLog receives how many rules were read, and the error in a
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
func read(dir string) ([]Rule, string, error) { func read(dir string) ([]Rule, string, error) {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err) return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
} }
var rules []Rule var rules []Rule
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
// readFile appends the rules of the rule file at path to rules. places // readFile appends the rules of the rule file at path to rules. places
// are where each id read so far is, and gain those of the file. // are where each id read so far is, and gain those of the file.
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) { func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
if err != nil { if err != nil {
return nil, err return nil, err
} }
+2 -2
View File
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d") dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
": no such file or directory") ": no such file or directory")
} }
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
func TestRuleFilesOffReadNothing(t *testing.T) { func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel() t.Parallel()
// SWWAF_RULES_DIR does not exist, which would stop the start. // SWW_RULES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d")) params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false params.Enabled = false
+8 -8
View File
@@ -1,5 +1,5 @@
// Package state keeps sww's state in JSON files in // Package state keeps sww's state in JSON files in
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires // SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires
// and docs/state-files.md lists them: // and docs/state-files.md lists them:
// bans.json holds the bans, clients.json each client's counters and // bans.json holds the bans, clients.json each client's counters and
// history, lookups.json GeoJS's answers, reputation.json the last try and // history, lookups.json GeoJS's answers, reputation.json the last try and
@@ -72,11 +72,11 @@ var (
// Params are what Load needs. // Params are what Load needs.
type Params struct { type Params struct {
// Dir is the directory of the state files (SWWAF_STATE_DIR). // Dir is the directory of the state files (SWW_STATE_DIR).
Dir string Dir string
// WriteDelay is how long after a ban is made bans.json is written // WriteDelay is how long after a ban is made bans.json is written
// (SWWAF_STATE_WRITE_DELAY), and CounterInterval how often every file // (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWWAF_STATE_COUNTER_INTERVAL). // is (SWW_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration WriteDelay time.Duration
CounterInterval time.Duration CounterInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
@@ -185,7 +185,7 @@ type stateFile interface {
func Load(params Params) (*Files, error) { func Load(params Params) (*Files, error) {
err := checkWritable(params.Dir) err := checkWritable(params.Dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err) return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err)
} }
f := &Files{params: params, sums: map[string][sha256.Size]byte{}} f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
@@ -362,7 +362,7 @@ func (f *Files) read(name string) (int, error) {
func (f *Files) readChanged(name string) ([]byte, bool, error) { func (f *Files) readChanged(name string) ([]byte, bool, error) {
path := filepath.Join(f.params.Dir, name) path := filepath.Join(f.params.Dir, name)
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil return nil, false, nil
} }
@@ -1035,7 +1035,7 @@ func write(dir, name string, data []byte) error {
// syncDirectory syncs dir to the disk, so that a rename in it lasts. // syncDirectory syncs dir to the disk, so that a rename in it lasts.
func syncDirectory(dir string) error { func syncDirectory(dir string) error {
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself
if err != nil { if err != nil {
return err return err
} }
@@ -1045,7 +1045,7 @@ func syncDirectory(dir string) error {
// writeSynced writes data to the file at path, and syncs it to the disk. // writeSynced writes data to the file at path, and syncs it to the disk.
func writeSynced(path string, data []byte) error { func writeSynced(path string, data []byte) error {
//nolint:gosec // a state file's temporary file, in SWWAF_STATE_DIR //nolint:gosec // a state file's temporary file, in SWW_STATE_DIR
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil { if err != nil {
return err return err
+1 -1
View File
@@ -931,7 +931,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "missing"),
notADirectory, notADirectory,
} { } {
const want = "SWWAF_STATE_DIR cannot be written: " const want = "SWW_STATE_DIR cannot be written: "
_, err := state.Load(newParams(dir)) _, err := state.Load(newParams(dir))
if err == nil || !strings.HasPrefix(err.Error(), want) { if err == nil || !strings.HasPrefix(err.Error(), want) {
+1 -1
View File
@@ -21,7 +21,7 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In discriminator's container, where // HealthCheck is the container's health check. In discriminator's container, where
// SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health // SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container, // endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy // where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and // answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise, // the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
+4 -4
View File
@@ -54,9 +54,9 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
// or invalid token file, or an invalid value of another setting, does not // or invalid token file, or an invalid value of another setting, does not
// fail it. // fail it.
for _, other := range []struct{ name, value string }{ for _, other := range []struct{ name, value string }{
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, {"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, {"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWWAF_MODE", "neither"}, {"SWW_MODE", "neither"},
} { } {
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand, commandSetting: discriminatorCommand,
@@ -72,7 +72,7 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
}, 1, "unhealthy: ask discriminator: ") }, 1, "unhealthy: ask discriminator: ")
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: discriminatorCommand, listenAddr: "8080", commandSetting: discriminatorCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ") }, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
stop() stop()
+17 -8
View File
@@ -29,6 +29,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// shutdownTimeout is how long requests in progress may take to finish // shutdownTimeout is how long requests in progress may take to finish
@@ -37,7 +38,7 @@ import (
const shutdownTimeout = 5 * time.Second const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as discriminator stops, the log lines still // remoteLogStopTimeout is how long, as discriminator stops, the log lines still
// waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has // waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
// carried them. // carried them.
const remoteLogStopTimeout = 2 * time.Second const remoteLogStopTimeout = 2 * time.Second
@@ -104,7 +105,7 @@ func printUsage(w io.Writer) int {
// done. It returns the process's exit status, 1 when discriminator cannot start. // done. It returns the process's exit status, 1 when discriminator cannot start.
func RunDiscriminator(ctx context.Context, params Params) int { func RunDiscriminator(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's // Until the settings are read, the one message is an invalid setting's
// error, which every SWWAF_LOG_LEVEL lets through. // error, which every SWW_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout, processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.LookupEnv), slog.LevelError) config.InstanceName(params.LookupEnv), slog.LevelError)
@@ -115,7 +116,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
return 1 return 1
} }
// While SWWAF_LOG_REMOTE_URL is set, every line on stdout from here on // While SWW_LOG_REMOTE_URL is set, every line on stdout from here on
// is sent there too. // is sent there too.
stdout := params.Stdout stdout := params.Stdout
@@ -150,7 +151,14 @@ func RunDiscriminator(ctx context.Context, params Params) int {
return 1 return 1
} }
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue) crs, err := discriminator.NewCoreRuleSet(cfg)
if err != nil {
processLog.Error("cannot use the Core Rule Set", "error", err.Error())
return 1
}
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs)
if err != nil { if err != nil {
processLog.Error("cannot use the lookup database", "error", err.Error()) processLog.Error("cannot use the lookup database", "error", err.Error())
@@ -170,8 +178,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil { if err != nil {
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR", processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error())
"error", err.Error())
return 1 return 1
} }
@@ -185,11 +192,12 @@ func RunDiscriminator(ctx context.Context, params Params) int {
} }
// newServer returns the server discriminator runs, with the metrics of the alerts, // newServer returns the server discriminator runs, with the metrics of the alerts,
// after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup // after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error. // database that cannot be read is an error.
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
crs *waf.CoreRuleSet,
) (*discriminator.Server, error) { ) (*discriminator.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
@@ -214,6 +222,7 @@ func newServer(
GeoJSURL: lookup.URL, GeoJSURL: lookup.URL,
AbuseIPDBURL: reputation.AbuseIPDBURL, AbuseIPDBURL: reputation.AbuseIPDBURL,
LookupFile: lookupFile, LookupFile: lookupFile,
CoreRuleSet: crs,
Now: now, Now: now,
Rules: ruleFiles, Rules: ruleFiles,
Alerts: alertQueue, Alerts: alertQueue,
@@ -272,7 +281,7 @@ func newAlertQueue(
} }
// newRemoteLogSender returns a sender of the log lines to // newRemoteLogSender returns a sender of the log lines to
// SWWAF_LOG_REMOTE_URL, with the settings for it. // SWW_LOG_REMOTE_URL, with the settings for it.
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender { func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
return remotelog.New(remotelog.Params{ return remotelog.New(remotelog.Params{
URL: cfg.LogRemoteURL, URL: cfg.LogRemoteURL,
+113 -62
View File
@@ -19,6 +19,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator" "sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/sww" "sneak.berlin/go/smallwebwaf/internal/sww"
@@ -33,21 +34,21 @@ const (
testVersion = "test" testVersion = "test"
// localhost is where the tests listen. // localhost is where the tests listen.
localhost = "127.0.0.1" localhost = "127.0.0.1"
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWW_LISTEN_ADDR"
stateDir = "SWWAF_STATE_DIR" stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWWAF_RULES_DIR" rulesDir = "SWW_RULES_DIR"
lookupSource = "SWWAF_LOOKUP_SOURCE" lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
instanceName = "SWWAF_INSTANCE_NAME" instanceName = "SWW_INSTANCE_NAME"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set. // adminSecret is the SWW_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at // instance is the SWW_INSTANCE_NAME the tests set where they look at
// it. // it.
instance = "fsn1app1/blog" instance = "fsn1app1/blog"
// greeting is what the tests' app answers. // greeting is what the tests' app answers.
@@ -123,14 +124,14 @@ func (o *output) text() string {
} }
// run runs `sww discriminator` with the settings in env until ctx is done, // run runs `sww discriminator` with the settings in env until ctx is done,
// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it, // and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses. // so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int { func run(ctx context.Context, env map[string]string, out *output) int {
return sww.RunDiscriminator(ctx, sww.Params{ return sww.RunDiscriminator(ctx, sww.Params{
Version: testVersion, Version: testVersion,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
value, ok := env[name] value, ok := env[name]
if !ok && name == "SWWAF_LOOKUP_SOURCE" { if !ok && name == "SWW_LOOKUP_SOURCE" {
return "off", true return "off", true
} }
@@ -146,7 +147,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{} out := &output{}
status := run(t.Context(), map[string]string{ status := run(t.Context(), map[string]string{
"SWWAF_BYTES_LIMIT_PER_MINUTE": "lots", "SWW_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance, instanceName: instance,
}, out) }, out)
if status != 1 { if status != 1 {
@@ -158,7 +159,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
if line["type"] != "process" || line["instance"] != instance || if line["type"] != "process" || line["instance"] != instance ||
line["level"] != "ERROR" || line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWWAF_BYTES_LIMIT_PER_MINUTE: ") { !strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
t.Errorf("start refused with %v", line) t.Errorf("start refused with %v", line)
} }
} }
@@ -214,7 +215,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
t.Errorf("exit status %d, want 1", status) t.Errorf("exit status %d, want 1", status)
} }
out.line(t, "msg", "cannot listen on SWWAF_LISTEN_ADDR") out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
} }
func TestServesUntilToldToStop(t *testing.T) { func TestServesUntilToldToStop(t *testing.T) {
@@ -280,8 +281,8 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_BLOCKLIST_URLS": lists.URL + "/tor.txt", "SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWWAF_LOG_LEVEL": "warn", "SWW_LOG_LEVEL": "warn",
}, out) }, out)
}() }()
@@ -384,7 +385,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
t.Parallel() t.Parallel()
const scope = "SWWAF_BAN_SCOPE_V4_PREFIX" const scope = "SWW_BAN_SCOPE_V4_PREFIX"
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
@@ -504,7 +505,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
rulesDir: dir, rulesDir: dir,
// The requests sent until the rule takes effect must not break a // The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too. // rate limit, whose ban would refuse them too.
"SWWAF_RATE_LIMIT_PER_MINUTE": "off", "SWW_RATE_LIMIT_PER_MINUTE": "off",
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -547,10 +548,60 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d") dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+ wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
": no such file or directory") ": no such file or directory")
} }
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "crs-exclusions.conf")
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory"
if line["error"] != want {
t.Errorf("start refused with %v, want the error %q", line, want)
}
}
func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleSetIsOff(
t *testing.T,
) {
t.Parallel()
broken := filepath.Join(t.TempDir(), "broken.conf")
err := os.WriteFile(broken, []byte("SecNoSuchDirective On\n"), 0o600)
if err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(t.TempDir(), "missing.conf"), broken} {
out := &output{}
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status)
}
out.line(t, "msg", "cannot use the Core Rule Set")
}
}
func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) { func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
t.Parallel() t.Parallel()
@@ -567,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
instanceName: instance, instanceName: instance,
lookupSource: "file", lookupSource: "file",
lookupDBPath: path, lookupDBPath: path,
"SWWAF_DENIED_COUNTRIES": "kp", "SWW_DENIED_COUNTRIES": "kp",
// The requests sent until a replacement takes effect, and those // The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would // for the metrics, must not break a rate limit, whose ban would
// refuse them too. // refuse them too.
@@ -662,7 +713,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
"SWWAF_BLOCKLIST_URLS": torURL, "SWW_BLOCKLIST_URLS": torURL,
// The requests sent until the list takes effect, and those for the // The requests sent until the list takes effect, and those for the
// metrics, must not break a rate limit, whose ban would refuse them // metrics, must not break a rate limit, whose ban would refuse them
// too. // too.
@@ -678,12 +729,12 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
}) })
// Restarted with drop.txt named after it and the server answering, tor.txt // Restarted with drop.txt named after it and the server answering, tor.txt
// waits SWWAF_BLOCKLIST_REFRESH after its failed try, kept in reputation.json, // waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
// while drop.txt, never tried, is fetched at once. Lists are fetched in the // while drop.txt, never tried, is fetched at once. Lists are fetched in the
// order named, so once drop.txt refuses the client, tor.txt has had its turn. // order named, so once drop.txt refuses the client, tor.txt has had its turn.
failing.Store(false) failing.Store(false)
env["SWWAF_BLOCKLIST_URLS"] = torURL + "," + dropURL env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
for statusFrom(t, url, placed) != http.StatusForbidden { for statusFrom(t, url, placed) != http.StatusForbidden {
time.Sleep(pollInterval) time.Sleep(pollInterval)
@@ -742,8 +793,8 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_CROWDSEC_LAPI_URL": engine.URL, "SWW_CROWDSEC_LAPI_URL": engine.URL,
"SWWAF_CROWDSEC_LAPI_KEY": bouncerKey, "SWW_CROWDSEC_LAPI_KEY": bouncerKey,
} }
// Once the list is fetched, the client's request bans it. // Once the list is fetched, the client's request bans it.
@@ -816,7 +867,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
t.Fatalf("exit status %d, want 1", status) t.Fatalf("exit status %d, want 1", status)
} }
want := "SWWAF_LOOKUP_DB_PATH cannot be read: open " + path + want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
": no such file or directory" ": no such file or directory"
line := out.line(t, "msg", "cannot use the lookup database") line := out.line(t, "msg", "cannot use the lookup database")
@@ -841,7 +892,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), "SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -896,8 +947,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), "SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWWAF_LOG_REMOTE_BUFFER": "1", "SWW_LOG_REMOTE_BUFFER": "1",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
} }
@@ -955,8 +1006,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
rulesDir: rules, rulesDir: rules,
"SWWAF_ALERT_WEBHOOK_URL": webhook.url, "SWW_ALERT_WEBHOOK_URL": webhook.url,
"SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, "SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance, instanceName: instance,
} }
@@ -1005,7 +1056,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
webhook.failing.Store(false) webhook.failing.Store(false)
env["SWWAF_ALLOW_NETS"] = localhost env["SWW_ALLOW_NETS"] = localhost
env[metricsToken] = token env[metricsToken] = token
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -1041,12 +1092,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
rateLimitPerDay: "1", rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts. // The metrics are read from 127.0.0.1, which no limit counts.
"SWWAF_ALLOW_NETS": localhost + "/32", "SWW_ALLOW_NETS": localhost + "/32",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
"SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url, "SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWWAF_ALERT_NTFY_URL": ntfy.url, "SWW_ALERT_NTFY_URL": ntfy.url,
"SWWAF_ALERT_NTFY_TOKEN": ntfyToken, "SWW_ALERT_NTFY_TOKEN": ntfyToken,
} }
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -1111,7 +1162,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"), wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
"SWWAF_STATE_DIR cannot be written: ") "SWW_STATE_DIR cannot be written: ")
} }
// wantStartRefused runs sww with its state files in dir, and // wantStartRefused runs sww with its state files in dir, and
@@ -1219,26 +1270,26 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
want := map[string]any{ want := map[string]any{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
"SWWAF_MODE": "enforce", "SWW_MODE": "enforce",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
"SWWAF_ALLOW_NETS": "", "SWW_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWW_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "120", "SWW_RATE_LIMIT_PER_MINUTE": "120",
"SWWAF_RATE_LIMIT_PER_HOUR": "1500", "SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "", "SWW_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWWAF_BAN_RESPONSE": "403", "SWW_BAN_RESPONSE": "403",
"SWWAF_LIMIT_BAN_DURATION": "1h", "SWW_LIMIT_BAN_DURATION": "1h",
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h", "SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWWAF_MAX_BAN_DURATION": "7d", "SWW_MAX_BAN_DURATION": "7d",
"SWWAF_ATTACK_BAN_DURATION": "7d", "SWW_ATTACK_BAN_DURATION": "7d",
"SWWAF_MAX_BANS": "5000", "SWW_MAX_BANS": "5000",
"SWWAF_BAN_SCOPE_V4_PREFIX": "32", "SWW_BAN_SCOPE_V4_PREFIX": "32",
"SWWAF_RULES_ENABLED": "true", "SWW_RULES_ENABLED": "true",
} }
for name, value := range want { for name, value := range want {
@@ -1438,7 +1489,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
} }
// wantRefused checks that discriminator at url refuses a request from localhost with // wantRefused checks that discriminator at url refuses a request from localhost with
// 403, the default SWWAF_BAN_RESPONSE. // 403, the default SWW_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) { func wantRefused(t *testing.T, url string) {
t.Helper() t.Helper()
@@ -1479,8 +1530,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int
} }
} }
// destination is a stand-in for SWWAF_ALERT_SLACK_WEBHOOK_URL or // destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
// SWWAF_ALERT_NTFY_URL. It notes each request it is sent, and answers // SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
// 200. // 200.
type destination struct { type destination struct {
url string url string
@@ -1539,7 +1590,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
} }
} }
// webhook is a stand-in for SWWAF_ALERT_WEBHOOK_URL. It notes each alert // webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
// it is sent, and answers 204, or 503 while failing. // it is sent, and answers 204, or 503 while failing.
type webhook struct { type webhook struct {
url string url string
+328
View File
@@ -0,0 +1,328 @@
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
# They are the fields where gitea sends a search, a file name or a branch name.
# Each exclusion runs with the request, before the rules it switches off, those
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
# Each comment names a request the rule refused.
# A request whose hostname SWW_APPS does not list gitea for skips every rule
# below. Inspect sets tx.app_gitea on the others.
SecRule &TX:app_gitea "@eq 0" \
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
# The query parameters in which gitea sends names within a repository or its
# own records, or a page of its own site, are not checked against the lists of
# system files, shell paths and command names, on any path:
# 930120: .gitignore
# 932160: bin/docker-entrypoint
# 932260: docker-build
SecAction "id:900591,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:path,\
ctl:ruleRemoveTargetById=930120;ARGS:files,\
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932160;ARGS:path,\
ctl:ruleRemoveTargetById=932160;ARGS:files,\
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932260;ARGS:path,\
ctl:ruleRemoveTargetById=932260;ARGS:files,\
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
# gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there:
# 930110: ../
# 930120: .gitignore
# 931100: http://127.0.0.1:8080
# 932120: Invoke-WebRequest
# 932130: $(pwd)
# 932140: if (a == b) {
# 932160: /bin/sh
# 932235: `make check`
# 932250: ls -la
# 932260: python3
# 932380: `whoami`
# 933100: <?php echo
# 933130: $_GET[
# 933135: ${VAR}
# 934100: process.env
# 934110: http://127.0.0.1:8080
# 934130: __proto__
# 941100: <script>
# 941110: <script>
# 941160: <img src=
# 941180: document.cookie
# 941390: fetch(
# 942100: SELECT * FROM users WHERE
# 942140: information_schema
# 942190: union select
# 942230: if (a == b) {
# 944100: java.lang.Runtime
# 944110: java.lang.Runtime
# 944130: java.lang.Runtime
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
"id:900600,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930110;ARGS:q,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=931100;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932130;ARGS:q,\
ctl:ruleRemoveTargetById=932140;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932235;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=932380;ARGS:q,\
ctl:ruleRemoveTargetById=933100;ARGS:q,\
ctl:ruleRemoveTargetById=933130;ARGS:q,\
ctl:ruleRemoveTargetById=933135;ARGS:q,\
ctl:ruleRemoveTargetById=934100;ARGS:q,\
ctl:ruleRemoveTargetById=934110;ARGS:q,\
ctl:ruleRemoveTargetById=934130;ARGS:q,\
ctl:ruleRemoveTargetById=941100;ARGS:q,\
ctl:ruleRemoveTargetById=941110;ARGS:q,\
ctl:ruleRemoveTargetById=941160;ARGS:q,\
ctl:ruleRemoveTargetById=941180;ARGS:q,\
ctl:ruleRemoveTargetById=941390;ARGS:q,\
ctl:ruleRemoveTargetById=942100;ARGS:q,\
ctl:ruleRemoveTargetById=942140;ARGS:q,\
ctl:ruleRemoveTargetById=942190;ARGS:q,\
ctl:ruleRemoveTargetById=942230;ARGS:q,\
ctl:ruleRemoveTargetById=944100;ARGS:q,\
ctl:ruleRemoveTargetById=944110;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# A page opened from the results of one of those searches has the search's
# address in Referer, which skips these rules then:
# 941110: <script>
# 941120: onClick={
# 941160: <img src=
# 942100: LIKE '%foo%'
# 944100: java.lang.Runtime
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
issues(?:/search)?|pulls|[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
"id:900601,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
# gitea's searches of repositories, users, branches, tags and packages send a
# name in q, which skips these rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
# 944130: org.apache.commons.logging
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
"id:900602,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# gitea's other searches for a name send it in q too, which skips only these
# rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
# The searches:
# - the dashboard's filter of repositories;
# - the repositories on a user's or an organisation's page, whose path has
# one segment;
# - the author filter of the issue and pull request lists;
# - the lists of users and organisations, and the API's search for users.
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
explore/(?:users|organizations)|api/v1/users/search|\
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
"id:900603,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# These requests name a file in the path, which skips these rules there:
# 920500: notes.txt~
# 934100: process.env.d.ts
# 941130: docs/a b.xhtml
# 944130: java.lang.String.md
# The requests:
# - a file's page, raw file and download;
# - its blame, history, edit, preview and delete pages;
# - the API's call for its contents;
# - a release attachment's download.
SecRule REQUEST_FILENAME \
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_preview|_delete|contents|releases/download)/" \
"id:900610,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
# The requests below name a file in a query parameter. These rules refused
# them for the file named:
# 930120: .gitignore
# 932120: install-script.sh
# 932130: [draft].md
# 932160: bin/docker-entrypoint
# 932260: docker-compose.yml
# 934100: process.env.d.ts
# 934130: __proto__.json
# 941130: index.xhtml
# 941180: document.write.js
# 942140: information_schema.sql
# 944110: java/lang/Runtime.java
# 944130: java.lang.String.md
# Each request skips the rules that refused it. Rule 900591 already has path and
# redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
"id:900620,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932160;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file,\
ctl:ruleRemoveTargetById=934100;ARGS:file,\
ctl:ruleRemoveTargetById=934130;ARGS:file,\
ctl:ruleRemoveTargetById=941130;ARGS:file,\
ctl:ruleRemoveTargetById=941180;ARGS:file,\
ctl:ruleRemoveTargetById=942140;ARGS:file,\
ctl:ruleRemoveTargetById=944110;ARGS:file,\
ctl:ruleRemoveTargetById=944130;ARGS:file"
# The API's list of a file's commits, in path.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
"id:900621,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The API's call for the contents of several files, in body, as JSON.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
"id:900622,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:body,\
ctl:ruleRemoveTargetById=932120;ARGS:body,\
ctl:ruleRemoveTargetById=932160;ARGS:body,\
ctl:ruleRemoveTargetById=934100;ARGS:body,\
ctl:ruleRemoveTargetById=934130;ARGS:body,\
ctl:ruleRemoveTargetById=941130;ARGS:body,\
ctl:ruleRemoveTargetById=941180;ARGS:body,\
ctl:ruleRemoveTargetById=942140;ARGS:body,\
ctl:ruleRemoveTargetById=944110;ARGS:body,\
ctl:ruleRemoveTargetById=944130;ARGS:body"
# An issue opened from a template, in template.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
"id:900623,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:template,\
ctl:ruleRemoveTargetById=932120;ARGS:template,\
ctl:ruleRemoveTargetById=932130;ARGS:template,\
ctl:ruleRemoveTargetById=932160;ARGS:template,\
ctl:ruleRemoveTargetById=932260;ARGS:template,\
ctl:ruleRemoveTargetById=934100;ARGS:template,\
ctl:ruleRemoveTargetById=934130;ARGS:template,\
ctl:ruleRemoveTargetById=941130;ARGS:template,\
ctl:ruleRemoveTargetById=941180;ARGS:template,\
ctl:ruleRemoveTargetById=942140;ARGS:template,\
ctl:ruleRemoveTargetById=944110;ARGS:template,\
ctl:ruleRemoveTargetById=944130;ARGS:template"
# The sign-in page on the way to a file's edit page, in redirect_to.
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
"id:900624,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
# An attachment uploaded through the API to a release, an issue or a comment,
# in name.
SecRule REQUEST_FILENAME \
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:900625,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:name,\
ctl:ruleRemoveTargetById=932120;ARGS:name,\
ctl:ruleRemoveTargetById=932260;ARGS:name,\
ctl:ruleRemoveTargetById=934100;ARGS:name,\
ctl:ruleRemoveTargetById=934130;ARGS:name,\
ctl:ruleRemoveTargetById=941130;ARGS:name,\
ctl:ruleRemoveTargetById=941180;ARGS:name,\
ctl:ruleRemoveTargetById=942140;ARGS:name,\
ctl:ruleRemoveTargetById=944130;ARGS:name"
# A diff's hidden lines, fetched when shown, in path.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/blob_excerpt/[0-9a-f]+$" \
"id:900626,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The requests below name a branch or tag in a query parameter, which skips
# the list of command names (932260) there. It refused names such as
# docker-build, python3-migration and feat/docker-support.
# Deleting or restoring a branch, in name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
# A release started from a tag, in tag.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
# A branch protection rule opened for editing, in rule_name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
# A directory listing's last commits, in refSubUrl.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# An artifact upload from actions/upload-artifact@v3 sends the header
# Content-Range, which the Core Rule Set refused (920450).
SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
SecMarker END_GITEA
+393
View File
@@ -0,0 +1,393 @@
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# defaults. Each line is a path and query, and may end with one header after a
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
# the query and the headers the same way for any method.
# Searches with ordinary words and symbols.
/owner/repo/search?q=package.json
/owner/repo/search?q=.gitignore
/owner/repo/search?q=docker-compose.yml
/owner/repo/search?q=python3
/owner/repo/search?q=ssh%20key
/owner/repo/search?q=feat%2Fdocker-support
/owner/repo/search?q=ls%20-la
/owner/repo/search?q=sed%20-i
/owner/repo/search?q=%2Fbin%2Fsh
/owner/repo/search?q=%60make%20check%60
/owner/repo/search?q=%60whoami%60
/owner/repo/search?q=fetch%28
/owner/repo/search?q=%24%7BVAR%7D
/owner/repo/search?q=%24%28pwd%29
/owner/repo/search?q=process.env
/owner/repo/search?q=%3Cimg%20src%3D
/owner/repo/search?q=%3Cscript%3E
/owner/repo/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/search?q=union%20select
/owner/repo/search?q=http%3A%2F%2F127.0.0.1%3A8080
/owner/repo/search?q=..%2F
/owner/repo/search?q=%2Fetc%2Fpasswd
/owner/repo/search?q=eval%28
/owner/repo/search?q=chmod%20%2Bx
/owner/repo/search?q=echo%20%24PATH
/owner/repo/search?q=java.lang.Runtime
/owner/repo/search?q=document.cookie
/owner/repo/search?q=alert%281%29
/owner/repo/search?q=__proto__
/owner/repo/search?q=C%3A%5CWindows%5CSystem32
/owner/repo/search?q=Invoke-WebRequest
/owner/repo/search?q=if%20%28a%20%3D%3D%20b%29%20%7B
/owner/repo/search?q=sudo%20apt%20install
/owner/repo/search?q=~%2F.ssh%2Fconfig
/owner/repo/search?q=.htaccess
/owner/repo/search?q=%24_GET%5B
/owner/repo/search?q=%24_POST
/owner/repo/search?q=%24_SERVER%5B%27REQUEST_URI%27%5D
/owner/repo/search?q=%3C%3Fphp%20echo
/owner/repo/search?q=information_schema
/owner/repo/search?q=LIKE%20%27%25foo%25%27
/owner/repo/search?q=onClick%3D%7B
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/code?q=%3Cimg%20src%3D
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/code?q=%3Cimg%20src%3D
/owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D
/owner/repo/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues/search?q=%3Cimg%20src%3D
/owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/pulls?q=%3Cimg%20src%3D
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/issues?type=your_repositories&q=%3Cimg%20src%3D
/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/issues/search?q=%3Cimg%20src%3D
/pulls?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/pulls?type=your_repositories&q=%3Cimg%20src%3D
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
# Searches for the names of repositories, users, branches, tags and packages.
/explore/repos?q=python3-app
/explore/repos?q=.profile
/explore/repos?q=powershell-scripts
/api/v1/repos/search?q=docker-build
/user/search_candidates?q=docker-ci
/owner/repo/branches?q=feat%2Fdocker-support
/owner/repo/tags?q=docker-v1.0
/owner/-/packages?q=python3-requests
/owner/-/packages?q=org.apache.commons.logging
/repo/search?q=python3-app
/repo/search?q=docker-build
/repo/search?q=powershell-scripts
/repo/search?q=.profile
/owner?tab=repositories&q=python3-app
/org?q=python3-app
/owner/repo/issues/posters?q=docker-ci
/owner/repo/pulls/posters?q=docker-ci
/explore/users?q=docker-ci
/explore/organizations?q=docker-ci
/api/v1/users/search?q=docker-ci
# Pages, raw files and edit pages of files, and the API's call for their
# contents.
/owner/repo/src/branch/main/package.json
/owner/repo/raw/branch/main/package.json
/owner/repo/_edit/main/package.json
/api/v1/repos/owner/repo/contents/package.json
/owner/repo/src/branch/main/.gitignore
/owner/repo/raw/branch/main/.gitignore
/owner/repo/_edit/main/.gitignore
/api/v1/repos/owner/repo/contents/.gitignore
/owner/repo/src/branch/main/docker-compose.yml
/owner/repo/raw/branch/main/docker-compose.yml
/owner/repo/_edit/main/docker-compose.yml
/api/v1/repos/owner/repo/contents/docker-compose.yml
/owner/repo/src/branch/main/Dockerfile
/owner/repo/raw/branch/main/Dockerfile
/owner/repo/_edit/main/Dockerfile
/api/v1/repos/owner/repo/contents/Dockerfile
/owner/repo/src/branch/main/.htaccess
/owner/repo/raw/branch/main/.htaccess
/owner/repo/_edit/main/.htaccess
/api/v1/repos/owner/repo/contents/.htaccess
/owner/repo/src/branch/main/rootfs/etc/passwd
/owner/repo/raw/branch/main/rootfs/etc/passwd
/owner/repo/_edit/main/rootfs/etc/passwd
/api/v1/repos/owner/repo/contents/rootfs/etc/passwd
/owner/repo/src/branch/main/bin/docker-entrypoint
/owner/repo/raw/branch/main/bin/docker-entrypoint
/owner/repo/_edit/main/bin/docker-entrypoint
/api/v1/repos/owner/repo/contents/bin/docker-entrypoint
/owner/repo/src/branch/main/scripts/python3-setup.sh
/owner/repo/raw/branch/main/scripts/python3-setup.sh
/owner/repo/_edit/main/scripts/python3-setup.sh
/api/v1/repos/owner/repo/contents/scripts/python3-setup.sh
/owner/repo/src/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/raw/branch/main/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/_edit/main/src/java.base/share/classes/java/lang/Runtime.java
/api/v1/repos/owner/repo/contents/src/java.base/share/classes/java/lang/Runtime.java
/owner/repo/src/branch/main/java.lang.String.md
/owner/repo/raw/branch/main/java.lang.String.md
/owner/repo/_edit/main/java.lang.String.md
/api/v1/repos/owner/repo/contents/java.lang.String.md
/owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/raw/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/_edit/main/META-INF/services/org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/contents/META-INF/services/org.apache.commons.logging.LogFactory
/owner/repo/src/branch/main/process.env.d.ts
/owner/repo/raw/branch/main/process.env.d.ts
/owner/repo/_edit/main/process.env.d.ts
/api/v1/repos/owner/repo/contents/process.env.d.ts
/owner/repo/src/branch/main/document.write.js
/owner/repo/raw/branch/main/document.write.js
/owner/repo/_edit/main/document.write.js
/api/v1/repos/owner/repo/contents/document.write.js
/owner/repo/src/branch/main/test/fixtures/__proto__.json
/owner/repo/raw/branch/main/test/fixtures/__proto__.json
/owner/repo/_edit/main/test/fixtures/__proto__.json
/api/v1/repos/owner/repo/contents/test/fixtures/__proto__.json
/owner/repo/src/branch/main/install-script.sh
/owner/repo/raw/branch/main/install-script.sh
/owner/repo/_edit/main/install-script.sh
/api/v1/repos/owner/repo/contents/install-script.sh
/owner/repo/src/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/raw/branch/main/src/backend/catalog/information_schema.sql
/owner/repo/_edit/main/src/backend/catalog/information_schema.sql
/api/v1/repos/owner/repo/contents/src/backend/catalog/information_schema.sql
/owner/repo/src/branch/main/docs/a%20b.xhtml
/owner/repo/raw/branch/main/docs/a%20b.xhtml
/owner/repo/_edit/main/docs/a%20b.xhtml
/api/v1/repos/owner/repo/contents/docs/a%20b.xhtml
/owner/repo/src/branch/main/%5Bdraft%5D.md
/owner/repo/raw/branch/main/%5Bdraft%5D.md
/owner/repo/_edit/main/%5Bdraft%5D.md
/api/v1/repos/owner/repo/contents/%5Bdraft%5D.md
/owner/repo/src/branch/main/notes.txt~
/owner/repo/raw/branch/main/notes.txt~
/owner/repo/_edit/main/notes.txt~
/api/v1/repos/owner/repo/contents/notes.txt~
# The download, blame, history, delete and preview pages of the files above
# whose names the Core Rule Set checks in the path, and their downloads as
# release attachments.
/owner/repo/media/branch/main/notes.txt~
/owner/repo/blame/branch/main/notes.txt~
/owner/repo/commits/branch/main/notes.txt~
/owner/repo/_delete/main/notes.txt~
/owner/repo/media/branch/main/process.env.d.ts
/owner/repo/blame/branch/main/process.env.d.ts
/owner/repo/commits/branch/main/process.env.d.ts
/owner/repo/_delete/main/process.env.d.ts
/owner/repo/media/branch/main/docs/a%20b.xhtml
/owner/repo/blame/branch/main/docs/a%20b.xhtml
/owner/repo/commits/branch/main/docs/a%20b.xhtml
/owner/repo/_delete/main/docs/a%20b.xhtml
/owner/repo/media/branch/main/java.lang.String.md
/owner/repo/blame/branch/main/java.lang.String.md
/owner/repo/commits/branch/main/java.lang.String.md
/owner/repo/_delete/main/java.lang.String.md
/owner/repo/_preview/main/notes.txt~
/owner/repo/_preview/main/process.env.d.ts
/owner/repo/_preview/main/java.lang.String.md
/owner/repo/releases/download/v1.0/notes.txt~
/owner/repo/releases/download/v1.0/process.env.d.ts
/owner/repo/releases/download/v1.0/java.lang.String.md
/owner/repo/releases/download/v1.0/a%20b.xhtml
# File names in the query string: the commit graph of a file, the API's lists
# of its commits and of its contents, an issue opened from a template, the
# sign-in page on the way to its edit page, and a diff's hidden lines.
/owner/repo/graph?file=package.json
/api/v1/repos/owner/repo/commits?path=package.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fpackage.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fpackage.json
/owner/repo/graph?file=.gitignore
/api/v1/repos/owner/repo/commits?path=.gitignore
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.gitignore%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.gitignore
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.gitignore
/owner/repo/graph?file=docker-compose.yml
/api/v1/repos/owner/repo/commits?path=docker-compose.yml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docker-compose.yml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocker-compose.yml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocker-compose.yml
/owner/repo/graph?file=.htaccess
/api/v1/repos/owner/repo/commits?path=.htaccess
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.htaccess%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.htaccess
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.htaccess
/owner/repo/graph?file=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/commits?path=rootfs%2Fetc%2Fpasswd
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22rootfs%2Fetc%2Fpasswd%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Frootfs%2Fetc%2Fpasswd
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Frootfs%2Fetc%2Fpasswd
/owner/repo/graph?file=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/commits?path=bin%2Fdocker-entrypoint
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22bin%2Fdocker-entrypoint%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fbin%2Fdocker-entrypoint
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fbin%2Fdocker-entrypoint
/owner/repo/graph?file=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/commits?path=scripts%2Fpython3-setup.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22scripts%2Fpython3-setup.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fscripts%2Fpython3-setup.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fscripts%2Fpython3-setup.sh
/owner/repo/graph?file=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/commits?path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
/owner/repo/graph?file=java.lang.String.md
/api/v1/repos/owner/repo/commits?path=java.lang.String.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22java.lang.String.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fjava.lang.String.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fjava.lang.String.md
/owner/repo/graph?file=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/commits?path=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
/owner/repo/graph?file=process.env.d.ts
/api/v1/repos/owner/repo/commits?path=process.env.d.ts
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22process.env.d.ts%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fprocess.env.d.ts
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fprocess.env.d.ts
/owner/repo/graph?file=document.write.js
/api/v1/repos/owner/repo/commits?path=document.write.js
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22document.write.js%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocument.write.js
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocument.write.js
/owner/repo/graph?file=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/commits?path=test%2Ffixtures%2F__proto__.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22test%2Ffixtures%2F__proto__.json%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Ftest%2Ffixtures%2F__proto__.json
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Ftest%2Ffixtures%2F__proto__.json
/owner/repo/graph?file=install-script.sh
/api/v1/repos/owner/repo/commits?path=install-script.sh
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22install-script.sh%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Finstall-script.sh
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Finstall-script.sh
/owner/repo/graph?file=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/commits?path=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fbackend%2Fcatalog%2Finformation_schema.sql%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
/owner/repo/graph?file=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/commits?path=docs%2Fa%20b.xhtml
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docs%2Fa%20b.xhtml%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocs%2Fa%20b.xhtml
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocs%2Fa%20b.xhtml
/owner/repo/graph?file=%5Bdraft%5D.md
/api/v1/repos/owner/repo/commits?path=%5Bdraft%5D.md
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=process.env.d.ts&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=java.lang.String.md&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=install-script.sh&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=information_schema.sql&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=document.write.js&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=__proto__.json&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=index.xhtml&style=unified&direction=down&wiki=false
# File names of attachments uploaded through the API, to a release, an issue and
# a comment.
/api/v1/repos/owner/repo/releases/1/assets?name=package.json
/api/v1/repos/owner/repo/releases/1/assets?name=.gitignore
/api/v1/repos/owner/repo/releases/1/assets?name=docker-compose.yml
/api/v1/repos/owner/repo/releases/1/assets?name=.htaccess
/api/v1/repos/owner/repo/releases/1/assets?name=docker-entrypoint
/api/v1/repos/owner/repo/releases/1/assets?name=python3-setup.sh
/api/v1/repos/owner/repo/releases/1/assets?name=Runtime.java
/api/v1/repos/owner/repo/releases/1/assets?name=org.apache.commons.logging.LogFactory
/api/v1/repos/owner/repo/releases/1/assets?name=%5Bdraft%5D.md
/api/v1/repos/owner/repo/releases/1/assets?name=notes.txt~
/api/v1/repos/owner/repo/releases/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/issues/comments/1/assets?name=install-script.sh
/api/v1/repos/owner/repo/releases/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/issues/comments/1/assets?name=process.env.d.ts
/api/v1/repos/owner/repo/releases/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/issues/comments/1/assets?name=java.lang.String.md
/api/v1/repos/owner/repo/releases/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/issues/comments/1/assets?name=information_schema.sql
/api/v1/repos/owner/repo/releases/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/1/assets?name=document.write.js
/api/v1/repos/owner/repo/issues/comments/1/assets?name=document.write.js
/api/v1/repos/owner/repo/releases/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/issues/comments/1/assets?name=__proto__.json
/api/v1/repos/owner/repo/releases/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/1/assets?name=index.xhtml
/api/v1/repos/owner/repo/issues/comments/1/assets?name=index.xhtml
# An artifact upload from actions/upload-artifact@v3, which sends Content-Range.
/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10
# Branch and tag names in the query string: deleting and restoring a branch,
# starting a release from a tag, a branch protection rule, a directory listing's
# last commits, and a container image push that reuses a layer.
/owner/repo/branches/delete?name=docker-build&page=1
/owner/repo/branches/restore?branch_id=1&name=docker-build&page=1
/owner/repo/releases/new?tag=docker-build
/owner/repo/settings/branches/edit?rule_name=docker-build
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
/owner/repo/branches/delete?name=feat%2Fdocker-support&page=1
/owner/repo/branches/restore?branch_id=1&name=feat%2Fdocker-support&page=1
/owner/repo/releases/new?tag=feat%2Fdocker-support
/owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support
/owner/repo/branches/delete?name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/branches/restore?branch_id=1&name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x
/owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
/owner/repo/branches/delete?name=python3-migration&page=1
/owner/repo/branches/restore?branch_id=1&name=python3-migration&page=1
/owner/repo/releases/new?tag=python3-migration
/owner/repo/settings/branches/edit?rule_name=python3-migration
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
/owner/repo/branches/delete?name=ansible-roles&page=1
/owner/repo/branches/restore?branch_id=1&name=ansible-roles&page=1
/owner/repo/releases/new?tag=ansible-roles
/owner/repo/settings/branches/edit?rule_name=ansible-roles
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
/owner/repo/branches/delete?name=whoami&page=1
/owner/repo/branches/restore?branch_id=1&name=whoami&page=1
/owner/repo/releases/new?tag=whoami
/owner/repo/settings/branches/edit?rule_name=whoami
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fwhoami
# Pages a signed-in user opens.
/
/owner/repo
/owner/repo/issues
/owner/repo/pulls
/owner/repo/releases
/owner/repo/wiki
/owner/repo/activity
/owner/repo/branches
/owner/repo/tags
/owner/repo/settings
/owner/repo/actions
/owner/repo/compare/main...main
/owner/repo/commits/branch/main
/owner/repo/archive/main.zip
/notifications
/user/settings
/user/settings/applications
/explore/repos
/-/admin
+236 -54
View File
@@ -1,8 +1,9 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes sww // body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
// makes to it, as "Attack detection" under "Settings" in SPEC.md describes // makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
// them. It reads no response. // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
// in SPEC.md describes them. It reads no response.
// //
// sww writes only to its state directory, so Coraza is built with // sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
@@ -11,10 +12,16 @@
package waf package waf
import ( import (
_ "embed"
"errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -26,15 +33,17 @@ import (
) )
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, and bodyDirectives for %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWWAF_WAF_BODY_LIMIT is set. Each rule sww adds has an id from // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set // directives of every app in apps for the third.
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting // Each rule sww adds has an id from 900000 to 900999, the ids the
// switches one off. Coraza joins a line ending in \ to the next, without // Core Rule Set keeps for the rules that set it up, which
// the spaces at the start of the next. // SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. sww compares the request's anomaly # The engine only detects. sww compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
SecRequestBodyAccess Off SecRequestBodyAccess Off
@@ -69,37 +78,38 @@ SecArgumentsLimit 1000
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
# The sixth: only the rules for requests are loaded, and no response is # The exclusions file comes before the rules, where the Core Rule Set puts
# its own file of exclusions that run before the rules, so that they run
# before the rules of phase 1 too.
%s
# The apps' fixes come before the rules for the same reason.
%s
# The fifth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # The third: redirect_uri is not checked for a URL naming an IP address or
# localhost. Coraza matches a parameter name here, and in the fourth, # localhost. Coraza matches a parameter name here without regard to case.
# without regard to case. ARGS holds the fields of a form data or multipart # ARGS holds the fields of a form data or multipart body Coraza reads as
# body Coraza reads as well as the query parameters, so a field of one of # well as the query parameters, so a field of this name is left out too.
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
# The fourth: the query parameters in which gitea sends names within a # The fourth: Referer is not checked for a Unix command without arguments,
# repository or its own records, or a page of its own site, are not # for Java starting a process, or for a Java class name. 944130 refused
# checked against the lists of system files, shell paths and command # every page opened from the page of java.lang.String.md.
# names. Coraza takes one rule id per directive.
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
!ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without
# arguments, or for Java starting a process. The cookies are left out in
# Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The sixth: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not
# checked for command names. 932260 refused the push of a layer from
# owner/python3-app.
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
` `
// bodyDirectives have the Core Rule Set read the part of a request body // bodyDirectives have the Core Rule Set read the part of a request body
@@ -144,22 +154,139 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// cookiesNotRead are the cookies the Core Rule Set reads a request // app is an app whose ordinary requests the Core Rule Set would refuse,
// without, the rest of the fifth change. // with the fixes that let them pass.
type app struct {
// directives are Coraza exclusions, loaded before the Core Rule Set's
// rules. Their first rule skips the rest unless Inspect sets the
// transaction variable app_ and the app's name, and they end with the
// marker it skips to.
directives string
// cookies are the cookies the Core Rule Set reads a request without.
cookies []string
}
//go:embed gitea.conf
var giteaDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a map
var cookiesNotRead = []string{"gitea_flash", "redirect_to"} var apps = map[string]app{
"gitea": {
directives: giteaDirectives,
cookies: []string{"gitea_flash", "redirect_to"},
},
}
// AppNames returns the names of the apps whose fixes sww has, in
// alphabetical order.
func AppNames() []string {
return slices.Sorted(maps.Keys(apps))
}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4. // ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
ParanoiaLevel int ParanoiaLevel int
// DisabledRules are the ids of the rules switched off // DisabledRules are the ids of the rules switched off
// (SWWAF_WAF_DISABLED_RULES). // (SWW_WAF_DISABLED_RULES).
DisabledRules []int DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64 BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
// Apps are the names of the apps each hostname serves, by hostname in
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
// apps only, or of every app while Apps is nil.
Apps map[string][]string
}
// The ids of sww's own rules, which an exclusions file may not
// name.
const (
firstOwnRuleID = 900000
lastOwnRuleID = 900999
)
// numberOrRange matches a number, or a range of numbers such as
// 900000-900999. Coraza reads a number with a + before it too, so the
// end of a range may have one.
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
// errSwwRuleID is the error of an exclusions file that names an
// id of sww's own rules.
var errSwwRuleID = errors.New("an id of sww's own rules")
// errChangesNoRule is the error of an exclusions file that holds a
// directive that removes or changes rules as it loads, such as
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
// the directive finds none of them.
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
"with a ctl exclusion")
// directivesIn returns the directives in text as Coraza reads them: it
// skips blank lines and comment lines, and joins a line ending in \ to
// the next, without the spaces around either.
func directivesIn(text string) []string {
var directives []string
joined := ""
for line := range strings.Lines(text) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if start, found := strings.CutSuffix(line, `\`); found {
joined += start
continue
}
directives = append(directives, joined+line)
joined = ""
}
return directives
}
// checkExclusions returns an error if exclusions, the text of an
// exclusions file, names an id of sww's own rules, alone or in a
// range, or holds a directive whose name starts with SecRuleRemove or
// SecRuleUpdate.
func checkExclusions(exclusions string) error {
for _, directive := range directivesIn(exclusions) {
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
first, _ := strconv.Atoi(match[1])
last := first
if match[2] != "" {
last, _ = strconv.Atoi(match[2])
}
if first <= lastOwnRuleID && last >= firstOwnRuleID {
return fmt.Errorf("the exclusions file names %s: %w", match[0],
errSwwRuleID)
}
}
// Coraza takes the directive's name up to the first space, without
// regard to case.
name, _, _ := strings.Cut(directive, " ")
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
strings.HasPrefix(lower, "secruleupdate") {
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
errChangesNoRule)
}
}
return nil
} }
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
@@ -167,18 +294,44 @@ type Params struct {
type CoreRuleSet struct { type CoreRuleSet struct {
waf coraza.WAF waf coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the six changes, at params' // New returns the Core Rule Set with the six changes and the apps' fixes,
// paranoia level, without the rules it switches off, and reading request // at params' paranoia level, without the rules it switches off, reading
// bodies up to params' limit. // request bodies up to params' limit, and with params' exclusions file
// loaded before the Core Rule Set's rules. An exclusions file is an error
// if it cannot be read or does not parse, or if checkExclusions refuses
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit) body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
} }
text := fmt.Sprintf(directives, params.ParanoiaLevel, body) exclusions := ""
if params.ExclusionsFile != "" {
file, err := os.ReadFile(params.ExclusionsFile)
if err != nil {
return nil, fmt.Errorf("read the exclusions file: %w", err)
}
exclusions = string(file)
err = checkExclusions(exclusions)
if err != nil {
return nil, err
}
}
var fixes strings.Builder
for _, name := range AppNames() {
fixes.WriteString(apps[name].directives + "\n")
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
fixes.String())
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -196,7 +349,7 @@ func New(params Params) (*CoreRuleSet, error) {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -208,11 +361,12 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client: on its // Inspect runs the Core Rule Set on r, a request from client, with the
// method, its URL with the query, and its headers, the Cookie header // fixes of the apps appsFor names for r's hostname: on its method, its URL
// without the cookies in cookiesNotRead, and on body, r's body as the // with the query, and its headers, the Cookie header without those apps'
// caller has it, as readBody reads it. It returns what it found, what it // cookies, and on body, r's body as the caller has it, as readBody reads
// read of body, and the error that ended the reading early, if one did. // it. It returns what it found, what it read of body, and the error that
// ended the reading early, if one did.
func (c *CoreRuleSet) Inspect( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
@@ -220,13 +374,24 @@ func (c *CoreRuleSet) Inspect(
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
// The transaction's variables are set only through Coraza's interface
// for plugins.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
var cookiesNotRead []string
for _, name := range c.appsFor(r.Host) {
state.Variables().TX().Set("app_"+name, []string{"1"})
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
}
tx.ProcessConnection(client.String(), 0, "", 0) tx.ProcessConnection(client.String(), 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto) tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header { for name, values := range r.Header {
for _, value := range values { for _, value := range values {
if name == "Cookie" { if name == "Cookie" {
value = withoutCookiesNotRead(value) value = withoutCookies(value, cookiesNotRead)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -266,7 +431,7 @@ func (c *CoreRuleSet) Inspect(
} }
// readBody reads body, the body of the request in tx, which has run on // readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is // the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it // 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 // 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. // when they are no longer than that, since they cannot be read in part.
@@ -319,9 +484,26 @@ func score(tx types.Transaction) int {
return n return n
} }
// withoutCookiesNotRead returns value, a Cookie header's, without the // appsFor returns the names of the apps whose fixes a request for host,
// cookies in cookiesNotRead. // its Host header, gets: every app while SWW_APPS is unset, and otherwise
func withoutCookiesNotRead(value string) string { // those SWW_APPS lists for host's name, without the port and in
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// withoutCookies returns value, a Cookie header's, without the cookies
// named in cookiesNotRead.
func withoutCookies(value string, cookiesNotRead []string) string {
var kept []string var kept []string
for cookie := range strings.SplitSeq(value, ";") { for cookie := range strings.SplitSeq(value, ";") {
+283 -5
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
@@ -14,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// defaultDisabledRules are the rules SWWAF_WAF_DISABLED_RULES switches off // defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
// by default. // by default.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
@@ -119,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a gitea request one of
// the six changes is for, and still finds result in refused, a request // the six changes or gitea's fixes are for, and still finds result in
// like it that the change is not for. // refused, a request like it that they are not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
) { ) {
@@ -308,13 +309,14 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
matched(930120)) matched(930120))
} }
func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) { func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
search = "https://git.example/explore/repos?q=env" search = "https://git.example/explore/repos?q=env"
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" + runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
"java.base/share/classes/java/lang/Runtime.java" "java.base/share/classes/java/lang/Runtime.java"
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
) )
crs := atDefaults(t) crs := atDefaults(t)
@@ -323,6 +325,8 @@ func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) {
matched(932340)) matched(932340))
wantChange(t, crs, get("/", "Referer: "+runtimeJava), wantChange(t, crs, get("/", "Referer: "+runtimeJava),
get("/", "X-Page: "+runtimeJava), matched(944110)) get("/", "X-Page: "+runtimeJava), matched(944110))
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
matched(944130))
// It is still checked for script and SQL injection. // It is still checked for script and SQL injection.
wantResult(t, crs, wantResult(t, crs,
@@ -362,7 +366,281 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
} }
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies. func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
reuse := "?mount=sha256:0000&from=owner/python3-app"
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
matched(932260))
// A command after the image's name is still refused.
wantResult(t, crs,
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
matched(930120, 932160))
}
// giteaFixed are gitea requests that only gitea's fixes let through, one
// for each kind of fix, each with what the Core Rule Set finds in it
// without them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var giteaFixed = []struct {
request request
result waf.Result
}{
// A search of code, on the path of gitea's search.
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
// A file name, in a parameter gitea sends names in, on any path.
{get("/?path=.gitignore"), matched(930120)},
// A file name, in a cookie of gitea's.
{
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
matched(930120),
},
}
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
// one that serves another app.
const (
giteaHost = "git.example"
otherHost = "blog.example"
)
// withHost returns r sent to host.
func withHost(r request, host string) request {
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
}
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
// giving apps.
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, nil)
for _, fixed := range giteaFixed {
for _, host := range []string{giteaHost, otherHost} {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
t.Parallel()
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
// The hostname is matched without the port and without regard to case.
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
}
}
}
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
t.Parallel()
for _, apps := range []map[string][]string{
{giteaHost: {"gitea"}},
// SWW_APPS set empty lists no hostname.
{},
} {
crs := servingApps(t, apps)
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
for _, fixed := range giteaFixed {
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
}
}
}
}
// withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) {
return waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
})
}
// exclusionsFile writes text to a file of exclusions and returns its path.
func exclusionsFile(t *testing.T, text string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "exclusions.conf")
err := os.WriteFile(path, []byte(text), 0o600)
if err != nil {
t.Fatal(err)
}
return path
}
// backupFileExclusion is an exclusions file that switches 920500, a rule
// of phase 1, off for the path of a backup file under /files/.
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
t.Parallel()
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
}
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
})
if err != nil {
t.Fatalf("load the exclusions: %v", err)
}
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
}
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
t.Parallel()
_, err := withExclusions(exclusionsFile(t,
"# 942100: GET /issues/900123\n"+backupFileExclusion))
if err != nil {
t.Errorf("the exclusions did not load: %v", err)
}
}
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "missing.conf")
_, err := withExclusions(missing)
if err == nil {
t.Errorf("%s loaded", missing)
}
for _, tc := range []struct{ text, want string }{
{"SecNoSuchDirective On\n", "unknown directive"},
// sww's own rules may not be switched off or changed, by
// id or in a range, on one line or split over several.
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
"names 899999-901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
"names 899999-+901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
"names 900500",
},
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
" ctl:ruleRemoveById=9005\\\n 00\"\n",
"names 900500",
},
// The Core Rule Set's rules load after the file.
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
{
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
"SecRuleUpdateTargetById in the exclusions file",
},
} {
_, err := withExclusions(exclusionsFile(t, tc.text))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
}
}
}
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
"1'%20OR%20'1'='1",
"..%2Fcustom%2Fconf%2Fapp.ini",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
target := "/owner/repo?" + parameter + "=" + attack
if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
}
}
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10 const bodyLimit = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads. // The Content-Type headers of the kinds of body the Core Rule Set reads.
+15 -15
View File
@@ -16,10 +16,10 @@
# - that a new discriminator container on the same volume still refuses the banned # - that a new discriminator container on the same volume still refuses the banned
# client; # client;
# - that a discriminator container with its state files on a new volume mounted at # - that a discriminator container with its state files on a new volume mounted at
# the directory SWWAF_STATE_DIR_FILE names becomes healthy; # the directory SWW_STATE_DIR_FILE names becomes healthy;
# - that one whose SWWAF_STATE_DIR does not exist logs discriminator's message # - that one whose SWW_STATE_DIR does not exist logs discriminator's message
# about it. # about it.
# discriminator runs with SWWAF_LOOKUP_SOURCE=off, so that no address is sent to # discriminator runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to
# GeoJS. The containers, the network, the volumes and both images are removed # GeoJS. The containers, the network, the volumes and both images are removed
# however the script ends. Building the app needs network access, for nixpkgs' # however the script ends. Building the app needs network access, for nixpkgs'
# binary cache. script/check does not run this. # binary cache. script/check does not run this.
@@ -37,7 +37,7 @@ NETWORK="$NAME"
APP="$NAME-app-container" APP="$NAME-app-container"
DISCRIMINATOR="$NAME-discriminator" DISCRIMINATOR="$NAME-discriminator"
VOLUME="$NAME-state" VOLUME="$NAME-state"
# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE # The volumes of the container whose state directory SWW_STATE_DIR_FILE
# names: its state files, and the file that names their directory. # names: its state files, and the file that names their directory.
MOVED_VOLUME="$NAME-moved-state" MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings" SETTINGS_VOLUME="$NAME-settings"
@@ -94,8 +94,8 @@ start_discriminator() {
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \ docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
--volume "$VOLUME:/var/lib/sww" \ --volume "$VOLUME:/var/lib/sww" \
--env SWW_COMMAND=discriminator \ --env SWW_COMMAND=discriminator \
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \ --env SWW_RATE_LIMIT_PER_MINUTE=1 \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
} }
@@ -111,7 +111,7 @@ start_app() {
} }
# start_moved_discriminator: run discriminator with the state files on a new volume at # start_moved_discriminator: run discriminator with the state files on a new volume at
# /srv/state, which SWWAF_STATE_DIR_FILE names in a file only the sww # /srv/state, which SWW_STATE_DIR_FILE names in a file only the sww
# user can read, as under "Settings given as files" in docs/settings.md, and # user can read, as under "Settings given as files" in docs/settings.md, and
# wait until it is healthy. discriminator can write the new volume only once the run # wait until it is healthy. discriminator can write the new volume only once the run
# script has given it to that user. # script has given it to that user.
@@ -126,8 +126,8 @@ start_moved_discriminator() {
--volume "$MOVED_VOLUME:/srv/state" \ --volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \ --volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
--env SWW_COMMAND=discriminator \ --env SWW_COMMAND=discriminator \
--env SWWAF_STATE_DIR_FILE=/etc/sww/settings/state-dir \ --env SWW_STATE_DIR_FILE=/etc/sww/settings/state-dir \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR" wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
} }
@@ -140,7 +140,7 @@ status_from() {
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true --max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true
} }
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's # refused: a request to the container gets 403, SWW_BAN_RESPONSE's
# default. # default.
refused() { refused() {
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
@@ -164,7 +164,7 @@ main() {
trap 'exit 1' HUP INT TERM trap 'exit 1' HUP INT TERM
docker build --no-cache -t "$IMAGE" . docker build --no-cache -t "$IMAGE" .
docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \ docker build --no-cache --build-arg SWW_IMAGE="$IMAGE" \
-t "$APP_IMAGE" deploy/example-app -t "$APP_IMAGE" deploy/example-app
docker network create "$NETWORK" >/dev/null docker network create "$NETWORK" >/dev/null
@@ -238,16 +238,16 @@ main() {
docker rm --force "$DISCRIMINATOR" >/dev/null docker rm --force "$DISCRIMINATOR" >/dev/null
start_moved_discriminator start_moved_discriminator
echo "example-app: the run script gives the state files' directory named" \ echo "example-app: the run script gives the state files' directory named" \
"by SWWAF_STATE_DIR_FILE to the sww user" "by SWW_STATE_DIR_FILE to the sww user"
docker rm --force "$DISCRIMINATOR" >/dev/null docker rm --force "$DISCRIMINATOR" >/dev/null
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \ docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
--env SWW_COMMAND=discriminator \ --env SWW_COMMAND=discriminator \
--env SWWAF_STATE_DIR=/srv/missing \ --env SWW_STATE_DIR=/srv/missing \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWW_LOOKUP_SOURCE=off \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "discriminator did not report the missing state directory" \ wait_for "discriminator did not report the missing state directory" \
logged "$DISCRIMINATOR" "SWWAF_STATE_DIR cannot be written" logged "$DISCRIMINATOR" "SWW_STATE_DIR cannot be written"
echo "example-app: discriminator reports a state directory that does not exist" echo "example-app: discriminator reports a state directory that does not exist"
} }
+13 -13
View File
@@ -3,10 +3,10 @@
# rproxy on the host, with the settings in the environment, until rproxy # rproxy on the host, with the settings in the environment, until rproxy
# exits or the script is told to stop. rproxy listens on port 8080 unless # exits or the script is told to stop. rproxy listens on port 8080 unless
# SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL. # SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL.
# discriminator listens on port 8090 unless SWWAF_LISTEN_ADDR says otherwise, and # discriminator listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and
# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless # rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless
# SWWAF_STATE_DIR is set, the state files go in bin/state, beside the binary, # SWW_STATE_DIR is set, the state files go in bin/state, beside the binary,
# and unless SWWAF_RULES_DIR is set, the rule files are those of share/rules.d, # and unless SWW_RULES_DIR is set, the rule files are those of share/rules.d,
# which the image ships. # which the image ships.
set -eu set -eu
@@ -26,18 +26,18 @@ stop_both() {
main() { main() {
"$SCRIPT_DIR/build" "$SCRIPT_DIR/build"
if [ -z "${SWWAF_STATE_DIR+set}" ]; then if [ -z "${SWW_STATE_DIR+set}" ]; then
SWWAF_STATE_DIR="$ROOT/bin/state" SWW_STATE_DIR="$ROOT/bin/state"
export SWWAF_STATE_DIR export SWW_STATE_DIR
mkdir -p "$SWWAF_STATE_DIR" mkdir -p "$SWW_STATE_DIR"
fi fi
if [ -z "${SWWAF_RULES_DIR+set}" ]; then if [ -z "${SWW_RULES_DIR+set}" ]; then
SWWAF_RULES_DIR="$ROOT/share/rules.d" SWW_RULES_DIR="$ROOT/share/rules.d"
export SWWAF_RULES_DIR export SWW_RULES_DIR
fi fi
SWWAF_LISTEN_ADDR="${SWWAF_LISTEN_ADDR:-:8090}" SWW_LISTEN_ADDR="${SWW_LISTEN_ADDR:-:8090}"
SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWWAF_LISTEN_ADDR##*:}}" SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWW_LISTEN_ADDR##*:}}"
export SWWAF_LISTEN_ADDR SWRP_WAF_BACKEND export SWW_LISTEN_ADDR SWRP_WAF_BACKEND
trap stop_both EXIT trap stop_both EXIT
trap 'exit 1' HUP INT TERM trap 'exit 1' HUP INT TERM
+7 -7
View File
@@ -24,7 +24,7 @@ main() {
# give_state_dir_to_sww gives the state directory and every file in # give_state_dir_to_sww gives the state directory and every file in
# it to the sww user, so that a volume mounted there needs no change # it to the sww user, so that a volume mounted there needs no change
# of owner; chown -R changes a symbolic link itself, never what it points # of owner; chown -R changes a symbolic link itself, never what it points
# to. When SWWAF_STATE_DIR, in either form, cannot be read or is not the # to. When SWW_STATE_DIR, in either form, cannot be read or is not the
# absolute path of a directory, no owner is changed, and discriminator starts and # absolute path of a directory, no owner is changed, and discriminator starts and
# stops with a message naming the setting. A change of owner that fails, as # stops with a message naming the setting. A change of owner that fails, as
# on a volume mounted read-only, does not end the script either: discriminator # on a volume mounted read-only, does not end the script either: discriminator
@@ -36,14 +36,14 @@ give_state_dir_to_sww() {
fi fi
} }
# state_dir prints SWWAF_STATE_DIR, /var/lib/sww when it is unset, # state_dir prints SWW_STATE_DIR, /var/lib/sww when it is unset,
# or what is in the file SWWAF_STATE_DIR_FILE names, less every newline at # or what is in the file SWW_STATE_DIR_FILE names, less every newline at
# its end. It fails when both are set or the file cannot be read. # its end. It fails when both are set or the file cannot be read.
state_dir() { state_dir() {
if [[ ! -v SWWAF_STATE_DIR_FILE ]]; then if [[ ! -v SWW_STATE_DIR_FILE ]]; then
printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/sww}" printf '%s\n' "${SWW_STATE_DIR-/var/lib/sww}"
elif [[ ! -v SWWAF_STATE_DIR ]]; then elif [[ ! -v SWW_STATE_DIR ]]; then
cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null cat -- "$SWW_STATE_DIR_FILE" 2>/dev/null
else else
return 1 return 1
fi fi