Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
365e1a3e01 |
No files matched your search
+2
-2
@@ -175,12 +175,12 @@ RUN groupadd --system --gid 65532 sww \
|
|||||||
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||||
sww
|
sww
|
||||||
|
|
||||||
# The state files' directory, SWW_WAF_STATE_DIR by default, where a volume
|
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||||
# is mounted to keep them across deploys. The run script gives it to the
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# sww user at each start of discriminator.
|
# sww user at each start of discriminator.
|
||||||
RUN mkdir /var/lib/sww
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||||
|
|
||||||
|
|||||||
@@ -90,9 +90,8 @@ the app accepts connections, whether or not `discriminator` answers; in
|
|||||||
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:
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||||
`SWW_RPROXY_WAF_BACKEND` names `discriminator` and `SWW_RPROXY_UPSTREAM_URL`
|
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||||
names the app;
|
|
||||||
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||||
`/var/lib/sww` for its state files.
|
`/var/lib/sww` for its state files.
|
||||||
|
|
||||||
@@ -101,8 +100,8 @@ Point traefik's labels at port 8080 of `rproxy`
|
|||||||
of the app or of `discriminator`. Anything on that network can reach the app
|
of the app or of `discriminator`. Anything on that network can reach the app
|
||||||
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||||
only what you trust. Each further app adds its own container and its own
|
only what you trust. Each further app adds its own container and its own
|
||||||
`rproxy`, whose `SWW_RPROXY_WAF_BACKEND` names the same `discriminator`. The
|
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||||
"Smallest setup" example below shows this shape.
|
setup" example below shows this shape.
|
||||||
|
|
||||||
### `rproxy` in the app's container
|
### `rproxy` in the app's container
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ by 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 `SWW_RPROXY_UPSTREAM_URL`.
|
of `SWRP_UPSTREAM_URL`.
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# The smallwebwaf image, pinned by digest.
|
# The smallwebwaf image, pinned by digest.
|
||||||
@@ -151,8 +150,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 `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
|
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
|
||||||
an app, and the "`rproxy` beside the app" example below runs one.
|
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
|
||||||
@@ -172,12 +171,12 @@ does.
|
|||||||
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||||
the request have `"action":"forward"`.
|
the request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `discriminator` with
|
- To see a refusal without banning yourself, run `discriminator` with
|
||||||
`SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||||
`discriminator`'s log lines have `"would_action":"banned"` and
|
`discriminator`'s log lines have `"would_action":"banned"` and
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||||
"Clients and bans" below describes.
|
"Clients and bans" below describes.
|
||||||
- With `SWW_WAF_METRICS_TOKEN` set, this answers with the metrics, from a host
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
on `discriminator`'s network:
|
`discriminator`'s network:
|
||||||
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
||||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||||
the whole exchange (see "Entrypoints").
|
the whole exchange (see "Entrypoints").
|
||||||
@@ -185,18 +184,18 @@ 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 `SWW_RPROXY_` settings, and
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||||
`discriminator` only the `SWW_WAF_` ones. Every setting but
|
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||||
`SWW_RPROXY_WAF_BACKEND` has a default, chosen for a service on the open
|
default, chosen for a service on the open internet. A setting that needs an
|
||||||
internet. A setting that needs an account, a key, a token or an alert
|
account, a key, a token or an alert destination stays off until you give one.
|
||||||
destination stays off until you give one.
|
|
||||||
|
|
||||||
- Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for
|
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||||
secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds
|
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||||
`SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at
|
contents are the value, less one newline at the end. Setting both forms stops
|
||||||
the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE`
|
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||||
and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have
|
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||||
no `_FILE` form. `SWW_COMMAND` cannot be given as a file.
|
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||||
|
one, since `rproxy` never touches disk.
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||||
1024: `512K`, `100M`, `5G`.
|
1024: `512K`, `100M`, `5G`.
|
||||||
@@ -217,121 +216,122 @@ destination stays off until you give one.
|
|||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| -------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||||
| `SWW_RPROXY_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||||
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `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_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
| `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_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWW_RPROXY_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||||
| `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`. |
|
| `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_WAF_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||||
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
||||||
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `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_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||||
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||||
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||||
| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||||
| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||||
| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||||
| `SWW_WAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||||
| `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||||
| `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `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_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
||||||
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `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_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_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_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
||||||
| `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_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_WAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||||
| `SWW_WAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||||
| `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_WAF_ASN_LIMIT_PERCENT`. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
|
||||||
| `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_WAF_COUNTRY_LIMIT_PERCENT`. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
|
||||||
| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_WAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
||||||
| `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_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_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_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_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;cloud.example.com:gitea`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
|
||||||
| `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `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_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `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_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `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_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `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_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `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_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `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_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_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `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_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `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_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||||
| `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||||
| `SWW_WAF_LOG_REMOTE_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_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||||
| `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `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_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `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_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `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_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `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_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `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_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `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_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `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_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `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_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `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_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
||||||
| `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_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_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
||||||
| `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_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_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `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_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||||
| `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_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||||
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -358,8 +358,8 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWW_RPROXY_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -394,8 +394,8 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWW_RPROXY_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -404,23 +404,23 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: discriminator
|
SWW_COMMAND: discriminator
|
||||||
# Monitoring skips every check.
|
# Monitoring skips every check.
|
||||||
SWW_WAF_ALLOW_NETS: 192.0.2.10
|
SWW_ALLOW_NETS: 192.0.2.10
|
||||||
# The office and the automation host are never rate limited.
|
# The office and the automation host are never rate limited.
|
||||||
SWW_WAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||||
# A netblock that sends nothing wanted.
|
# A netblock that sends nothing wanted.
|
||||||
SWW_WAF_DENY_NETS: 198.51.100.128/25
|
SWW_DENY_NETS: 198.51.100.128/25
|
||||||
# Tighter limits for everyone else: a small static site.
|
# Tighter limits for everyone else: a small static site.
|
||||||
SWW_WAF_RATE_LIMIT_PER_MINUTE: "60"
|
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||||
SWW_WAF_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_WAF_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_WAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||||
SWW_WAF_COUNTRY_LIMIT_PERCENT: cn:25
|
SWW_COUNTRY_LIMIT_PERCENT: cn:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_WAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
SWW_WAF_BAN_RESPONSE: "429"
|
SWW_BAN_RESPONSE: "429"
|
||||||
SWW_WAF_LIMIT_BAN_DURATION: 30m
|
SWW_LIMIT_BAN_DURATION: 30m
|
||||||
volumes:
|
volumes:
|
||||||
- discriminator-state:/var/lib/sww
|
- discriminator-state:/var/lib/sww
|
||||||
|
|
||||||
@@ -446,9 +446,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:
|
||||||
SWW_RPROXY_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
# Answer 503 rather than pass a request on while discriminator is down.
|
# Answer 503 rather than pass a request on while discriminator is down.
|
||||||
SWW_RPROXY_DEFAULT_DISPOSITION: deny
|
SWRP_DEFAULT_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
@@ -456,16 +456,16 @@ services:
|
|||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: discriminator
|
SWW_COMMAND: discriminator
|
||||||
SWW_WAF_INSTANCE_NAME: fsn1/discriminator
|
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||||
# Refuse nothing yet; log what enforce mode would have done.
|
# Refuse nothing yet; log what enforce mode would have done.
|
||||||
SWW_WAF_MODE: observe
|
SWW_MODE: observe
|
||||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||||
SWW_WAF_LOOKUP_SOURCE: file
|
SWW_LOOKUP_SOURCE: file
|
||||||
SWW_WAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||||
# Alerts to an ntfy topic, with its token read from a file.
|
# Alerts to an ntfy topic, with its token read from a file.
|
||||||
SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||||
SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||||
SWW_WAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
||||||
volumes:
|
volumes:
|
||||||
- discriminator-state:/var/lib/sww
|
- discriminator-state:/var/lib/sww
|
||||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||||
@@ -502,12 +502,12 @@ of the 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 `SWW_RPROXY_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||||
as the visitor sent it, with the first 64 KiB of its body.
|
the visitor sent it, with the first 64 KiB of its body.
|
||||||
|
|
||||||
`discriminator` takes the checks:
|
`discriminator` takes the checks:
|
||||||
|
|
||||||
3. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`.
|
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
4. The bans.
|
4. The bans.
|
||||||
5. The lookup of the client's AS number and country.
|
5. The lookup of the client's AS number and country.
|
||||||
6. The country lists.
|
6. The country lists.
|
||||||
@@ -528,20 +528,20 @@ of the request reaches the app.
|
|||||||
and the bytes each way. For an allowed request, `discriminator` counts the
|
and the bytes each way. For an allowed request, `discriminator` counts the
|
||||||
bytes toward the byte limits and adds the request to the client's history.
|
bytes toward the byte limits and adds the request to the client's history.
|
||||||
|
|
||||||
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
request over the header limits of step 12 is answered `431`, and bans nobody
|
||||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
and the status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
### Clients and bans
|
### Clients and bans
|
||||||
|
|
||||||
- A client is one IPv4 address or one IPv6 netblock of
|
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||||
`SWW_WAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
since one abuser usually controls a whole /64.
|
||||||
- Each limit is counted over a sliding window with a few integers per client.
|
- Each limit is counted over a sliding window with a few integers per client.
|
||||||
The table of clients and the table of bans each have a bound
|
The table of clients and the table of bans each have a bound
|
||||||
(`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_MAX_BANS`), so memory is bounded too.
|
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||||
lifts it.
|
lifts it.
|
||||||
@@ -553,16 +553,20 @@ and the status for a size or time limit, such as `413` for a body too large.
|
|||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
carry what people write and publish, which the rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
Git Credential Manager. On gitea's own paths, its searches, file names and
|
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||||
branch names get through too. No setting undoes the changes; "Attack
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
detection" in [`SPEC.md`](SPEC.md) lists them.
|
- Each supported app also has its own fixes, which let its ordinary requests
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
|
through. gitea's let its searches, file names and branch names through.
|
||||||
own fields or paths.
|
`SWW_APPS` names the apps each hostname serves. A hostname gets only its own
|
||||||
|
apps' fixes, and a hostname it does not list gets none. Unset, every app's
|
||||||
|
fixes apply to every hostname.
|
||||||
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
while `discriminator` runs. The image ships one file of probes no real visitor
|
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||||
@@ -570,7 +574,7 @@ and the status for a size or time limit, such as `413` for a body too large.
|
|||||||
### State files
|
### State files
|
||||||
|
|
||||||
`discriminator` keeps everything in memory, and the five JSON files in
|
`discriminator` keeps everything in memory, and the five JSON files in
|
||||||
`SWW_WAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||||
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
||||||
notices a file edited on disk and takes the edit in; an edit that does not parse
|
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ The endpoints under `/_sww/`, what each takes and what each answers.
|
|||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_sww/metrics`: the metrics, for `SWW_WAF_METRICS_TOKEN` (see
|
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||||
Needs `SWW_WAF_ADMIN_TOKEN`, as do the three below.
|
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||||
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||||
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration
|
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
what is wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- 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,8 +38,8 @@ in [`SPEC.md`](../SPEC.md)).
|
|||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator`
|
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||||
reached as `discriminator:8080` on its network:
|
as `discriminator:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Every ban.
|
# Every ban.
|
||||||
|
|||||||
+4
-4
@@ -77,8 +77,8 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised.
|
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
`event`: one of the events above.
|
one of the events above.
|
||||||
- `host`: the site of the request that raised the alert, since one
|
- `host`: the site of the request that raised the alert, since one
|
||||||
`discriminator` serves many sites. It is left out of `source_failure`,
|
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||||
`file_error` and `summary`.
|
`file_error` and `summary`.
|
||||||
@@ -122,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while
|
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||||
`SWW_WAF_ALERT_NTFY_TOKEN` is set;
|
is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ keeps the `instance` label as `exported_instance` unless the scrape sets
|
|||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
|
|||||||
+8
-8
@@ -44,7 +44,7 @@ Every one of these lines has:
|
|||||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||||
line.
|
line.
|
||||||
- `instance`: `SWW_WAF_INSTANCE_NAME`.
|
- `instance`: `SWW_INSTANCE_NAME`.
|
||||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||||
- `method`, `path` and `query`: the visitor's request.
|
- `method`, `path` and `query`: the visitor's request.
|
||||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||||
@@ -82,8 +82,8 @@ Every one of these lines has:
|
|||||||
- GeoJS had not answered by the time `discriminator` answered.
|
- GeoJS had not answered by the time `discriminator` answered.
|
||||||
- `content_type`: the visitor's `Content-Type`.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `content_length`: the body length the visitor announced.
|
- `content_length`: the body length the visitor announced.
|
||||||
- `request_headers`: the headers listed in `SWW_WAF_LOG_REQUEST_HEADERS`, by
|
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||||
name in lower case.
|
lower case.
|
||||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ Every one of these lines has:
|
|||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `denied`: refused for `SWW_WAF_DENY_NETS`, or for a reputation source with
|
- `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
|
||||||
the action `deny`;
|
action `deny`;
|
||||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
CrowdSec decision, each of which bans the client;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
@@ -191,13 +191,13 @@ these meanings where they differ:
|
|||||||
sooner. It includes the wait for `discriminator`.
|
sooner. It includes the wait for `discriminator`.
|
||||||
- `duration_response`: milliseconds from then until the end of the answer.
|
- `duration_response`: 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 `discriminator`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`.
|
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||||
It is left out when:
|
left out when:
|
||||||
- `discriminator` was not asked, as for the health check;
|
- `discriminator` was not asked, as for the health check;
|
||||||
- the visitor went away while `discriminator` was being asked;
|
- the visitor went away while `discriminator` was being asked;
|
||||||
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
`disposition`, and otherwise left out.
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|||||||
+230
-218
@@ -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_WAF_` are `discriminator`'s. Those that start
|
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||||
with `SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
|
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||||
chooses between the two (see "The image's command" below).
|
between the two (see "The image's command" below).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
@@ -28,256 +28,269 @@ chooses between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:`
|
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||||
and a port, such as `:9000`. Leave the host out (see "Ports" in
|
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||||
name in each line `discriminator` logs, and in each alert and metric, such as
|
in each line `discriminator` logs, and in each alert and metric, such as
|
||||||
`fsn1/discriminator`. Valid UTF-8. No control character while
|
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||||
`SWW_WAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||||
space while `SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME`
|
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||||
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 `discriminator`
|
||||||
- `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
|
||||||
keeps, a whole number above zero.
|
keeps, a whole number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWW_WAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_sww/clients/<ip>`. At least 32 characters.
|
`/_sww/clients/<ip>`. At least 32 characters.
|
||||||
- `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At
|
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||||
least 32 characters.
|
32 characters.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
|
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||||
files, an absolute path that exists and can be written.
|
absolute path that exists and can be written.
|
||||||
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file
|
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
is written. A duration.
|
written. A duration.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s
|
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||||
own messages that are written: `debug`, `info`, `warn` or `error`.
|
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWW_WAF_LOG_REQUEST_HEADERS` (default
|
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWW_WAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||||
own. A file that cannot be read, or holds no certificate, is refused.
|
A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||||
wait to be sent, a whole number above zero.
|
to be sent, a whole number above zero.
|
||||||
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name
|
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||||
in each syslog record, 1 to 48 printable ASCII characters without a space.
|
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||||
countries get series of their own. A whole number above zero.
|
get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
(see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
3 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
a client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two pages at once with an empty cache, up to 60 files each.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||||
limit.
|
limit.
|
||||||
- `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
||||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
in a day. A count or `off`. The default is four hours at the hour limit. A
|
||||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||||
limit.
|
limit.
|
||||||
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||||
the runners' addresses in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||||
network where many people share one address, such as an office.
|
network where many people share one address, such as an office.
|
||||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
|
counted for a client in each window. A size or `off`.
|
||||||
client in each window. A size or `off`.
|
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
- `SWW_WAF_BYTES_COUNT` (default `both`): which body bytes the byte limits
|
`response`, `request` or `both`.
|
||||||
count: `response`, `request` or `both`.
|
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
stops the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- the two country lists;
|
- the two country lists;
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- the four biased threshold lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
|
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||||
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
|
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
app the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country lists
|
## Country lists
|
||||||
|
|
||||||
- `SWW_WAF_DENIED_COUNTRIES` (default empty): countries whose clients are
|
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||||
refused, such as `cn,ru,kp`.
|
such as `cn,ru,kp`.
|
||||||
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
countries whose clients get through, such as `us,de`. A country on both lists
|
countries whose clients get through, such as `us,de`. A country on both lists
|
||||||
stops the start.
|
stops the start.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_WAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with
|
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||||
`:` and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||||
listed twice is refused.
|
listed twice is refused.
|
||||||
- `SWW_WAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||||
`cn:25,ru:50`.
|
`cn:25,ru:50`.
|
||||||
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
|
||||||
empty): the same, for the byte limits alone.
|
same, for the byte limits alone.
|
||||||
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
the lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
a text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is refused.
|
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
|
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||||
files.
|
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
|
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
paranoia level, from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
|
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
request is a match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
|
- `SWW_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
|
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
Rule Set does not inspect.
|
not inspect.
|
||||||
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
|
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||||
request body the Core Rule Set reads, a size of at most `64K`. A question
|
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||||
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
|
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||||
KiB less one byte of it, whatever the value (see "The answer" in
|
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||||
|
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||||
|
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||||
|
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||||
|
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||||
|
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||||
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
|
yet. It has no `_FILE` form.
|
||||||
|
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||||
|
fixes of its hostname's apps only (see "Attack detection" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
|
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||||
Set exclusions, written as Coraza directives. It is loaded before the Core
|
or more apps separated by commas, for example
|
||||||
Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that
|
- `gitea` is the only app supported.
|
||||||
cannot be read or does not parse stops the start, whatever
|
- The request's `Host` is matched by exact name, without its port and
|
||||||
`SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from
|
without regard to case. There are no wildcards.
|
||||||
900000 to 900999 outside a comment, alone or in a range. So does a directive
|
- A hostname not listed gets no app's fixes, so every rule checks it in
|
||||||
that removes or changes rules as the file loads, such as `SecRuleRemoveById`,
|
full. Set empty, it lists no hostname.
|
||||||
since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form.
|
- Unset, every app's fixes apply to every hostname.
|
||||||
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one
|
- Any of these mistakes stops the start:
|
||||||
client in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
||||||
|
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
|
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
A duration.
|
duration.
|
||||||
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer is permanent. A duration.
|
||||||
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
of attack. A duration.
|
attack. A duration.
|
||||||
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWW_WAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||||
last try before it is fetched again. A duration of `1h` or more.
|
try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or
|
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
`log`.
|
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
- `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_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones
|
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
optional port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence
|
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
score, from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a
|
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||||
day, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec
|
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
without a user or a fragment; its decision list is at `v1/decisions` under it.
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
Set without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`.
|
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||||
check with AbuseIPDB may take. A duration.
|
with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||||
value. A value cannot hold a comma.
|
value cannot hold a comma.
|
||||||
- `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||||
that receives each alert as a message, checked as the webhook URL is.
|
receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||||
as the webhook URL is.
|
webhook URL is.
|
||||||
- `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWW_WAF_ALERT_EVENTS` (default
|
- `SWW_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWW_WAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
|
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
A whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
@@ -285,25 +298,25 @@ Each scope has four thresholds: the most requests and the most bytes counted in
|
|||||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||||
default. The four thresholds per client are:
|
default. The four thresholds per client are:
|
||||||
|
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
|
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
|
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||||
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -324,7 +337,7 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||||
-e SWW_COMMAND=discriminator \
|
-e SWW_COMMAND=discriminator \
|
||||||
-e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -348,52 +361,51 @@ A few limits are fixed rather than settings:
|
|||||||
|
|
||||||
## `discriminator`'s answer and `rproxy`'s wait
|
## `discriminator`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||||
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
|
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||||
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
|
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||||
AbuseIPDB are asked in the background (see "Answering in time" under
|
in the background (see "Answering in time" under "`discriminator`" in
|
||||||
"`discriminator`" 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. Any can be given as a file, as a
|
start, and an invalid value stops the start. None can be given as a file, since
|
||||||
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
|
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||||
|
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`.
|
||||||
|
|
||||||
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `discriminator`'s URL. An
|
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
||||||
`http` or `https` URL with a host and an optional port, and nothing more.
|
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||||
`rproxy` speaks HTTP/1.1 to it.
|
speaks HTTP/1.1 to it.
|
||||||
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||||
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
||||||
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
|
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||||
request when `discriminator` cannot be connected to, does not answer at all,
|
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||||
or does not answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the
|
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||||
app, and `deny` answers `503`. It does not apply to an answer `discriminator`
|
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
||||||
gives, such as an error.
|
an error.
|
||||||
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
size or `off`.
|
`off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
size or `off`.
|
`off`.
|
||||||
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
|
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
|
||||||
take to send its request line and headers. It then also bounds, from the end
|
send its request line and headers. It then also bounds, from the end of the
|
||||||
of the headers, the time until the app has been sent the whole request. A time
|
headers, the time until the app has been sent the whole request. A time or
|
||||||
or `off`.
|
`off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
|
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||||
take, from the end of the request to its last byte reaching the visitor. A
|
from the end of the request to its last byte reaching the visitor. A time or
|
||||||
time or `off`.
|
`off`.
|
||||||
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||||
`:9000`.
|
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||||
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
`https` URL with a host and an optional port, and nothing more.
|
||||||
`http` or `https` URL with a host and an optional port, and nothing more.
|
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
|
||||||
- `SWW_RPROXY_TRUSTED_PROXIES` (default
|
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
|
||||||
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
|
nothing. A client outside these netblocks cannot claim another address. Where
|
||||||
`X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
|
other containers reach `rproxy` directly, or visitors on a private network
|
||||||
outside these netblocks cannot claim another address. Where other containers
|
reach traefik, they can name another address in the header. Narrowing the
|
||||||
reach `rproxy` directly, or visitors on a private network reach traefik, they
|
setting to traefik's own network closes that gap.
|
||||||
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
|
||||||
|
|
||||||
@@ -405,14 +417,14 @@ check checks: `rproxy` or `discriminator` (see "Deployment" in
|
|||||||
|
|
||||||
Nothing reads these names any more:
|
Nothing reads these names any more:
|
||||||
|
|
||||||
- `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`.
|
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||||
- `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`.
|
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||||
- `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`.
|
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||||
- `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`.
|
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||||
- `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are
|
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||||
`SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds.
|
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||||
- `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||||
`SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||||
- `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES`
|
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||||
have no setting. `rproxy` closes a kept-open connection idle for 120 seconds,
|
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||||
and takes a request line and headers of at most 32 KiB.
|
takes a request line and headers of at most 32 KiB.
|
||||||
+8
-8
@@ -1,13 +1,13 @@
|
|||||||
# State files
|
# State files
|
||||||
|
|
||||||
The five JSON files in `SWW_WAF_STATE_DIR`, and what each holds. "Persistent
|
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||||
Each has a top-level `version` of 1.
|
a top-level `version` of 1.
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of
|
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||||
those `discriminator` made. Per entry:
|
`discriminator` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -93,10 +93,10 @@ GeoJS answers, one per line:
|
|||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB
|
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
of disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWW_WAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ 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.3 // indirect
|
github.com/corazawaf/libinjection-go v0.3.3 // 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
|
||||||
@@ -27,22 +25,14 @@ 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.58.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
|
|||||||
@@ -12,12 +12,8 @@ github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBO
|
|||||||
github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
github.com/corazawaf/libinjection-go v0.3.3/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,10 +34,6 @@ 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=
|
||||||
@@ -66,24 +58,8 @@ 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=
|
||||||
|
|||||||
+30
-30
@@ -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_WAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// object" in docs/alerts.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWW_WAF_ALERT_EVENTS names them.
|
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban sww made.
|
// EventBan is a ban sww made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
@@ -45,7 +45,7 @@ const (
|
|||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventWAFBlock = "waf_block"
|
EventWAFBlock = "waf_block"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
@@ -61,14 +61,14 @@ const (
|
|||||||
// cannot be read, or a state file that cannot be written.
|
// cannot be read, or a state file that cannot be written.
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWW_WAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWW_WAF_ALERT_EVENTS does not name it.
|
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its
|
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -80,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWW_WAF_ALERT_SLACK_WEBHOOK_URL names.
|
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWW_WAF_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWW_WAF_INSTANCE_NAME, which every alert gives.
|
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWW_WAF_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWW_WAF_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -606,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWW_WAF_ALERT_NTFY_URL": onlyNtfy,
|
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWW_WAF_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWW_WAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to 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("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWW_WAF_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
|
|||||||
+12
-12
@@ -28,16 +28,16 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWW_WAF_IPV6_GROUP_PREFIX.
|
// SWW_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWW_WAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatch is a named netblock of SWW_WAF_WATCH_NETS.
|
// ScopeWatch is a named netblock of SWW_WATCH_NETS.
|
||||||
ScopeWatch = "watch"
|
ScopeWatch = "watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||||
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
|
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||||
// SWW_WAF_WATCH_*.
|
// SWW_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
|
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWW_WAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -107,13 +107,13 @@ type Request struct {
|
|||||||
Host string
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWW_WAF_IPV6_GROUP_PREFIX its address is in.
|
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_WAF_TRAP_PATHS, that path as the
|
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -199,7 +199,7 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWW_WAF_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
|
|||||||
+342
-274
File diff suppressed because it is too large.
Load diff
+201
-141
@@ -17,107 +17,106 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The settings, by name.
|
// The settings, by name.
|
||||||
const (
|
const (
|
||||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
mode = "SWW_WAF_MODE"
|
mode = "SWW_MODE"
|
||||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWW_WAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWW_WAF_DENIED_COUNTRIES"
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
asnLimitPercent = "SWW_WAF_ASN_LIMIT_PERCENT"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWW_WAF_COUNTRY_LIMIT_PERCENT"
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH"
|
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||||
blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE"
|
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||||
abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET"
|
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||||
reputationAction = "SWW_WAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||||
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
maxBans = "SWW_WAF_MAX_BANS"
|
maxBans = "SWW_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
stateDir = "SWW_WAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWW_WAF_METRICS_TOP_N"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
logLevel = "SWW_WAF_LOG_LEVEL"
|
logLevel = "SWW_LOG_LEVEL"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
rulesEnabled = "SWW_WAF_RULES_ENABLED"
|
rulesEnabled = "SWW_RULES_ENABLED"
|
||||||
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
wafMode = "SWW_WAF_MODE"
|
||||||
wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL"
|
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
trapPaths = "SWW_WAF_TRAP_PATHS"
|
apps = "SWW_APPS"
|
||||||
errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
logRemoteURL = "SWW_WAF_LOG_REMOTE_URL"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER"
|
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY"
|
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||||
logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME"
|
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||||
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
|
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||||
alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL"
|
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||||
alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL"
|
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||||
alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||||
alertEvents = "SWW_WAF_ALERT_EVENTS"
|
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||||
alertCooldown = "SWW_WAF_ALERT_COOLDOWN"
|
alertEvents = "SWW_ALERT_EVENTS"
|
||||||
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
|
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
watchNets = "SWW_WAF_WATCH_NETS"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||||
// followed by each of the four ends.
|
// followed by each of the four ends.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
watch = "SWW_WAF_WATCH_"
|
watch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -158,21 +157,21 @@ func loggedAnomalyDefaults() map[string]string {
|
|||||||
return logged
|
return logged
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and
|
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||||
// defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN.
|
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||||
const (
|
const (
|
||||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||||
"source_failure,file_error"
|
"source_failure,file_error"
|
||||||
defaultAlertCooldown = "15m"
|
defaultAlertCooldown = "15m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES.
|
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
|
|
||||||
@@ -197,8 +196,8 @@ const (
|
|||||||
otherToken = "fedcba9876543210fedcba9876543210"
|
otherToken = "fedcba9876543210fedcba9876543210"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and
|
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||||
// remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines.
|
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||||
const (
|
const (
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||||
@@ -210,7 +209,7 @@ const off = "off"
|
|||||||
// enabled is true, as a setting's value.
|
// enabled is true, as a setting's value.
|
||||||
const enabled = "true"
|
const enabled = "true"
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and
|
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
defaultLookupSource = "geojs"
|
defaultLookupSource = "geojs"
|
||||||
@@ -220,22 +219,18 @@ const (
|
|||||||
// environment is a set of environment variables, for FromEnvironment.
|
// environment is a set of environment variables, for FromEnvironment.
|
||||||
type environment map[string]string
|
type environment map[string]string
|
||||||
|
|
||||||
// viper returns a viper Set with each of the variables, as the viper
|
// lookupEnv reads one of the variables, as os.LookupEnv does.
|
||||||
// NewViper returns one bound to a process's environment.
|
func (e environment) lookupEnv(name string) (string, bool) {
|
||||||
func (e environment) viper() *viper.Viper {
|
value, ok := e[name]
|
||||||
v := viper.New()
|
|
||||||
for name, value := range e {
|
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
return value, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromEnvironment reads the settings from env, which must be valid.
|
// fromEnvironment reads the settings from env, which must be valid.
|
||||||
func fromEnvironment(t *testing.T, env environment) *config.Config {
|
func fromEnvironment(t *testing.T, env environment) *config.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(env.viper())
|
cfg, err := config.FromEnvironment(env.lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("settings %v: %v", env, err)
|
t.Fatalf("settings %v: %v", env, err)
|
||||||
}
|
}
|
||||||
@@ -462,7 +457,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(
|
_, err := config.FromEnvironment(
|
||||||
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
|
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv)
|
||||||
|
|
||||||
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
|
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
|
||||||
`starting with /, such as /assets/`
|
`starting with /, such as /assets/`
|
||||||
@@ -517,7 +512,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -582,6 +577,70 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want map[string][]string
|
||||||
|
}{
|
||||||
|
{environment{}, nil},
|
||||||
|
{environment{apps: ""}, map[string][]string{}},
|
||||||
|
{
|
||||||
|
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||||
|
map[string][]string{
|
||||||
|
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
cfg := fromEnvironment(t, tc.env)
|
||||||
|
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||||
|
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
notEntry = " is not a hostname, : and a list of apps, " +
|
||||||
|
"such as git.example.com:gitea"
|
||||||
|
notHostname = " is not a DNS name such as git.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ value, want string }{
|
||||||
|
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||||
|
{"git.example.com:gitea;", `""` + notEntry},
|
||||||
|
{":gitea", `":gitea"` + notEntry},
|
||||||
|
{"git.example.com:", `"git.example.com:"` + notEntry},
|
||||||
|
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
||||||
|
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
||||||
|
{"git example.com:gitea", `"git example.com"` + notHostname},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea,nextcloud",
|
||||||
|
`"nextcloud" is not an app sww has fixes for; the apps are gitea`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea;GIT.example.com:gitea",
|
||||||
|
`"git.example.com" is listed twice`,
|
||||||
|
},
|
||||||
|
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||||
|
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
||||||
|
|
||||||
|
want := apps + ": " + tc.want
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -646,7 +705,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -771,7 +830,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
|
|||||||
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
|
||||||
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
||||||
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
|
||||||
}
|
}
|
||||||
@@ -788,7 +847,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
|
|||||||
t.Fatalf("write %s: %v", caFile, err)
|
t.Fatalf("write %s: %v", caFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
|
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -803,7 +862,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
|||||||
|
|
||||||
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
|
||||||
|
|
||||||
_, err := config.FromEnvironment(sending.viper())
|
_, err := config.FromEnvironment(sending.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||||
@@ -833,7 +892,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
|||||||
logRemoteURL: remoteURL,
|
logRemoteURL: remoteURL,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
logRemoteAppName: "my app",
|
logRemoteAppName: "my app",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as blog`
|
`characters without a space, such as blog`
|
||||||
@@ -964,7 +1023,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
|
|||||||
// An item that is not a header is named by its place, not shown.
|
// An item that is not a header is named by its place, not shown.
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
alertWebhookHeaders: "X-Team:ops," + secret,
|
alertWebhookHeaders: "X-Team:ops," + secret,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
||||||
"and the header's value, such as Authorization:Bearer <token>"
|
"and the header's value, such as Authorization:Bearer <token>"
|
||||||
@@ -1001,7 +1060,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|||||||
"ftp://hooks.example/services/" + secret,
|
"ftp://hooks.example/services/" + secret,
|
||||||
"https://hooks.example/services/%zz" + secret,
|
"https://hooks.example/services/%zz" + secret,
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||||
"a fragment, such as https://alerts.example/sww"
|
"a fragment, such as https://alerts.example/sww"
|
||||||
@@ -1052,7 +1111,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
|
|||||||
"set": {alertNtfyToken: token + "\r"},
|
"set": {alertNtfyToken: token + "\r"},
|
||||||
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(env.viper())
|
_, err := config.FromEnvironment(env.lookupEnv)
|
||||||
|
|
||||||
want := alertNtfyToken + ": holds a control character, such as the " +
|
want := alertNtfyToken + ": holds a control character, such as the " +
|
||||||
"carriage return of a Windows line end"
|
"carriage return of a Windows line end"
|
||||||
@@ -1069,7 +1128,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||||
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
||||||
@@ -1094,7 +1153,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|||||||
"set": {instanceName: latin1},
|
"set": {instanceName: latin1},
|
||||||
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(env.viper())
|
_, err := config.FromEnvironment(env.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
want := instanceName + `: "caf\xe9" is not valid UTF-8`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1109,7 +1168,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
deniedCountries: "cn,ru",
|
deniedCountries: "cn,ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ru on both country lists was accepted")
|
t.Fatal("ru on both country lists was accepted")
|
||||||
}
|
}
|
||||||
@@ -1171,7 +1230,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
|
|||||||
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
|
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
if err == nil || err.Error() != tc.want {
|
if err == nil || err.Error() != tc.want {
|
||||||
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
|
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -1201,7 +1260,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
lookupSource: off, name: value,
|
lookupSource: off, name: value,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
|
||||||
!strings.Contains(err.Error(), lookupSource+" is off") {
|
!strings.Contains(err.Error(), lookupSource+" is off") {
|
||||||
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
|
||||||
@@ -1210,7 +1269,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||||
// threshold per AS number that is off, any other anomaly threshold, or
|
// threshold per AS number that is off, any other anomaly threshold, or
|
||||||
// a blocklist.
|
// a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
@@ -1351,7 +1410,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1430,7 +1489,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1448,7 +1507,7 @@ const (
|
|||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them.
|
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||||
const (
|
const (
|
||||||
actionDeny = "deny"
|
actionDeny = "deny"
|
||||||
actionLimit = "limit"
|
actionLimit = "limit"
|
||||||
@@ -1527,7 +1586,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1542,9 +1601,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too`
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1728,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1737,7 +1796,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
|||||||
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
|
||||||
{spamhaus + ",,", "has an empty item in its list"},
|
{spamhaus + ",,", "has an empty item in its list"},
|
||||||
} {
|
} {
|
||||||
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := dnsblZones + ": " + tc.want
|
want := dnsblZones + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1790,7 +1849,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
want := tc.name + ": " + tc.want
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -1847,7 +1906,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080"
|
"such as http://172.17.0.1:8080"
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||||
`SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too`
|
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -1904,7 +1963,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.viper())
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
if err == nil || err.Error() != tc.want {
|
if err == nil || err.Error() != tc.want {
|
||||||
t.Errorf("error %v, want %s", err, tc.want)
|
t.Errorf("error %v, want %s", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -2064,7 +2123,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
|
|||||||
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 := config.FromEnvironment(environment{tc.name: tc.value}.viper())
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
|
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
|
||||||
}
|
}
|
||||||
@@ -2089,7 +2148,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
|
|||||||
t.Run(value, func(t *testing.T) {
|
t.Run(value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
|
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
|
||||||
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
if err == nil || err.Error() != logRequestHeaders+": "+want {
|
||||||
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
|
||||||
}
|
}
|
||||||
@@ -2106,7 +2165,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
|||||||
t.Run(name+"="+value, func(t *testing.T) {
|
t.Run(name+"="+value, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{name: value}.viper())
|
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
|
||||||
|
|
||||||
want := name + ": is shorter than 32 characters"
|
want := name + ": is shorter than 32 characters"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2162,7 +2221,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
|
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
@@ -2173,7 +2232,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|||||||
logRemoteURL: remoteURL,
|
logRemoteURL: remoteURL,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
|
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as blog`
|
`characters without a space, such as blog`
|
||||||
@@ -2188,7 +2247,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsToken + "_FILE": writeFile(t, token),
|
metricsToken + "_FILE": writeFile(t, token),
|
||||||
}.viper())
|
}.lookupEnv)
|
||||||
|
|
||||||
want := metricsToken + ": is set, and so is " + metricsToken +
|
want := metricsToken + ": is set, and so is " + metricsToken +
|
||||||
"_FILE; set only one of them"
|
"_FILE; set only one of them"
|
||||||
@@ -2203,7 +2262,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
||||||
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
|
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
|
||||||
|
|
||||||
want := metricsToken + "_FILE: cannot be read: "
|
want := metricsToken + "_FILE: cannot be read: "
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||||
@@ -2338,6 +2397,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
exclusionsFile: "",
|
||||||
|
apps: "",
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
+48
-37
@@ -8,39 +8,38 @@ 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 (SWW_RPROXY_LISTEN_ADDR).
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
|
// UpstreamURL is the app (SWRP_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
|
||||||
// (SWW_RPROXY_TRUSTED_PROXIES).
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is discriminator's URL (SWW_RPROXY_WAF_BACKEND).
|
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
||||||
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// DefaultDisposition is what is done with a request when discriminator cannot be
|
// DefaultDisposition is what is done with a request when discriminator 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 (SWW_RPROXY_DEFAULT_DISPOSITION).
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
DefaultDisposition string
|
DefaultDisposition string
|
||||||
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
|
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
|
||||||
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
|
// MaxResponseSize the largest response body (SWRP_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 (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
// app (SWRP_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
|
||||||
// (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
|
// (SWRP_MAX_RESPONSE_TIME_MS).
|
||||||
MaxRequestTime time.Duration
|
MaxRequestTime time.Duration
|
||||||
MaxResponseTime time.Duration
|
MaxResponseTime time.Duration
|
||||||
|
|
||||||
@@ -59,26 +58,24 @@ var (
|
|||||||
"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 through v, normally the viper
|
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
|
||||||
// NewViper returns, bound to the process's environment. A setting that is not
|
// os.LookupEnv. A setting that is not set takes its default, and
|
||||||
// set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
|
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
|
||||||
// set. A setting may instead be given as a file: the variable named by the
|
// invalid is an error that names it. Since rproxy never touches disk, its
|
||||||
// setting's name with _FILE added names the file, which is read now. A setting
|
// settings have no _FILE form.
|
||||||
// that is set but invalid is an error that names it.
|
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
|
||||||
func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||||
env := &environment{v: v, readFiles: true}
|
|
||||||
cfg := &RProxy{
|
cfg := &RProxy{
|
||||||
ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
|
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
|
||||||
UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
|
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
|
||||||
TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
|
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
|
||||||
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
|
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
|
||||||
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
|
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
|
||||||
DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
|
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
|
||||||
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
|
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
|
||||||
MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
|
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
|
||||||
MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
|
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
|
||||||
MaxResponseTime: env.millisecondsOrOff(
|
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
|
||||||
"SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
@@ -90,15 +87,15 @@ func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
|
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
|
||||||
// SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
|
// SWRP_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(
|
||||||
v *viper.Viper,
|
lookupEnv func(string) (string, bool),
|
||||||
) (string, *url.URL, error) {
|
) (string, *url.URL, error) {
|
||||||
env := &environment{v: v, readFiles: true}
|
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||||
listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
|
listenAddr := env.address("SWRP_LISTEN_ADDR")
|
||||||
upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
|
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
|
||||||
|
|
||||||
if env.err != nil {
|
if env.err != nil {
|
||||||
return "", nil, env.err
|
return "", nil, env.err
|
||||||
@@ -107,6 +104,20 @@ 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 {
|
||||||
|
|||||||
@@ -9,23 +9,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rproxy's settings, by name, and their values in these tests.
|
// rproxy's settings, by name, and their values in these tests.
|
||||||
const (
|
const (
|
||||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
|
||||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListen = "SWRP_LISTEN_ADDR"
|
||||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstream = "SWRP_UPSTREAM_URL"
|
||||||
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
|
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
|
||||||
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
|
||||||
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://discriminator:8080"
|
backendURL = "http://discriminator:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyDefaultAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
@@ -34,12 +32,11 @@ 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) {
|
||||||
v := viper.New()
|
return config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
for name, value := range env {
|
value, ok := env[name]
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.RProxyFromEnvironment(v)
|
return value, ok
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxyDefaults(t *testing.T) {
|
func TestRProxyDefaults(t *testing.T) {
|
||||||
@@ -180,41 +177,31 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxySettingReadFromFile(t *testing.T) {
|
func TestRProxyReadsNoFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "backend")
|
path := filepath.Join(t.TempDir(), "backend")
|
||||||
|
|
||||||
err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
|
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
||||||
if err != nil {
|
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||||
t.Fatalf("settings: %v", err)
|
t.Errorf("error %v, want %s still required", err, wafBackend)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WAFBackend.String() != backendURL {
|
cfg, err := rproxyConfig(map[string]string{
|
||||||
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,
|
wafBackend: backendURL,
|
||||||
rproxyTimeout + "_FILE": path,
|
rproxyTimeout + "_FILE": "/nonexistent/timeout",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
want := rproxyTimeout + "_FILE: cannot be read: "
|
t.Fatalf("settings: %v, with the file left unread", err)
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
|
||||||
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout != 2*time.Second {
|
||||||
|
t.Errorf("timeout %v, want the default", cfg.Timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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 discriminator
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ func (rq *request) answerHealth() {
|
|||||||
|
|
||||||
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
||||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWW_WAF_METRICS_TOKEN, the others SWW_WAF_ADMIN_TOKEN. A request without it is
|
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||||
// path does.
|
// path does.
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
@@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWW_WAF_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/blog"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_WAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWW_WAF_WATCH_"
|
anomalyWatch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWW_WAF_WATCH_NETS"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWW_WAF_DENY_NETS
|
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||||
// discriminator has to answer.
|
// discriminator has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
@@ -166,7 +166,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{wafMode: off},
|
||||||
{wafMode: "detect"},
|
{wafMode: "detect"},
|
||||||
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
|
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -399,7 +399,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWW_WAF_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWW_WAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
@@ -85,8 +85,8 @@ func (rq *request) countBytes() {
|
|||||||
// countRefusal counts the request for the error burst once it has been
|
// countRefusal counts the request for the error burst once it has been
|
||||||
// answered, if discriminator refused it after a rule file match, a trap path or a
|
// answered, if discriminator refused it after a rule file match, a trap path or a
|
||||||
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
||||||
// SWW_WAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||||
// break a limit. A client in SWW_WAF_ALLOW_NETS, which the checks skip, is
|
// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
|
||||||
// not counted, and nothing is while the threshold is off.
|
// not counted, and nothing is while the threshold is off.
|
||||||
func (rq *request) countRefusal() {
|
func (rq *request) countRefusal() {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
@@ -121,7 +121,7 @@ func (rq *request) countRefusal() {
|
|||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -277,7 +277,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWW_WAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -343,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWW_WAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
// banResponseClose is the SWW_WAF_BAN_RESPONSE that closes the connection.
|
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||||
banResponseClose = "close"
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWW_WAF_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||||
// SWW_WAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWW_WAF_LIMIT_BAN_DURATION again.
|
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -148,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWW_WAF_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -164,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWW_WAF_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -173,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWW_WAF_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -231,10 +231,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWW_WAF_BAN_RESPONSE at its default
|
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type percentage struct {
|
|||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits: one of its lists is not empty,
|
// client's limits: one of its lists is not empty,
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL
|
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
|
||||||
// is set. The client's lookup is then needed before its request goes on.
|
// is set. The client's lookup is then needed before its request goes on.
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
|||||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||||
// that list it. Each is the lowest of those the settings give it, the
|
// that list it. Each is the lowest of those the settings give it, the
|
||||||
// first of them in the order below when several are lowest: the
|
// first of them in the order below when several are lowest: the
|
||||||
// percentage SWW_WAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
// SWW_WAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
// country, unless its request skipped the wait for GeoJS,
|
// country, unless its request skipped the wait for GeoJS,
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||||
// percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and for a client
|
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
|
||||||
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
||||||
// percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For the byte
|
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
|
||||||
// limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT take the
|
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
|
||||||
// place of the first three for an AS number or a country they list.
|
// place of the first three for an AS number or a country they list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
@@ -47,37 +47,36 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
|
|
||||||
unknown := percentage{percent: whole}
|
unknown := percentage{percent: whole}
|
||||||
if country == "" && !rq.skippedLookupWait {
|
if country == "" && !rq.skippedLookupWait {
|
||||||
unknown = percentage{cfg.UnknownLimitPercent, "SWW_WAF_UNKNOWN_LIMIT_PERCENT"}
|
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched := percentage{percent: whole}
|
fetched := percentage{percent: whole}
|
||||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
fetched = percentage{percent, "SWW_WAF_ASN_LIMIT_PERCENT_URL"}
|
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||||
}
|
}
|
||||||
|
|
||||||
blocklisted := percentage{percent: whole}
|
blocklisted := percentage{percent: whole}
|
||||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_WAF_BLOCKLIST_ACTION"}
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
reputationListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_WAF_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_WAF_ASN_LIMIT_PERCENT"),
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
|
||||||
fetched)
|
fetched)
|
||||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||||
"SWW_WAF_COUNTRY_LIMIT_PERCENT")
|
"SWW_COUNTRY_LIMIT_PERCENT")
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_WAF_ASN_BYTES_PERCENT")
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
countryBytes = given(cfg.CountryBytesPercent, country,
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
"SWW_WAF_COUNTRY_BYTES_PERCENT")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ import (
|
|||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
asnLimitPercent = "SWW_WAF_ASN_LIMIT_PERCENT"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWW_WAF_COUNTRY_LIMIT_PERCENT"
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -230,7 +230,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||||
noCountry, "25 from " + asnLimitPercent,
|
noCountry, "25 from " + asnLimitPercent,
|
||||||
@@ -487,7 +487,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWW_WAF_ASN_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_WAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWW_WAF_BYTES_COUNT.
|
// The values of SWW_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWW_WAF_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
|||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||||
// length SWW_WAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS exempts its
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
// path, as
|
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
// log line, and the rules in the metrics. A score at or over
|
// log line, and the rules in the metrics. A score at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||||
// and in block mode refuses the request, which is an offence its client's
|
// and in block mode refuses the request, which is an offence its client's
|
||||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
||||||
// request it does not refuse.
|
// request it does not refuse.
|
||||||
@@ -56,7 +55,7 @@ func (rq *request) checkCoreRuleSet() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||||
// Rule Set scored at result, at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its
|
// Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
||||||
// detail gives the rule ids, the score, the method and the path with the
|
// detail gives the rule ids, the score, the method and the path with the
|
||||||
// query, and, for a request that is not refused for it, the mode: detect,
|
// query, and, for a request that is not refused for it, the mode: detect,
|
||||||
// or observe in observe mode.
|
// or observe in observe mode.
|
||||||
@@ -83,8 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
|||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -10,17 +10,19 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
|
apps = "SWW_APPS"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -28,7 +30,7 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||||
// setting.
|
// setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
@@ -37,7 +39,7 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
||||||
@@ -125,7 +127,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -134,7 +136,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -153,7 +155,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -237,6 +239,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Only gitea's fixes let a file name through in path, a parameter
|
||||||
|
// gitea sends file names in.
|
||||||
|
const fileName = "/?path=.gitignore"
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
|
wafMode: block, apps: siteA + ":gitea",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||||
|
http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||||
|
http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -468,8 +491,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
Host: appHost,
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over " +
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
|
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
// countryDenied reports whether the country lists refuse the request, by
|
// countryDenied reports whether the country lists refuse the request, by
|
||||||
// the client's country as it was looked up. A client without a country,
|
// the client's country as it was looked up. A client without a country,
|
||||||
// or whose country cannot be found, is refused only by
|
// or whose country cannot be found, is refused only by
|
||||||
// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
func (rq *request) countryDenied() bool {
|
func (rq *request) countryDenied() bool {
|
||||||
denied := rq.h.config.DeniedCountries
|
denied := rq.h.config.DeniedCountries
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
|||||||
@@ -245,11 +245,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWW_WAF_ALLOW_NETS", "",
|
"not in SWW_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWW_WAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -279,7 +279,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// leave the client unknown.
|
// leave the client unknown.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as reputation.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const (
|
|||||||
// headers and body.
|
// headers and body.
|
||||||
DispositionDeny = "deny"
|
DispositionDeny = "deny"
|
||||||
// DispositionClose tells rproxy to close the visitor's connection
|
// DispositionClose tells rproxy to close the visitor's connection
|
||||||
// without an answer, which SWW_WAF_BAN_RESPONSE asks for when it is close.
|
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
|
||||||
// discriminator closing the question's connection instead would read as
|
// discriminator closing the question's connection instead would read as
|
||||||
// discriminator being down, and rproxy would then let the request through.
|
// discriminator being down, and rproxy would then let the request through.
|
||||||
DispositionClose = "close"
|
DispositionClose = "close"
|
||||||
@@ -149,7 +149,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||||
// its client: it is over the header limits, a blocklist lists its client while
|
// its client: it is over the header limits, a blocklist lists its client while
|
||||||
// SWW_WAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
||||||
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
||||||
// rproxy lets a request through when discriminator answers too late.
|
// rproxy lets a request through when discriminator answers too late.
|
||||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||||
@@ -335,7 +335,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allow answers a question with DispositionAllow and, while
|
// allow answers a question with DispositionAllow and, while
|
||||||
// SWW_WAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
||||||
func (rq *request) allow() {
|
func (rq *request) allow() {
|
||||||
rq.allowed = true
|
rq.allowed = true
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ func (rq *request) deny(r refusal) {
|
|||||||
rq.refused = &r
|
rq.refused = &r
|
||||||
|
|
||||||
if r.status == 0 {
|
if r.status == 0 {
|
||||||
// SWW_WAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
// SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||||
// status stays 0: the visitor is sent nothing.
|
// status stays 0: the visitor is sent nothing.
|
||||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const (
|
|||||||
// reportBodyLimit is the longest body SPEC.md lets a report have.
|
// reportBodyLimit is the longest body SPEC.md lets a report have.
|
||||||
reportBodyLimit = 1 << 20
|
reportBodyLimit = 1 << 20
|
||||||
// anomalyClientPerMinute is the anomaly threshold these tests set.
|
// anomalyClientPerMinute is the anomaly threshold these tests set.
|
||||||
anomalyClientPerMinute = "SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE"
|
anomalyClientPerMinute = "SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The fields of a report, as "discriminator" in SPEC.md names them.
|
// The fields of a report, as "discriminator" in SPEC.md names them.
|
||||||
@@ -181,7 +181,7 @@ func (s *Discriminator) history() ratelimit.History {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metrics returns what the metrics endpoint answers, for a server started with
|
// metrics returns what the metrics endpoint answers, for a server started with
|
||||||
// the token as SWW_WAF_METRICS_TOKEN.
|
// the token as SWW_METRICS_TOKEN.
|
||||||
func (s *Discriminator) metrics() string {
|
func (s *Discriminator) metrics() string {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
@@ -1485,7 +1485,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
|
|||||||
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{"SWW_WAF_LOG_LEVEL": "error"})
|
s := startDiscriminator(t, "", map[string]string{"SWW_LOG_LEVEL": "error"})
|
||||||
|
|
||||||
// A refused question would write a process warning, held back at error.
|
// A refused question would write a process warning, held back at error.
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
|
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
|
|
||||||
// refused is a request the tests here send, which discriminator refuses after a rule
|
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||||
// file match.
|
// file match.
|
||||||
@@ -48,7 +48,7 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||||
// SWW_WAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForErrorBurst(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, threshold string, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
@@ -258,15 +258,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS is not refused, and so not counted.
|
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||||
// and country, for the app, while SWW_WAF_ADD_LOOKUP_HEADERS is set. Go writes
|
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
|
||||||
// every header name in this form, as it sends it and as it receives it, so
|
// every header name in this form, as it sends it and as it receives it, so
|
||||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line, unless
|
// database or through GeoJS, and notes them for the log line, unless
|
||||||
// SWW_WAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
// country list or a biased threshold, a new client's request waits for it,
|
// country list or a biased threshold, a new client's request waits for it,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// and country.
|
// and country.
|
||||||
type asnAndCountry struct{ asn, asName, country string }
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database.
|
// lookup database.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWW_WAF_METRICS_TOP_N"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
// token is the SWW_WAF_METRICS_TOKEN the tests set, and bearer how a
|
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a
|
||||||
// request carries it.
|
// request carries it.
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
token = "0123456789abcdef0123456789abcdef"
|
||||||
bearer = "Bearer " + token
|
bearer = "Bearer " + token
|
||||||
@@ -113,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
@@ -124,7 +124,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// SWW_WAF_BAN_RESPONSE=close sends no status at all.
|
// SWW_BAN_RESPONSE=close sends no status at all.
|
||||||
s.get(denied, 0, requestlog.ActionDenied)
|
s.get(denied, 0, requestlog.ActionDenied)
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
// A first broken limit bans for an hour.
|
||||||
@@ -145,7 +145,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWW_WAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
@@ -328,12 +328,12 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||||
// which SWW_WAF_DENIED_COUNTRIES does not refuse.
|
// which SWW_DENIED_COUNTRIES does not refuse.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
// The client stops waiting for GeoJS once SWW_WAF_LOOKUP_TIMEOUT is up, so
|
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
|
||||||
// GeoJS's failure can come after its request has ended.
|
// GeoJS's failure can come after its request has ended.
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// observe is the value of SWW_WAF_MODE for observe mode.
|
// observe is the value of SWW_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
banned = otherClient // under a ban read from bans.json
|
banned = otherClient // under a ban read from bans.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWW_WAF_MODE at its default
|
setting string // "" leaves SWW_MODE at its default
|
||||||
observe bool
|
observe bool
|
||||||
}{
|
}{
|
||||||
{"", false},
|
{"", false},
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ const adminPrefix = "/_sww/"
|
|||||||
const HealthPath = "/_sww/healthz"
|
const HealthPath = "/_sww/healthz"
|
||||||
|
|
||||||
// MetricsPath is where the metrics are, for a request that carries
|
// MetricsPath is where the metrics are, for a request that carries
|
||||||
// SWW_WAF_METRICS_TOKEN.
|
// SWW_METRICS_TOKEN.
|
||||||
const MetricsPath = "/_sww/metrics"
|
const MetricsPath = "/_sww/metrics"
|
||||||
|
|
||||||
// BansPath is where an admin lists and adds bans, and, followed by / and
|
// BansPath is where an admin lists and adds bans, and, followed by / and
|
||||||
// a client's address, lifts them, with SWW_WAF_ADMIN_TOKEN.
|
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
|
||||||
const BansPath = "/_sww/bans"
|
const BansPath = "/_sww/bans"
|
||||||
|
|
||||||
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
||||||
// the client's address after it, with SWW_WAF_ADMIN_TOKEN.
|
// the client's address after it, with SWW_ADMIN_TOKEN.
|
||||||
const ClientsPath = "/_sww/clients/"
|
const ClientsPath = "/_sww/clients/"
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
@@ -54,16 +54,16 @@ type Params struct {
|
|||||||
// ProcessLog receives the process's own messages.
|
// ProcessLog receives the process's own messages.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||||
// while SWW_WAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
// while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||||
GeoJSURL string
|
GeoJSURL string
|
||||||
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
||||||
// SWW_WAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
// SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
||||||
AbuseIPDBURL string
|
AbuseIPDBURL string
|
||||||
// LookupFile is the lookup database they are looked up in while
|
// LookupFile is the lookup database they are looked up in while
|
||||||
// SWW_WAF_LOOKUP_SOURCE is file, and nil otherwise.
|
// SWW_LOOKUP_SOURCE is file, and nil otherwise.
|
||||||
LookupFile *lookup.File
|
LookupFile *lookup.File
|
||||||
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
||||||
// while SWW_WAF_CORE_RULE_SET_MODE is not off, and nil while it is.
|
// while SWW_WAF_MODE is not off, and nil while it is.
|
||||||
CoreRuleSet *waf.CoreRuleSet
|
CoreRuleSet *waf.CoreRuleSet
|
||||||
// Now tells the time by which requests are counted for the rate
|
// Now tells the time by which requests are counted for the rate
|
||||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||||
@@ -76,14 +76,14 @@ type Params struct {
|
|||||||
// permanent, for each count over an anomaly threshold, for each request
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||||
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
// SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||||
// or the day's AbuseIPDB checks used up.
|
// or the day's AbuseIPDB checks used up.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server discriminator runs, with the parts whose state the state
|
// Server is the server discriminator runs, with the parts whose state the state
|
||||||
// files keep, the lookup database, nil unless SWW_WAF_LOOKUP_SOURCE is file, the
|
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
||||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
// AbuseIPDB's scores and checks spent, and the metrics.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -175,7 +175,7 @@ func newHandler(params Params) *handler {
|
|||||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||||
// m, AbuseIPDB's while SWW_WAF_ABUSEIPDB_KEY is set.
|
// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
|
||||||
func newReputation(
|
func newReputation(
|
||||||
params Params, m *metrics.Metrics,
|
params Params, m *metrics.Metrics,
|
||||||
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
||||||
@@ -207,11 +207,12 @@ func newReputation(
|
|||||||
return lists, dnsbl, abuseIPDB
|
return lists, dnsbl, abuseIPDB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_CORE_RULE_SET_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_CORE_RULE_SET_MODE is
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||||
// off. It reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// or nil while SWW_WAF_MODE is off. It
|
||||||
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
||||||
// an error in every mode. While the mode is off, a set file is loaded with the
|
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||||
@@ -225,9 +226,10 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
|||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
|
Apps: cfg.Apps,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
if cfg.WAFMode == config.WAFModeOff {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"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/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
@@ -57,33 +55,33 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
mode = "SWW_WAF_MODE"
|
mode = "SWW_MODE"
|
||||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWW_WAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWW_WAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWW_WAF_DENIED_COUNTRIES"
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWW_WAF_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
maxBans = "SWW_WAF_MAX_BANS"
|
maxBans = "SWW_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
|
wafMode = "SWW_WAF_MODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
// off is the value that switches a setting off.
|
||||||
@@ -296,8 +294,8 @@ func startProxyWithGeoJS(
|
|||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns discriminator's server as well, whose
|
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||||
// Addr is where discriminator listens. Unless env sets SWW_WAF_RULES_DIR, it is an
|
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||||
// empty directory, of no rules, and unless it sets SWW_WAF_INSTANCE_NAME, that is app,
|
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||||
// the label instance of every metric.
|
// the label instance of every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
@@ -345,34 +343,27 @@ func serve(t *testing.T, server *discriminator.Server) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// viperFrom returns a viper Set with each of settings, as the viper
|
|
||||||
// config.NewViper returns one bound to a process's environment.
|
|
||||||
func viperFrom(settings map[string]string) *viper.Viper {
|
|
||||||
v := viper.New()
|
|
||||||
for name, value := range settings {
|
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
||||||
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
|
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
|
||||||
// where it listens. What it writes is dropped. SWW_RPROXY_TIMEOUT_MS is a minute unless
|
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||||
// env sets it, so that on a busy host SWW_RPROXY_DEFAULT_DISPOSITION does not decide.
|
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWW_RPROXY_WAF_BACKEND": "http://" + discriminatorAddr,
|
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
|
||||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
"SWW_RPROXY_TIMEOUT_MS": "60000",
|
"SWRP_TIMEOUT_MS": "60000",
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(viperFrom(settings))
|
cfg, err := config.RProxyFromEnvironment(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)
|
||||||
}
|
}
|
||||||
@@ -404,11 +395,11 @@ func startRProxy(
|
|||||||
// settings in env make it. No alert is sent from the queue: they wait in it,
|
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||||
// for the test to look at.
|
// for the test to look at.
|
||||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||||
// SWW_WAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||||
// database SWW_WAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||||
// at abuseIPDBURL while env sets SWW_WAF_ABUSEIPDB_KEY.
|
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
|
||||||
// SWW_WAF_CORE_RULE_SET_MODE is off unless env sets it, so that only the tests of the
|
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||||
// Core Rule Set have their requests inspected by it.
|
// inspected by it.
|
||||||
func newDiscriminator(
|
func newDiscriminator(
|
||||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||||
) (*discriminator.Server, *output, *alerts.Queue) {
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
@@ -433,7 +424,11 @@ func newDiscriminatorWith(
|
|||||||
|
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(viperFrom(settings))
|
cfg, err := config.FromEnvironment(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minute is the window of SWW_WAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||||
// limit_hit names it.
|
// limit_hit names it.
|
||||||
const minute = "minute"
|
const minute = "minute"
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// With SWW_WAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
||||||
// /64s of one /48, are one client, and the second's request breaks the
|
// /64s of one /48, are one client, and the second's request breaks the
|
||||||
// limit; the third, in the next /48, is another client.
|
// limit; the third, in the next /48, is another client.
|
||||||
const (
|
const (
|
||||||
@@ -118,7 +118,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
@@ -151,7 +151,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// A ban, SWW_WAF_DENY_NETS and the country lists still refuse a path
|
// A ban, SWW_DENY_NETS and the country lists still refuse a path
|
||||||
// under a prefix.
|
// under a prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// deny is the SWW_WAF_BLOCKLIST_ACTION and the SWW_WAF_REPUTATION_ACTION that
|
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
||||||
// refuses the requests of a client a source lists.
|
// refuses the requests of a client a source lists.
|
||||||
const deny = "deny"
|
const deny = "deny"
|
||||||
|
|
||||||
// blocklistDenied notes the blocklists that list the client, as
|
// blocklistDenied notes the blocklists that list the client, as
|
||||||
// noteListed does, and reports whether SWW_WAF_BLOCKLIST_ACTION, being deny,
|
// noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
|
||||||
// refuses the request. Being limit, it lowers the client's limits instead
|
// refuses the request. Being limit, it lowers the client's limits instead
|
||||||
// (see limitPercentages), and being log, it does nothing more.
|
// (see limitPercentages), and being log, it does nothing more.
|
||||||
func (rq *request) blocklistDenied() bool {
|
func (rq *request) blocklistDenied() bool {
|
||||||
@@ -43,7 +43,7 @@ func (rq *request) crowdSecBanned(now time.Time) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
||||||
// noteListed does, and reports whether SWW_WAF_REPUTATION_ACTION, being
|
// noteListed does, and reports whether SWW_REPUTATION_ACTION, being
|
||||||
// deny, refuses the request. Being limit, it lowers the client's limits
|
// deny, refuses the request. Being limit, it lowers the client's limits
|
||||||
// instead (see limitPercentages), and being log, it does nothing more. A
|
// instead (see limitPercentages), and being log, it does nothing more. A
|
||||||
// zone without a verdict on the client is asked about it in the
|
// zone without a verdict on the client is asked about it in the
|
||||||
@@ -59,8 +59,8 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
||||||
// its score of the client is a hit, and reports whether
|
// its score of the client is a hit, and reports whether
|
||||||
// SWW_WAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
||||||
// does for a zone. While SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A
|
// does for a zone. While SWW_ABUSEIPDB_KEY is unset it does nothing. A
|
||||||
// client without a score is checked in the background, by the request's
|
// client without a score is checked in the background, by the request's
|
||||||
// address, if its history counts an offence, and the request does not
|
// address, if its history counts an offence, and the request does not
|
||||||
// wait for the answer. The score is then used for each address of the
|
// wait for the answer. The score is then used for each address of the
|
||||||
@@ -81,7 +81,7 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
rq.abuseIPDBHit = true
|
rq.abuseIPDBHit = true
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE")
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
return rq.h.config.ReputationAction == deny
|
return rq.h.config.ReputationAction == deny
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ import (
|
|||||||
|
|
||||||
// The reputation settings.
|
// The reputation settings.
|
||||||
const (
|
const (
|
||||||
blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWW_WAF_BLOCKLIST_ACTION and SWW_WAF_REPUTATION_ACTION:
|
// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
|
||||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
// quarter.
|
// quarter.
|
||||||
const (
|
const (
|
||||||
@@ -127,7 +127,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
// fromKP's country refuses it before the list is looked at, and fromDE,
|
// fromKP's country refuses it before the list is looked at, and fromDE,
|
||||||
// in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted, nor
|
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
|
||||||
// alerted.
|
// alerted.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
@@ -224,7 +224,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
{
|
{
|
||||||
"SWW_WAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
"SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -312,9 +312,9 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
// The DNSBL settings.
|
// The DNSBL settings.
|
||||||
const (
|
const (
|
||||||
dnsblZones = "SWW_WAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
reputationAction = "SWW_WAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The DNSBL zones these tests name, which are never asked about the
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
@@ -370,7 +370,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
for _, from := range []string{fromDE, fromKP} {
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
// In the order SWW_WAF_DNSBL_ZONES names them.
|
// In the order SWW_DNSBL_ZONES names them.
|
||||||
wantReputation(t, line, listedBy[from]...)
|
wantReputation(t, line, listedBy[from]...)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||||
line.LimitPercentSetting, tc.percent)
|
line.LimitPercentSetting, tc.percent)
|
||||||
@@ -416,7 +416,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
||||||
|
|
||||||
// The blocklist refuses fromKP before its verdict is looked at, and
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
// fromDE, in SWW_WAF_ALLOW_NETS, is not checked at all: neither is noted
|
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
// for the zone, nor alerted, nor asked about.
|
// for the zone, nor alerted, nor asked about.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
dropURL)
|
dropURL)
|
||||||
@@ -609,7 +609,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|||||||
|
|
||||||
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
||||||
const (
|
const (
|
||||||
abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
accountKey = "abuseipdb-key-0123456789abcdef"
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -800,7 +800,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
reputationAction: tc.action,
|
reputationAction: tc.action,
|
||||||
})
|
})
|
||||||
// At SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
// At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
@@ -851,7 +851,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
// The alert is made as a DNSBL zone's is, with the score besides.
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||||
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE" ||
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
|
||||||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
||||||
queue.Suppressed() != 1 {
|
queue.Suppressed() != 1 {
|
||||||
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
||||||
@@ -922,7 +922,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
})
|
})
|
||||||
// Every source lists client, and none otherClient, whose score is
|
// Every source lists client, and none otherClient, whose score is
|
||||||
// under SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
||||||
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
||||||
@@ -971,7 +971,7 @@ func verdictsFetched() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
||||||
// a verdict of each zone SWW_WAF_DNSBL_ZONES names, fetched at
|
// a verdict of each zone SWW_DNSBL_ZONES names, fetched at
|
||||||
// verdictsFetched, as reputation.json would at start: one that lists the
|
// verdictsFetched, as reputation.json would at start: one that lists the
|
||||||
// client from each zone listedBy gives for it, and one that does not from
|
// client from each zone listedBy gives for it, and one that does not from
|
||||||
// each other zone.
|
// each other zone.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type decision struct {
|
|||||||
lookedUp bool
|
lookedUp bool
|
||||||
lookupAnswer lookup.Answer
|
lookupAnswer lookup.Answer
|
||||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||||
// found no answer kept. SWW_WAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
// found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
||||||
// so that not waiting never lowers its client's limits.
|
// so that not waiting never lowers its client's limits.
|
||||||
skippedLookupWait bool
|
skippedLookupWait bool
|
||||||
// counted is true for a request the rate limits counted, whose bytes
|
// counted is true for a request the rate limits counted, whose bytes
|
||||||
@@ -161,7 +161,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
|||||||
|
|
||||||
// check is the one place where a request can be refused. It returns nil to let
|
// check is the one place where a request can be refused. It returns nil to let
|
||||||
// the request through. The checks of checkClient decide: a request they refuse
|
// the request through. The checks of checkClient decide: a request they refuse
|
||||||
// is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
// is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
||||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
||||||
// line names what enforce mode would have done. ctx is the request's own context.
|
// line names what enforce mode would have done. ctx is the request's own context.
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
@@ -185,17 +185,17 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
// checkClient runs the checks on the request's client, and returns the
|
||||||
// action of the first that refuses the request, or "" when none does. A
|
// action of the first that refuses the request, or "" when none does. A
|
||||||
// client in SWW_WAF_ALLOW_NETS skips them, and is not looked up. For any
|
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any
|
||||||
// other client, SWW_WAF_DENY_NETS comes first, then a ban on its netblock,
|
// other client, SWW_DENY_NETS comes first, then a ban on its netblock,
|
||||||
// so that a client either refuses is not looked up, then the lookup of
|
// so that a client either refuses is not looked up, then the lookup of
|
||||||
// its AS number and country, then the country lists, then the blocklists,
|
// its AS number and country, then the country lists, then the blocklists,
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
// then the CrowdSec decision list, which bans the client it lists, then
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
// them refuses is not counted for the rate limits. Then come the
|
||||||
// rate limits, unless the client is in SWW_WAF_RATE_LIMIT_EXEMPT_NETS or the
|
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
|
||||||
// request's path is exempt under SWW_WAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||||
// every other request is counted, each of them by the client's limit
|
// every other request is counted, each of them by the client's limit
|
||||||
// percentages, then SWW_WAF_TRAP_PATHS, then the rule files, then the header
|
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
|
||||||
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
||||||
// is exempt from the byte limits too. ctx is the request's own context.
|
// is exempt from the byte limits too. ctx is the request's own context.
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
@@ -266,8 +266,8 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// countedByRateLimits reports whether the rate limits count the request: its
|
// countedByRateLimits reports whether the rate limits count the request: its
|
||||||
// client is not in SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||||
// under SWW_WAF_RATE_LIMIT_EXEMPT_PATHS.
|
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
|
||||||
func (rq *request) countedByRateLimits() bool {
|
func (rq *request) countedByRateLimits() bool {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ func (rq *request) countedByRateLimits() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether r is exempt under prefixes,
|
// pathExempt reports whether r is exempt under prefixes,
|
||||||
// SWW_WAF_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS: whether its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
|
||||||
// path as sent, the path the app receives, not percent-decoded, starts
|
// path as sent, the path the app receives, not percent-decoded, starts
|
||||||
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
||||||
// app whose router matches the path as received. A request whose decoded
|
// app whose router matches the path as received. A request whose decoded
|
||||||
@@ -378,9 +378,9 @@ func (rq *request) addToHistory() {
|
|||||||
|
|
||||||
// countAnomalies counts the request, which has ended, and its bytes, as
|
// countAnomalies counts the request, which has ended, and its bytes, as
|
||||||
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
||||||
// with it: a request refused, one from a client in SWW_WAF_ALLOW_NETS or
|
// with it: a request refused, one from a client in SWW_ALLOW_NETS or
|
||||||
// SWW_WAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
||||||
// SWW_WAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
||||||
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
||||||
// threshold off, the default, it does nothing.
|
// threshold off, the default, it does nothing.
|
||||||
func (rq *request) countAnomalies() {
|
func (rq *request) countAnomalies() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
// requestIDHeader carries the request's id.
|
// requestIDHeader carries the request's id.
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
// instance is the SWW_WAF_INSTANCE_NAME a test sets.
|
// instance is the SWW_INSTANCE_NAME a test sets.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||||
// limits count it as.
|
// limits count it as.
|
||||||
@@ -195,7 +195,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
|||||||
func TestTimingsAreInOrder(t *testing.T) {
|
func TestTimingsAreInOrder(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// The pause sets the times apart; a hold-up of the test only lengthens
|
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line, "noted")
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
// A block rule refuses with 403, whatever SWW_WAF_BAN_RESPONSE is, and
|
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||||
// bans no one.
|
// bans no one.
|
||||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantRuleIDs(t, line, "noted", "blocked")
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// A ban rule refuses with SWW_WAF_BAN_RESPONSE, and bans the client for
|
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||||
// seven days, the default.
|
// seven days, the default.
|
||||||
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line, "noted", "probe")
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
@@ -101,7 +101,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
attackBanDuration: "1h",
|
attackBanDuration: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The first probe bans for SWW_WAF_ATTACK_BAN_DURATION.
|
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||||
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
@@ -122,8 +122,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -133,7 +133,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// trapPath reports whether the request asks for a path in
|
// trapPath reports whether the request asks for a path in
|
||||||
// SWW_WAF_TRAP_PATHS: its path as a path rule sees it, without the query,
|
// SWW_TRAP_PATHS: its path as a path rule sees it, without the query,
|
||||||
// as sent or once percent-decoded, is one of them. Such a request is a
|
// as sent or once percent-decoded, is one of them. Such a request is a
|
||||||
// clear sign of attack, as a ban rule's match is: it bans the client's
|
// clear sign of attack, as a ban rule's match is: it bans the client's
|
||||||
// netblock, or in observe mode raises the alert for the ban it would have
|
// netblock, or in observe mode raises the alert for the ban it would have
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
// fromKP is in SWW_WAF_ALLOW_NETS, and in SWW_WAF_DENY_NETS too, which
|
// fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
|
||||||
// comes after it.
|
// comes after it.
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
@@ -36,7 +36,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWW_WAF_DENY_NETS, the country lists nor the limit of one
|
// Neither SWW_DENY_NETS, the country lists nor the limit of one
|
||||||
// request a minute refuses the client, and its country is not looked
|
// request a minute refuses the client, and its country is not looked
|
||||||
// up.
|
// up.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||||
// to.
|
// to.
|
||||||
const (
|
const (
|
||||||
trapPaths = "SWW_WAF_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
// A block rule for the same path: the trap path comes first.
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -38,7 +38,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client in SWW_WAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// The query is not part of the path.
|
// The query is not part of the path.
|
||||||
@@ -104,7 +104,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
"SWW_WAF_RULES_ENABLED": "false",
|
"SWW_RULES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|||||||
@@ -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_WAF_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -221,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWW_WAF_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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_WAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWW_WAF_LOOKUP_TIMEOUT).
|
// (SWW_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
|||||||
+10
-10
@@ -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_WAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWW_WAF_INSTANCE_NAME) as its label instance.
|
// (SWW_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
offences: counterVec("sww_offences_total",
|
offences: counterVec("sww_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
wafMatches: counterVec("sww_waf_matches_total",
|
wafMatches: counterVec("sww_waf_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by 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),
|
||||||
@@ -214,13 +214,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWW_WAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_remote_log_lines_sent_total",
|
Name: "sww_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWW_WAF_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_reputation_daily_budget_remaining",
|
Name: "sww_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -363,8 +363,8 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_suppressed_total",
|
Name: "sww_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
@@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWW_WAF_CORE_RULE_SET_MODE at mode.
|
// with SWW_WAF_MODE at mode.
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,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_WAF_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// kept, in memory, and written to clients.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
@@ -156,7 +156,7 @@ type Request struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
// table holds at most maxClients clients (SWW_WAF_MAX_TRACKED_CLIENTS). Past
|
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
// it, the least recently seen client is dropped, with its history, and
|
||||||
// starts afresh if it comes back.
|
// starts afresh if it comes back.
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
func New(limits Limits, maxClients int) *Limiter {
|
||||||
|
|||||||
@@ -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_WAF_MAX_TRACKED_CLIENTS.
|
// SWW_MAX_TRACKED_CLIENTS.
|
||||||
const tableSize = 20000
|
const tableSize = 20000
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
|||||||
@@ -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_WAF_LOG_REMOTE_URL, as the "Request log" section
|
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
||||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||||
// or unreachable endpoint never holds up a request or stdout.
|
// or unreachable endpoint never holds up a request or stdout.
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The forms of SWW_WAF_LOG_REMOTE_URL, by its scheme.
|
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
||||||
const (
|
const (
|
||||||
SchemeUDP = "syslog+udp"
|
SchemeUDP = "syslog+udp"
|
||||||
SchemeTCP = "syslog+tcp"
|
SchemeTCP = "syslog+tcp"
|
||||||
@@ -56,18 +56,18 @@ const (
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is the endpoint (SWW_WAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||||
// SchemeTLS, a host and a port.
|
// SchemeTLS, a host and a port.
|
||||||
URL *url.URL
|
URL *url.URL
|
||||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
// Buffer is the most lines held while they wait to be sent
|
// Buffer is the most lines held while they wait to be sent
|
||||||
// (SWW_WAF_LOG_REMOTE_BUFFER).
|
// (SWW_LOG_REMOTE_BUFFER).
|
||||||
Buffer int
|
Buffer int
|
||||||
// Facility is the number of the records' syslog facility
|
// Facility is the number of the records' syslog facility
|
||||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
// (SWW_WAF_LOG_REMOTE_APP_NAME).
|
// (SWW_LOG_REMOTE_APP_NAME).
|
||||||
Facility int
|
Facility int
|
||||||
AppName string
|
AppName string
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed",
|
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
||||||
"error", err.Error(), "connecting_again_in", delay.String())
|
"error", err.Error(), "connecting_again_in", delay.String())
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
|||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" {
|
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
||||||
t.Fatalf("logged %q", line)
|
t.Fatalf("logged %q", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,17 +58,17 @@ type Checks struct {
|
|||||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
type AbuseIPDBParams struct {
|
type AbuseIPDBParams struct {
|
||||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||||
// the account's key (SWW_WAF_ABUSEIPDB_KEY).
|
// the account's key (SWW_ABUSEIPDB_KEY).
|
||||||
URL string
|
URL string
|
||||||
Key string
|
Key string
|
||||||
// MinScore is the least score that is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE),
|
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
||||||
// and DailyBudget the most checks made in a day, in UTC
|
// and DailyBudget the most checks made in a day, in UTC
|
||||||
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||||
MinScore int64
|
MinScore int64
|
||||||
DailyBudget int
|
DailyBudget int
|
||||||
// CacheTTL is how long a score is used after it was fetched
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
|||||||
@@ -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_WAF_DNSBL_ZONES).
|
// (SWW_DNSBL_ZONES).
|
||||||
Zones []string
|
Zones []string
|
||||||
// Resolver is the resolver they are asked through
|
// Resolver is the resolver they are asked through
|
||||||
// (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||||
// host's, as /etc/resolv.conf names it.
|
// host's, as /etc/resolv.conf names it.
|
||||||
Resolver netip.AddrPort
|
Resolver netip.AddrPort
|
||||||
// CacheTTL is how long a verdict is used after it was fetched
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||||
// (SWW_WAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them.
|
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
||||||
func (d *DNSBL) Zones() []string {
|
func (d *DNSBL) Zones() []string {
|
||||||
return slices.Clone(d.params.Zones)
|
return slices.Clone(d.params.Zones)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
// lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key
|
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
||||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||||
// passed since it was fetched. Each zone without one is asked about addr
|
// passed since it was fetched. Each zone without one is asked about addr
|
||||||
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps verdicts, read from reputation.json, in place of those it
|
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||||
// keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and,
|
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
||||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||||
// more is neither used nor written, as for any verdict.
|
// more is neither used nor written, as for any verdict.
|
||||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||||
|
|||||||
@@ -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_WAF_REPUTATION_CACHE_TTL, and timeout their
|
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
||||||
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||||
// can have Go's resolver wait for one server, so that it is the
|
// can have Go's resolver wait for one server, so that it is the
|
||||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
|||||||
dnsbl := reputation.NewDNSBL(p)
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||||
// zone SWW_WAF_DNSBL_ZONES does not name.
|
// zone SWW_DNSBL_ZONES does not name.
|
||||||
inUse := reputation.Verdict{
|
inUse := reputation.Verdict{
|
||||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||||
// engine SWW_WAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||||
// today. The state package writes all of these to reputation.json and
|
// today. The state package writes all of these to reputation.json and
|
||||||
// reads them from it, so that a restart keeps them too.
|
// reads them from it, so that a restart keeps them too.
|
||||||
@@ -66,18 +66,18 @@ type List struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
|
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
||||||
// ASNLimitPercentURL the file of AS:percent lines
|
// ASNLimitPercentURL the file of AS:percent lines
|
||||||
// (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWW_WAF_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// Refresh is how long after a list was last fetched or tried it is
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
// fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||||
// list, which is fetched again crowdSecRefresh after.
|
// list, which is fetched again crowdSecRefresh after.
|
||||||
Refresh time.Duration
|
Refresh time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -152,7 +152,7 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then
|
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
|
||||||
// the CrowdSec decision list's.
|
// the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
@@ -168,7 +168,7 @@ func (l *Lists) URLs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||||
// the order SWW_WAF_BLOCKLIST_URLS names them.
|
// the order SWW_BLOCKLIST_URLS names them.
|
||||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -551,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||||
// AS number, : and a percentage, as SWW_WAF_ASN_LIMIT_PERCENT takes them. An
|
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
|
||||||
// AS number listed more than once gets the lowest of its percentages.
|
// AS number listed more than once gets the lowest of its percentages.
|
||||||
func parsePercents(lines []string) (entries, error) {
|
func parsePercents(lines []string) (entries, error) {
|
||||||
found := entries{percents: map[string]int64{}}
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|||||||
@@ -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_WAF_BLOCKLIST_REFRESH, and cooldown their
|
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
||||||
// SWW_WAF_ALERT_COOLDOWN, longer than it.
|
// SWW_ALERT_COOLDOWN, longer than it.
|
||||||
refresh = 24 * time.Hour
|
refresh = 24 * time.Hour
|
||||||
cooldown = 48 * time.Hour
|
cooldown = 48 * time.Hour
|
||||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
lists := start(t, servers, params(torURL, dropURL))
|
lists := start(t, servers, params(torURL, dropURL))
|
||||||
|
|
||||||
// In the order SWW_WAF_BLOCKLIST_URLS names them.
|
// In the order SWW_BLOCKLIST_URLS names them.
|
||||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ const (
|
|||||||
// rule.
|
// rule.
|
||||||
ActionRuleBlocked = "rule_blocked"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_WAF_REPUTATION_ACTION is deny.
|
// SWW_REPUTATION_ACTION is deny.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
// ActionCountryDenied is a request refused for its client's country.
|
// ActionCountryDenied is a request refused for its client's country.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
@@ -112,7 +112,7 @@ type Line struct {
|
|||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
// ContentLength is the length of its body the request announced.
|
// ContentLength is the length of its body the request announced.
|
||||||
ContentLength int64 `json:"content_length,omitempty"`
|
ContentLength int64 `json:"content_length,omitempty"`
|
||||||
// RequestHeaders are the headers SWW_WAF_LOG_REQUEST_HEADERS names that
|
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
||||||
// the request carried, by name in lower case.
|
// the request carried, by name in lower case.
|
||||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||||
@@ -202,7 +202,7 @@ type RProxyLine struct {
|
|||||||
DurationRequest float64 `json:"duration_request"`
|
DurationRequest float64 `json:"duration_request"`
|
||||||
DurationResponse float64 `json:"duration_response"`
|
DurationResponse float64 `json:"duration_response"`
|
||||||
// Disposition is the valid disposition discriminator answered or, while
|
// Disposition is the valid disposition discriminator answered or, while
|
||||||
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
|
// DefaultDisposition is true, the value of SWRP_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"`
|
||||||
@@ -250,8 +250,8 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_WAF_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// errResponseTooLarge ends an app's response body that is longer than
|
// errResponseTooLarge ends an app's response body that is longer than
|
||||||
// SWW_RPROXY_MAX_RESPONSE_SIZE.
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
var errResponseTooLarge = errors.New(
|
var errResponseTooLarge = errors.New(
|
||||||
"the response body is over SWW_RPROXY_MAX_RESPONSE_SIZE")
|
"the response body is over SWRP_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
|
||||||
// SWW_RPROXY_MAX_REQUEST_SIZE.
|
// SWRP_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
|
||||||
@@ -68,7 +68,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
|
||||||
// SWW_RPROXY_MAX_RESPONSE_SIZE.
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
body io.ReadCloser
|
body io.ReadCloser
|
||||||
rq *request
|
rq *request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// discriminator cannot be connected to, does not answer at all, or does not answer
|
// discriminator cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps discriminator busy past
|
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
||||||
// SWW_RPROXY_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
||||||
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||||
// whenever discriminator is slow. Making such requests hard to send is
|
// whenever discriminator 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("discriminator gave no answer", "error", err.Error())
|
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
||||||
@@ -84,7 +84,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask sends discriminator the question, and returns its answer, which must have come
|
// ask sends discriminator the question, and returns its answer, which must have come
|
||||||
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||||
// away.
|
// away.
|
||||||
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||||
@@ -228,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postReport posts report to discriminator, and waits for its answer for up to
|
// postReport posts report to discriminator, and waits for its answer for up to
|
||||||
// SWW_RPROXY_TIMEOUT_MS.
|
// SWRP_TIMEOUT_MS.
|
||||||
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
||||||
body, err := json.Marshal(report)
|
body, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,9 +33,13 @@ func announcedLength(size int) string {
|
|||||||
func TestServerHasTheFixedLimits(t *testing.T) {
|
func TestServerHasTheFixedLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(viperFrom(map[string]string{
|
cfg, err := config.RProxyFromEnvironment(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)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -50,10 +50,10 @@ type request struct {
|
|||||||
start time.Time
|
start time.Time
|
||||||
|
|
||||||
// disposition is what is done with the request: discriminator's valid disposition,
|
// disposition is what is done with the request: discriminator's valid disposition,
|
||||||
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||||
disposition string
|
disposition string
|
||||||
// answered is true when discriminator gave disposition, which makes a report due,
|
// answered is true when discriminator gave disposition, which makes a report due,
|
||||||
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
|
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||||
answered bool
|
answered bool
|
||||||
byDefault bool
|
byDefault bool
|
||||||
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
||||||
@@ -118,10 +118,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 SWW_RPROXY_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
|
// within SWRP_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 SWW_RPROXY_MAX_REQUEST_SIZE goes on with the part
|
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||||
// read, so that discriminator is asked first; passToApp then refuses it.
|
// read, so that discriminator 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 {
|
||||||
@@ -190,7 +190,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 SWW_RPROXY_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
// sent to SWRP_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
|
||||||
// discriminator's.
|
// discriminator's.
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
@@ -325,7 +325,7 @@ func (rq *request) send(status int, body []byte) {
|
|||||||
|
|
||||||
// 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 SWW_RPROXY_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
||||||
// used that time up first.
|
// used that time up first.
|
||||||
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
||||||
rq.stopReadingBody(deadline)
|
rq.stopReadingBody(deadline)
|
||||||
@@ -419,7 +419,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 SWW_RPROXY_MAX_REQUEST_TIME_MS is off.
|
// when SWRP_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 {
|
||||||
@@ -440,7 +440,7 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
|||||||
_ = rq.rc.SetReadDeadline(deadline)
|
_ = rq.rc.SetReadDeadline(deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRequestTimer starts SWW_RPROXY_MAX_REQUEST_TIME_MS, which runs from the
|
// startRequestTimer starts SWRP_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()
|
||||||
@@ -454,7 +454,7 @@ func (rq *request) startRequestTimer() {
|
|||||||
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
|
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestTimedOut is called when SWW_RPROXY_MAX_REQUEST_TIME_MS runs out while the
|
// requestTimedOut is called when SWRP_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
|
||||||
@@ -500,7 +500,7 @@ func (rq *request) gotFirstResponseByte() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wroteRequest is called once the app has been sent the whole request:
|
// wroteRequest is called once the app has been sent the whole request:
|
||||||
// SWW_RPROXY_MAX_REQUEST_TIME_MS ends and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
|
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_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
|
||||||
@@ -524,7 +524,7 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseTimedOut is called when SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out before the
|
// responseTimedOut is called when SWRP_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()
|
||||||
@@ -539,7 +539,7 @@ func (rq *request) responseTimedOut() {
|
|||||||
// answer before the visitor has sent its whole body, as with a 404 it sends
|
// answer before the visitor has sent its whole body, as with a 404 it sends
|
||||||
// without reading it. The time limits then run on: until the transport's read of
|
// without reading it. The time limits then run on: until the transport's read of
|
||||||
// the body returns, Go's server cannot send the answer, and
|
// the body returns, Go's server cannot send the answer, and
|
||||||
// SWW_RPROXY_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
|
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
|
||||||
// sending.
|
// sending.
|
||||||
func (rq *request) responseReceived() {
|
func (rq *request) responseReceived() {
|
||||||
if rq.body != nil && !rq.body.received.Load() {
|
if rq.body != nil && !rq.body.received.Load() {
|
||||||
@@ -549,7 +549,7 @@ func (rq *request) responseReceived() {
|
|||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
}
|
}
|
||||||
|
|
||||||
// setResponseDeadline sets SWW_RPROXY_MAX_RESPONSE_TIME_MS on the connection to the
|
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
|
||||||
// visitor: the app's answer must reach the visitor within it of the end of the
|
// visitor: the app's answer must reach the visitor within it of the end of the
|
||||||
// request.
|
// request.
|
||||||
func (rq *request) setResponseDeadline() {
|
func (rq *request) setResponseDeadline() {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
||||||
// about each request. It passes a request discriminator allows to the app and the
|
// about each request. It passes a request discriminator 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
|
||||||
// discriminator goes to the visitor as discriminator gave it.
|
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
|
||||||
// SWW_RPROXY_DEFAULT_DISPOSITION
|
|
||||||
// decides only when discriminator cannot be connected to, does not answer at all, or
|
// decides only when discriminator cannot be connected to, does not answer at all, or
|
||||||
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
|
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
||||||
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||||
// SPEC.md says what it does.
|
// SPEC.md says what it does.
|
||||||
@@ -72,7 +71,7 @@ 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 SWW_RPROXY_MAX_REQUEST_TIME_MS while
|
// connection idle for idleTimeout, and applies SWRP_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 {
|
||||||
@@ -104,7 +103,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 SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
// which SWRP_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()
|
||||||
@@ -213,7 +212,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||||
// it from the app. discriminator answers a path under adminPrefix itself, so such a
|
// it from the app. discriminator answers a path under adminPrefix itself, so such a
|
||||||
// request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it. ctx is the
|
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||||
// request's own context.
|
// 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
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
@@ -45,13 +43,13 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
|
timeoutSetting = "SWRP_TIMEOUT_MS"
|
||||||
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
defaultDisposition = "SWRP_DEFAULT_DISPOSITION"
|
||||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
maxRequestSize = "SWRP_MAX_REQUEST_SIZE"
|
||||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers the tests set and look at.
|
// The headers the tests set and look at.
|
||||||
@@ -347,17 +345,6 @@ func startServer(
|
|||||||
return addr, out, server
|
return addr, out, server
|
||||||
}
|
}
|
||||||
|
|
||||||
// viperFrom returns a viper Set with each of settings, as the viper
|
|
||||||
// config.NewViper returns one bound to a process's environment.
|
|
||||||
func viperFrom(settings map[string]string) *viper.Viper {
|
|
||||||
v := viper.New()
|
|
||||||
for name, value := range settings {
|
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// startServerLogging is startServer with rproxy's request lines going to
|
// startServerLogging is startServer with rproxy's request lines going to
|
||||||
// requestLog and its process lines to processLog.
|
// requestLog and its process lines to processLog.
|
||||||
func startServerLogging(
|
func startServerLogging(
|
||||||
@@ -367,11 +354,15 @@ func startServerLogging(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(viperFrom(settings))
|
cfg, err := config.RProxyFromEnvironment(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package rules reads the rule files: the plain text files in
|
// Package rules reads the rule files: the plain text files in
|
||||||
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
|
// SWW_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_WAF_RULES_DIR must go without a change before
|
// quietTime is how long SWW_RULES_DIR must go without a change before
|
||||||
// the rule files are read again, so that a file still being written, such
|
// the rule files are read again, so that a file still being written, such
|
||||||
// as one saved in place, appended to or copied in with scp, is read only
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
// once whole.
|
// once whole.
|
||||||
@@ -100,9 +100,9 @@ type Rule struct {
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
|
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
|
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
||||||
// and no rule loaded.
|
// and no rule loaded.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
// ProcessLog receives how many rules were read, and the error in a
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
|||||||
func read(dir string) ([]Rule, string, error) {
|
func read(dir string) ([]Rule, string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dir, fmt.Errorf("SWW_WAF_RULES_DIR cannot be read: %w", err)
|
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []Rule
|
var rules []Rule
|
||||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
|||||||
// readFile appends the rules of the rule file at path to rules. places
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
// are where each id read so far is, and gain those of the file.
|
// are where each id read so far is, and gain those of the file.
|
||||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|||||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
|
// SWW_RULES_DIR does not exist, which would stop the start.
|
||||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
params.Enabled = false
|
params.Enabled = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package state keeps sww's state in JSON files in
|
// Package state keeps sww's state in JSON files in
|
||||||
// SWW_WAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
||||||
// and docs/state-files.md lists them:
|
// and docs/state-files.md lists them:
|
||||||
// bans.json holds the bans, clients.json each client's counters and
|
// bans.json holds the bans, clients.json each client's counters and
|
||||||
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||||
@@ -72,11 +72,11 @@ var (
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the state files (SWW_WAF_STATE_DIR).
|
// Dir is the directory of the state files (SWW_STATE_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// WriteDelay is how long after a ban is made bans.json is written
|
// WriteDelay is how long after a ban is made bans.json is written
|
||||||
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
// (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file
|
||||||
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
|
// is (SWW_STATE_COUNTER_INTERVAL).
|
||||||
WriteDelay time.Duration
|
WriteDelay time.Duration
|
||||||
CounterInterval time.Duration
|
CounterInterval time.Duration
|
||||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
||||||
@@ -185,7 +185,7 @@ type stateFile interface {
|
|||||||
func Load(params Params) (*Files, error) {
|
func Load(params Params) (*Files, error) {
|
||||||
err := checkWritable(params.Dir)
|
err := checkWritable(params.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_WAF_STATE_DIR cannot be written: %w", err)
|
return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||||
@@ -362,7 +362,7 @@ func (f *Files) read(name string) (int, error) {
|
|||||||
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||||
path := filepath.Join(f.params.Dir, name)
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_WAF_STATE_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1035,7 @@ func write(dir, name string, data []byte) error {
|
|||||||
|
|
||||||
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
||||||
func syncDirectory(dir string) error {
|
func syncDirectory(dir string) error {
|
||||||
directory, err := os.Open(dir) //nolint:gosec // SWW_WAF_STATE_DIR itself
|
directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1045,7 @@ func syncDirectory(dir string) error {
|
|||||||
|
|
||||||
// writeSynced writes data to the file at path, and syncs it to the disk.
|
// writeSynced writes data to the file at path, and syncs it to the disk.
|
||||||
func writeSynced(path string, data []byte) error {
|
func writeSynced(path string, data []byte) error {
|
||||||
//nolint:gosec // a state file's temporary file, in SWW_WAF_STATE_DIR
|
//nolint:gosec // a state file's temporary file, in SWW_STATE_DIR
|
||||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -931,7 +931,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
|
|||||||
filepath.Join(t.TempDir(), "missing"),
|
filepath.Join(t.TempDir(), "missing"),
|
||||||
notADirectory,
|
notADirectory,
|
||||||
} {
|
} {
|
||||||
const want = "SWW_WAF_STATE_DIR cannot be written: "
|
const want = "SWW_STATE_DIR cannot be written: "
|
||||||
|
|
||||||
_, err := state.Load(newParams(dir))
|
_, err := state.Load(newParams(dir))
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||||
|
|||||||
+15
-13
@@ -10,8 +10,6 @@ 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/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
)
|
)
|
||||||
@@ -23,17 +21,17 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
|
|||||||
|
|
||||||
// HealthCheck is the container's health check. In discriminator's container, where
|
// HealthCheck is the container's health check. In discriminator's container, where
|
||||||
// SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
|
// SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
|
||||||
// endpoint on 127.0.0.1, at the port in SWW_WAF_LISTEN_ADDR. In rproxy's container,
|
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
|
||||||
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
|
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
|
||||||
// answers its health endpoint on 127.0.0.1, at the port in SWW_RPROXY_LISTEN_ADDR, and
|
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
|
||||||
// the app accepts connections at the address in SWW_RPROXY_UPSTREAM_URL. Otherwise,
|
// the app accepts connections at the address in SWRP_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, args []string, v *viper.Viper,
|
ctx context.Context, 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 +44,15 @@ func HealthCheck(
|
|||||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
command, err := config.Command(v)
|
command, err := config.Command(lookupEnv)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
err = fmt.Errorf("invalid setting: %w", err)
|
err = fmt.Errorf("invalid setting: %w", err)
|
||||||
case command == "discriminator":
|
case command == "discriminator":
|
||||||
err = checkDiscriminator(ctx, v)
|
err = checkDiscriminator(ctx, lookupEnv)
|
||||||
default:
|
default:
|
||||||
err = checkRProxyAndApp(ctx, v)
|
err = checkRProxyAndApp(ctx, lookupEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,8 +65,10 @@ func HealthCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkDiscriminator asks discriminator's health endpoint.
|
// checkDiscriminator asks discriminator's health endpoint.
|
||||||
func checkDiscriminator(ctx context.Context, v *viper.Viper) error {
|
func checkDiscriminator(
|
||||||
listenAddr, err := config.DiscriminatorListenAddr(v)
|
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||||
|
) error {
|
||||||
|
listenAddr, err := config.DiscriminatorListenAddr(lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid setting: %w", err)
|
return fmt.Errorf("invalid setting: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,8 +82,10 @@ func checkDiscriminator(ctx context.Context, v *viper.Viper) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
|
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
|
||||||
func checkRProxyAndApp(ctx context.Context, v *viper.Viper) error {
|
func checkRProxyAndApp(
|
||||||
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v)
|
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||||
|
) 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
|
|||||||
// or invalid token file, or an invalid value of another setting, does not
|
// or invalid token file, or an invalid value of another setting, does not
|
||||||
// fail it.
|
// fail it.
|
||||||
for _, other := range []struct{ name, value string }{
|
for _, other := range []struct{ name, value string }{
|
||||||
{"SWW_WAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
{"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
||||||
{"SWW_WAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
{"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
||||||
{"SWW_WAF_MODE", "neither"},
|
{"SWW_MODE", "neither"},
|
||||||
} {
|
} {
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: discriminatorCommand,
|
commandSetting: discriminatorCommand,
|
||||||
@@ -72,7 +72,7 @@ func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
|
|||||||
}, 1, "unhealthy: ask discriminator: ")
|
}, 1, "unhealthy: ask discriminator: ")
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: discriminatorCommand, listenAddr: "8080",
|
commandSetting: discriminatorCommand, listenAddr: "8080",
|
||||||
}, 1, "unhealthy: invalid setting: SWW_WAF_LISTEN_ADDR: ")
|
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
|
||||||
|
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
0, "")
|
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
|
||||||
// SWW_RPROXY_WAF_BACKEND being unset.
|
// SWRP_WAF_BACKEND being unset.
|
||||||
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
|
wantHealthCheck(t, settings("SWRP_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.
|
||||||
@@ -123,14 +123,13 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
// itself.
|
// itself.
|
||||||
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
|
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
|
||||||
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
|
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
|
||||||
"unhealthy: invalid setting: SWW_RPROXY_LISTEN_ADDR: ")
|
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ")
|
||||||
|
|
||||||
// A setting of rproxy's can be given as a file too, here the listen
|
// No setting of rproxy's has a form given as a file.
|
||||||
// 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,
|
||||||
}, 0, "")
|
}, 1, "unhealthy: ask rproxy: ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
||||||
@@ -146,7 +145,11 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
|||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
got := sww.HealthCheck(t.Context(), []string{"now"}, viperFrom(nil), &stderr)
|
noSettings := func(string) (string, bool) {
|
||||||
|
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 {
|
||||||
@@ -163,7 +166,11 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
|
|||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
got := sww.HealthCheck(t.Context(), nil, viperFrom(env), &stderr)
|
got := sww.HealthCheck(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) ||
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||||
// requests until ctx is done. Then it gives the requests in progress
|
// requests until ctx is done. Then it gives the requests in progress
|
||||||
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
|
// shutdownTimeout to finish, and the reports being sent SWRP_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.
|
||||||
@@ -23,7 +23,7 @@ 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(params.Stdout, hostname, slog.LevelInfo)
|
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
|
||||||
|
|
||||||
cfg, err := config.RProxyFromEnvironment(params.Viper)
|
cfg, err := config.RProxyFromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("invalid setting", "error", err.Error())
|
processLog.Error("invalid setting", "error", err.Error())
|
||||||
|
|
||||||
@@ -38,7 +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("cannot listen on SWW_RPROXY_LISTEN_ADDR", "error", err.Error())
|
processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -25,13 +25,13 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// wafBackend is the setting that names discriminator to rproxy.
|
// wafBackend is the setting that names discriminator to rproxy.
|
||||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
// rproxyListenAddr is the setting that says where rproxy listens.
|
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||||
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||||
// rproxyUpstreamURL is the setting that says where the app is.
|
// rproxyUpstreamURL is the setting that says where the app is.
|
||||||
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
||||||
// rproxyTimeout is the setting that says how long rproxy waits for discriminator.
|
// rproxyTimeout is the setting that says how long rproxy waits for discriminator.
|
||||||
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
|
rproxyTimeout = "SWRP_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,7 +43,11 @@ 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,
|
||||||
Viper: viperFrom(env),
|
LookupEnv: func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
},
|
||||||
Stdout: out,
|
Stdout: out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -61,8 +65,8 @@ 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",
|
||||||
"SWW_RPROXY_UPSTREAM_URL": appURL,
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
"SWW_RPROXY_TRUSTED_PROXIES": localhost + "/32",
|
"SWRP_TRUSTED_PROXIES": localhost + "/32",
|
||||||
wafBackend: backend,
|
wafBackend: backend,
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
@@ -433,7 +437,7 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output
|
|||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
commandForMain+"=rproxy",
|
commandForMain+"=rproxy",
|
||||||
"SWW_RPROXY_LISTEN_ADDR="+localhost+":0")
|
"SWRP_LISTEN_ADDR="+localhost+":0")
|
||||||
cmd.Env = append(cmd.Env, env...)
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
|
||||||
err = cmd.Start()
|
err = cmd.Start()
|
||||||
@@ -481,7 +485,7 @@ func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
|||||||
|
|
||||||
addr, _, _ := startRProxyProcess(t,
|
addr, _, _ := startRProxyProcess(t,
|
||||||
wafBackend+"="+backend.URL,
|
wafBackend+"="+backend.URL,
|
||||||
"SWW_RPROXY_UPSTREAM_URL="+startApp(t),
|
"SWRP_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{
|
||||||
@@ -547,7 +551,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
rproxyListenAddr: localhost + ":0",
|
rproxyListenAddr: localhost + ":0",
|
||||||
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
"SWRP_UPSTREAM_URL": startApp(t),
|
||||||
rproxyTimeout: "60000",
|
rproxyTimeout: "60000",
|
||||||
wafBackend: backend.URL,
|
wafBackend: backend.URL,
|
||||||
}
|
}
|
||||||
@@ -623,7 +627,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
cutOffAfter = 5 * time.Second
|
cutOffAfter = 5 * time.Second
|
||||||
upstreamSetting = "SWW_RPROXY_UPSTREAM_URL"
|
upstreamSetting = "SWRP_UPSTREAM_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
asked := make(chan struct{})
|
asked := make(chan struct{})
|
||||||
@@ -745,7 +749,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 SWW_RPROXY_LISTEN_ADDR")
|
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainRunsRProxy(t *testing.T) {
|
func TestMainRunsRProxy(t *testing.T) {
|
||||||
|
|||||||
+12
-15
@@ -20,8 +20,6 @@ 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/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
@@ -40,7 +38,7 @@ import (
|
|||||||
const shutdownTimeout = 5 * time.Second
|
const shutdownTimeout = 5 * time.Second
|
||||||
|
|
||||||
// remoteLogStopTimeout is how long, as discriminator stops, the log lines still
|
// remoteLogStopTimeout is how long, as discriminator stops, the log lines still
|
||||||
// waiting are sent to SWW_WAF_LOG_REMOTE_URL before they are given up. stdout has
|
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
|
||||||
// carried them.
|
// carried them.
|
||||||
const remoteLogStopTimeout = 2 * time.Second
|
const remoteLogStopTimeout = 2 * time.Second
|
||||||
|
|
||||||
@@ -61,9 +59,8 @@ 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
|
||||||
// Viper reads the settings, normally the viper config.NewViper returns
|
// LookupEnv reads an environment variable, normally os.LookupEnv.
|
||||||
// for the process's environment.
|
LookupEnv func(string) (string, bool)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -82,7 +79,7 @@ func Main(version string) int {
|
|||||||
syscall.SIGTERM, os.Interrupt)
|
syscall.SIGTERM, os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout}
|
params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "rproxy":
|
case "rproxy":
|
||||||
@@ -90,7 +87,7 @@ func Main(version string) int {
|
|||||||
case "discriminator":
|
case "discriminator":
|
||||||
return RunDiscriminator(ctx, params)
|
return RunDiscriminator(ctx, params)
|
||||||
case "healthcheck":
|
case "healthcheck":
|
||||||
return HealthCheck(ctx, args[1:], params.Viper, os.Stderr)
|
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
|
||||||
default:
|
default:
|
||||||
return printUsage(os.Stderr)
|
return printUsage(os.Stderr)
|
||||||
}
|
}
|
||||||
@@ -108,18 +105,18 @@ func printUsage(w io.Writer) int {
|
|||||||
// done. It returns the process's exit status, 1 when discriminator cannot start.
|
// done. It returns the process's exit status, 1 when discriminator cannot start.
|
||||||
func RunDiscriminator(ctx context.Context, params Params) int {
|
func RunDiscriminator(ctx context.Context, params Params) int {
|
||||||
// Until the settings are read, the one message is an invalid setting's
|
// Until the settings are read, the one message is an invalid setting's
|
||||||
// error, which every SWW_WAF_LOG_LEVEL lets through.
|
// error, which every SWW_LOG_LEVEL lets through.
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
config.InstanceName(params.Viper), slog.LevelError)
|
config.InstanceName(params.LookupEnv), slog.LevelError)
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(params.Viper)
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("invalid setting", "error", err.Error())
|
processLog.Error("invalid setting", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// While SWW_WAF_LOG_REMOTE_URL is set, every line on stdout from here on
|
// While SWW_LOG_REMOTE_URL is set, every line on stdout from here on
|
||||||
// is sent there too.
|
// is sent there too.
|
||||||
stdout := params.Stdout
|
stdout := params.Stdout
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
|
|||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot listen on SWW_WAF_LISTEN_ADDR", "error", err.Error())
|
processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error())
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -195,7 +192,7 @@ func RunDiscriminator(ctx context.Context, params Params) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newServer returns the server discriminator runs, with the metrics of the alerts,
|
// newServer returns the server discriminator runs, with the metrics of the alerts,
|
||||||
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
|
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
|
||||||
// database that cannot be read is an error.
|
// database that cannot be read is an error.
|
||||||
func newServer(
|
func newServer(
|
||||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||||
@@ -284,7 +281,7 @@ func newAlertQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newRemoteLogSender returns a sender of the log lines to
|
// newRemoteLogSender returns a sender of the log lines to
|
||||||
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
// SWW_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,
|
||||||
|
|||||||
+72
-84
@@ -19,8 +19,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
@@ -36,21 +34,21 @@ const (
|
|||||||
testVersion = "test"
|
testVersion = "test"
|
||||||
// localhost is where the tests listen.
|
// localhost is where the tests listen.
|
||||||
localhost = "127.0.0.1"
|
localhost = "127.0.0.1"
|
||||||
listenAddr = "SWW_WAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
stateDir = "SWW_WAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
rulesDir = "SWW_WAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
instanceName = "SWW_WAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
|
// instance is the SWW_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.
|
||||||
@@ -125,29 +123,20 @@ func (o *output) text() string {
|
|||||||
return o.buf.String()
|
return o.buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// viperFrom returns a viper Set with each of settings, as the viper
|
|
||||||
// config.NewViper returns one bound to a process's environment.
|
|
||||||
func viperFrom(settings map[string]string) *viper.Viper {
|
|
||||||
v := viper.New()
|
|
||||||
for name, value := range settings {
|
|
||||||
v.Set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// run runs `sww discriminator` with the settings in env until ctx is done,
|
// run runs `sww discriminator` with the settings in env until ctx is done,
|
||||||
// and returns its exit status. SWW_WAF_LOOKUP_SOURCE is off unless env sets it,
|
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
|
||||||
// so that no test sends GeoJS its clients' addresses.
|
// so that no test sends GeoJS its clients' addresses.
|
||||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||||
v := viperFrom(env)
|
|
||||||
if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set {
|
|
||||||
v.Set("SWW_WAF_LOOKUP_SOURCE", "off")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sww.RunDiscriminator(ctx, sww.Params{
|
return sww.RunDiscriminator(ctx, sww.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
Viper: v,
|
LookupEnv: func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
if !ok && name == "SWW_LOOKUP_SOURCE" {
|
||||||
|
return "off", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
},
|
||||||
Stdout: out,
|
Stdout: out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -158,7 +147,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
"SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots",
|
"SWW_BYTES_LIMIT_PER_MINUTE": "lots",
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -170,7 +159,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
if line["type"] != "process" || line["instance"] != instance ||
|
if line["type"] != "process" || line["instance"] != instance ||
|
||||||
line["level"] != "ERROR" ||
|
line["level"] != "ERROR" ||
|
||||||
!strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") {
|
!strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
|
||||||
t.Errorf("start refused with %v", line)
|
t.Errorf("start refused with %v", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +215,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR")
|
out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServesUntilToldToStop(t *testing.T) {
|
func TestServesUntilToldToStop(t *testing.T) {
|
||||||
@@ -292,8 +281,8 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
||||||
"SWW_WAF_LOG_LEVEL": "warn",
|
"SWW_LOG_LEVEL": "warn",
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -396,7 +385,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
|
const scope = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
@@ -516,7 +505,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
rulesDir: dir,
|
rulesDir: dir,
|
||||||
// The requests sent until the rule takes effect must not break a
|
// The requests sent until the rule takes effect must not break a
|
||||||
// rate limit, whose ban would refuse them too.
|
// rate limit, whose ban would refuse them too.
|
||||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
|
"SWW_RATE_LIMIT_PER_MINUTE": "off",
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -559,7 +548,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
|
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,14 +560,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
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_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
"SWW_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_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
|
want := "SWW_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 {
|
||||||
@@ -603,8 +592,7 @@ 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_CORE_RULE_SET_MODE": config.WAFModeOff,
|
"SWW_WAF_MODE": config.WAFModeOff, "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("%s: exit status %d, want 1", path, status)
|
t.Fatalf("%s: exit status %d, want 1", path, status)
|
||||||
@@ -630,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWW_WAF_DENIED_COUNTRIES": "kp",
|
"SWW_DENIED_COUNTRIES": "kp",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
@@ -725,7 +713,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWW_WAF_BLOCKLIST_URLS": torURL,
|
"SWW_BLOCKLIST_URLS": torURL,
|
||||||
// The requests sent until the list takes effect, and those for the
|
// The requests sent until the list takes effect, and those for the
|
||||||
// metrics, must not break a rate limit, whose ban would refuse them
|
// metrics, must not break a rate limit, whose ban would refuse them
|
||||||
// too.
|
// too.
|
||||||
@@ -741,12 +729,12 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Restarted with drop.txt named after it and the server answering, tor.txt
|
// Restarted with drop.txt named after it and the server answering, tor.txt
|
||||||
// waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
|
// waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
|
||||||
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
||||||
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
||||||
failing.Store(false)
|
failing.Store(false)
|
||||||
|
|
||||||
env["SWW_WAF_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
for statusFrom(t, url, placed) != http.StatusForbidden {
|
for statusFrom(t, url, placed) != http.StatusForbidden {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
@@ -805,8 +793,8 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
|
"SWW_CROWDSEC_LAPI_URL": engine.URL,
|
||||||
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
"SWW_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the list is fetched, the client's request bans it.
|
// Once the list is fetched, the client's request bans it.
|
||||||
@@ -879,7 +867,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + path +
|
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
|
||||||
": no such file or directory"
|
": no such file or directory"
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the lookup database")
|
line := out.line(t, "msg", "cannot use the lookup database")
|
||||||
@@ -904,7 +892,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -959,8 +947,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
||||||
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
|
"SWW_LOG_REMOTE_BUFFER": "1",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
@@ -1018,8 +1006,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: rules,
|
rulesDir: rules,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
|
"SWW_ALERT_WEBHOOK_URL": webhook.url,
|
||||||
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,7 +1056,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
|
|
||||||
webhook.failing.Store(false)
|
webhook.failing.Store(false)
|
||||||
|
|
||||||
env["SWW_WAF_ALLOW_NETS"] = localhost
|
env["SWW_ALLOW_NETS"] = localhost
|
||||||
env[metricsToken] = token
|
env[metricsToken] = token
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1104,12 +1092,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||||
"SWW_WAF_ALLOW_NETS": localhost + "/32",
|
"SWW_ALLOW_NETS": localhost + "/32",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
||||||
"SWW_WAF_ALERT_NTFY_URL": ntfy.url,
|
"SWW_ALERT_NTFY_URL": ntfy.url,
|
||||||
"SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken,
|
"SWW_ALERT_NTFY_TOKEN": ntfyToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1174,7 +1162,7 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
||||||
"SWW_WAF_STATE_DIR cannot be written: ")
|
"SWW_STATE_DIR cannot be written: ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantStartRefused runs sww with its state files in dir, and
|
// wantStartRefused runs sww with its state files in dir, and
|
||||||
@@ -1282,26 +1270,26 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
"SWW_WAF_MODE": "enforce",
|
"SWW_MODE": "enforce",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
"SWW_WAF_ALLOW_NETS": "",
|
"SWW_ALLOW_NETS": "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
"SWW_WAF_DENY_NETS": "",
|
"SWW_DENY_NETS": "",
|
||||||
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "120",
|
"SWW_RATE_LIMIT_PER_MINUTE": "120",
|
||||||
"SWW_WAF_RATE_LIMIT_PER_HOUR": "1500",
|
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
"SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWW_WAF_DENIED_COUNTRIES": "",
|
"SWW_DENIED_COUNTRIES": "",
|
||||||
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
"SWW_WAF_BAN_RESPONSE": "403",
|
"SWW_BAN_RESPONSE": "403",
|
||||||
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
|
"SWW_LIMIT_BAN_DURATION": "1h",
|
||||||
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||||
"SWW_WAF_MAX_BAN_DURATION": "7d",
|
"SWW_MAX_BAN_DURATION": "7d",
|
||||||
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
|
"SWW_ATTACK_BAN_DURATION": "7d",
|
||||||
"SWW_WAF_MAX_BANS": "5000",
|
"SWW_MAX_BANS": "5000",
|
||||||
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
|
"SWW_BAN_SCOPE_V4_PREFIX": "32",
|
||||||
"SWW_WAF_RULES_ENABLED": "true",
|
"SWW_RULES_ENABLED": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
@@ -1501,7 +1489,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantRefused checks that discriminator at url refuses a request from localhost with
|
// wantRefused checks that discriminator at url refuses a request from localhost with
|
||||||
// 403, the default SWW_WAF_BAN_RESPONSE.
|
// 403, the default SWW_BAN_RESPONSE.
|
||||||
func wantRefused(t *testing.T, url string) {
|
func wantRefused(t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1542,8 +1530,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
|
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
||||||
// 200.
|
// 200.
|
||||||
type destination struct {
|
type destination struct {
|
||||||
url string
|
url string
|
||||||
@@ -1602,7 +1590,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhook is a stand-in for SWW_WAF_ALERT_WEBHOOK_URL. It notes each alert
|
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
|
||||||
// it is sent, and answers 204, or 503 while failing.
|
// it is sent, and answers 204, or 503 while failing.
|
||||||
type webhook struct {
|
type webhook struct {
|
||||||
url string
|
url string
|
||||||
|
|||||||
+52
-7
@@ -1,8 +1,51 @@
|
|||||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
# They are the fields where gitea sends a search, a file name or a branch name.
|
||||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
# Each exclusion runs with the request, before the rules it switches off, those
|
||||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
||||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
# Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# A request whose hostname SWW_APPS does not list gitea for skips every rule
|
||||||
|
# below. Inspect sets tx.app_gitea on the others.
|
||||||
|
SecRule &TX:app_gitea "@eq 0" \
|
||||||
|
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||||
|
|
||||||
|
# The query parameters in which gitea sends names within a repository or its
|
||||||
|
# own records, or a page of its own site, are not checked against the lists of
|
||||||
|
# system files, shell paths and command names, on any path:
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 932160: bin/docker-entrypoint
|
||||||
|
# 932260: docker-build
|
||||||
|
SecAction "id:900591,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||||
|
|
||||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# text searched for in q, which skips these rules there:
|
# text searched for in q, which skips these rules there:
|
||||||
@@ -155,8 +198,8 @@ SecRule REQUEST_FILENAME \
|
|||||||
# 942140: information_schema.sql
|
# 942140: information_schema.sql
|
||||||
# 944110: java/lang/Runtime.java
|
# 944110: java/lang/Runtime.java
|
||||||
# 944130: java.lang.String.md
|
# 944130: java.lang.String.md
|
||||||
# Each request skips the rules that refused it. The fourth change already has
|
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
# A file's commit graph, in file.
|
# A file's commit graph, in file.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
@@ -281,3 +324,5 @@ SecRule REQUEST_FILENAME \
|
|||||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||||
"id:900640,phase:1,pass,nolog,\
|
"id:900640,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
|
|
||||||
|
SecMarker END_GITEA
|
||||||
+109
-65
@@ -1,9 +1,9 @@
|
|||||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the eight changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
||||||
// makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||||
// response.
|
// in SPEC.md describes them. It reads no response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -32,16 +34,16 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second, and
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||||
// giteaExclusions for the third.
|
// directives of every app in apps for the third.
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_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_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
|
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||||
SecRuleEngine DetectionOnly
|
SecRuleEngine DetectionOnly
|
||||||
SecRequestBodyAccess Off
|
SecRequestBodyAccess Off
|
||||||
@@ -81,44 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# before the rules of phase 1 too.
|
# before the rules of phase 1 too.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The eighth change comes before the rules for the same reason.
|
# The apps' fixes come before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The fifth: only the rules for requests are loaded, and no response is
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
# localhost. Coraza matches a parameter name here without regard to case.
|
||||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
||||||
# body Coraza reads as well as the query parameters, so a field of one of
|
# well as the query parameters, so a field of this name is left out too.
|
||||||
# these names is left out too.
|
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||||
|
|
||||||
# The fourth: the query parameters in which gitea sends names within a
|
# The fourth: Referer is not checked for a Unix command without arguments,
|
||||||
# repository or its own records, or a page of its own site, are not
|
# for Java starting a process, or for a Java class name. 944130 refused
|
||||||
# checked against the lists of system files, shell paths and command
|
# every page opened from the page of java.lang.String.md.
|
||||||
# names. Coraza takes one rule id per directive.
|
|
||||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
|
|
||||||
# The fifth, for Referer: it is not checked for a Unix command without
|
|
||||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
|
||||||
# refused every page opened from the page of java.lang.String.md. The
|
|
||||||
# cookies are left out in Inspect.
|
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
# The seventh: a container image push that reuses a layer names the image
|
# The sixth: a container image push that reuses a layer names the image
|
||||||
# it takes the layer from in the query parameter from, which is not
|
# it takes the layer from in the query parameter from, which is not
|
||||||
# checked for command names. 932260 refused the push of a layer from
|
# checked for command names. 932260 refused the push of a layer from
|
||||||
# owner/python3-app.
|
# owner/python3-app.
|
||||||
@@ -168,33 +154,55 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||||
`
|
`
|
||||||
|
|
||||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
// with the fixes that let them pass.
|
||||||
// directives.
|
type app struct {
|
||||||
//
|
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||||
//go:embed gitea.conf
|
// rules. Their first rule skips the rest unless Inspect sets the
|
||||||
var giteaExclusions string
|
// transaction variable app_ and the app's name, and they end with the
|
||||||
|
// marker it skips to.
|
||||||
|
directives string
|
||||||
|
// cookies are the cookies the Core Rule Set reads a request without.
|
||||||
|
cookies []string
|
||||||
|
}
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
//go:embed gitea.conf
|
||||||
// without, the rest of the fifth change.
|
var giteaDirectives string
|
||||||
|
|
||||||
|
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a map
|
||||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
var apps = map[string]app{
|
||||||
|
"gitea": {
|
||||||
|
directives: giteaDirectives,
|
||||||
|
cookies: []string{"gitea_flash", "redirect_to"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppNames returns the names of the apps whose fixes sww has, in
|
||||||
|
// alphabetical order.
|
||||||
|
func AppNames() []string {
|
||||||
|
return slices.Sorted(maps.Keys(apps))
|
||||||
|
}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// ParanoiaLevel is SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4.
|
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
|
||||||
ParanoiaLevel int
|
ParanoiaLevel int
|
||||||
// DisabledRules are the ids of the rules switched off
|
// DisabledRules are the ids of the rules switched off
|
||||||
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
// (SWW_WAF_DISABLED_RULES).
|
||||||
DisabledRules []int
|
DisabledRules []int
|
||||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||||
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||||
BodyLimit int64
|
BodyLimit int64
|
||||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
// ExclusionsFile is the path of a file of exclusions loaded before the
|
||||||
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
||||||
|
// apps only, or of every app while Apps is nil.
|
||||||
|
Apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of sww's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
@@ -218,7 +226,7 @@ var errSwwRuleID = errors.New("an id of sww's own rules")
|
|||||||
// 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("the Core Rule Set's rules load after the file; " +
|
var errChangesNoRule = errors.New("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
|
||||||
@@ -286,13 +294,15 @@ func checkExclusions(exclusions string) error {
|
|||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
waf coraza.WAF
|
waf coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
|
apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the eight changes, at params'
|
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// at params' paranoia level, without the rules it switches off, reading
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// request bodies up to params' limit, and with params' exclusions file
|
||||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||||
|
// it.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -315,8 +325,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fixes strings.Builder
|
||||||
|
for _, name := range AppNames() {
|
||||||
|
fixes.WriteString(apps[name].directives + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions)
|
fixes.String())
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -334,7 +349,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit, apps: params.Apps}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -346,11 +361,12 @@ type Result struct {
|
|||||||
Score int
|
Score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||||
// method, its URL with the query, and its headers, the Cookie header
|
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
// with the query, and its headers, the Cookie header without those apps'
|
||||||
// caller has it, as readBody reads it. It returns what it found, what it
|
// cookies, and on body, r's body as the caller has it, as readBody reads
|
||||||
// read of body, and the error that ended the reading early, if one did.
|
// it. It returns what it found, what it read of body, and the error that
|
||||||
|
// ended the reading early, if one did.
|
||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
@@ -358,13 +374,24 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
|
// The transaction's variables are set only through Coraza's interface
|
||||||
|
// for plugins.
|
||||||
|
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||||
|
|
||||||
|
var cookiesNotRead []string
|
||||||
|
|
||||||
|
for _, name := range c.appsFor(r.Host) {
|
||||||
|
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||||
|
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||||
|
|
||||||
for name, values := range r.Header {
|
for name, values := range r.Header {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if name == "Cookie" {
|
if name == "Cookie" {
|
||||||
value = withoutCookiesNotRead(value)
|
value = withoutCookies(value, cookiesNotRead)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue // it held those cookies alone
|
continue // it held those cookies alone
|
||||||
}
|
}
|
||||||
@@ -404,7 +431,7 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readBody reads body, the body of the request in tx, which has run on
|
// readBody reads body, the body of the request in tx, which has run on
|
||||||
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
|
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
|
||||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||||
// when they are no longer than that, since they cannot be read in part.
|
// when they are no longer than that, since they cannot be read in part.
|
||||||
@@ -457,9 +484,26 @@ func score(tx types.Transaction) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
// appsFor returns the names of the apps whose fixes a request for host,
|
||||||
// cookies in cookiesNotRead.
|
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||||
func withoutCookiesNotRead(value string) string {
|
// those SWW_APPS lists for host's name, without the port and in
|
||||||
|
// lowercase.
|
||||||
|
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||||
|
if c.apps == nil {
|
||||||
|
return AppNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
hostname = host // it has no port
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.apps[strings.ToLower(hostname)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||||
|
// named in cookiesNotRead.
|
||||||
|
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
for cookie := range strings.SplitSeq(value, ";") {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultDisabledRules are the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off
|
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
||||||
// by default.
|
// by default.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -120,8 +120,8 @@ func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a gitea request one of
|
// wantChange checks that crs lets through passes, a gitea request one of
|
||||||
// the eight changes is for, and still finds result in refused, a request
|
// the six changes or gitea's fixes are for, and still finds result in
|
||||||
// like it that the change is not for.
|
// refused, a request like it that they are not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
||||||
) {
|
) {
|
||||||
@@ -381,6 +381,96 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|||||||
matched(930120, 932160))
|
matched(930120, 932160))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
||||||
|
// for each kind of fix, each with what the Core Rule Set finds in it
|
||||||
|
// without them.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var giteaFixed = []struct {
|
||||||
|
request request
|
||||||
|
result waf.Result
|
||||||
|
}{
|
||||||
|
// A search of code, on the path of gitea's search.
|
||||||
|
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
||||||
|
// A file name, in a parameter gitea sends names in, on any path.
|
||||||
|
{get("/?path=.gitignore"), matched(930120)},
|
||||||
|
// A file name, in a cookie of gitea's.
|
||||||
|
{
|
||||||
|
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
||||||
|
matched(930120),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
||||||
|
// one that serves another app.
|
||||||
|
const (
|
||||||
|
giteaHost = "git.example"
|
||||||
|
otherHost = "blog.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withHost returns r sent to host.
|
||||||
|
func withHost(r request, host string) request {
|
||||||
|
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
||||||
|
// giving apps.
|
||||||
|
func servingApps(t *testing.T, apps map[string][]string) *waf.CoreRuleSet {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
crs, err := waf.New(waf.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the Core Rule Set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return crs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, nil)
|
||||||
|
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
for _, host := range []string{giteaHost, otherHost} {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{giteaHost: {"gitea"}})
|
||||||
|
|
||||||
|
// The hostname is matched without the port and without regard to case.
|
||||||
|
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), waf.Result{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, apps := range []map[string][]string{
|
||||||
|
{giteaHost: {"gitea"}},
|
||||||
|
// SWW_APPS set empty lists no hostname.
|
||||||
|
{},
|
||||||
|
} {
|
||||||
|
crs := servingApps(t, apps)
|
||||||
|
|
||||||
|
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||||
// exclusions file at path.
|
// exclusions file at path.
|
||||||
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
||||||
@@ -525,7 +615,7 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -550,7 +640,7 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
|
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||||
const bodyLimit = 8 << 10
|
const bodyLimit = 8 << 10
|
||||||
|
|
||||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||||
|
|||||||
+16
-16
@@ -16,10 +16,10 @@
|
|||||||
# - that a new discriminator container on the same volume still refuses the banned
|
# - that a new discriminator container on the same volume still refuses the banned
|
||||||
# client;
|
# client;
|
||||||
# - that a discriminator container with its state files on a new volume mounted at
|
# - that a discriminator container with its state files on a new volume mounted at
|
||||||
# the directory SWW_WAF_STATE_DIR_FILE names becomes healthy;
|
# the directory SWW_STATE_DIR_FILE names becomes healthy;
|
||||||
# - that one whose SWW_WAF_STATE_DIR does not exist logs discriminator's message
|
# - that one whose SWW_STATE_DIR does not exist logs discriminator's message
|
||||||
# about it.
|
# about it.
|
||||||
# discriminator runs with SWW_WAF_LOOKUP_SOURCE=off, so that no address is sent to
|
# discriminator runs with SWW_LOOKUP_SOURCE=off, so that no address is sent to
|
||||||
# GeoJS. The containers, the network, the volumes and both images are removed
|
# GeoJS. The containers, the network, the volumes and both images are removed
|
||||||
# however the script ends. Building the app needs network access, for nixpkgs'
|
# however the script ends. Building the app needs network access, for nixpkgs'
|
||||||
# binary cache. script/check does not run this.
|
# binary cache. script/check does not run this.
|
||||||
@@ -37,7 +37,7 @@ NETWORK="$NAME"
|
|||||||
APP="$NAME-app-container"
|
APP="$NAME-app-container"
|
||||||
DISCRIMINATOR="$NAME-discriminator"
|
DISCRIMINATOR="$NAME-discriminator"
|
||||||
VOLUME="$NAME-state"
|
VOLUME="$NAME-state"
|
||||||
# The volumes of the container whose state directory SWW_WAF_STATE_DIR_FILE
|
# The volumes of the container whose state directory SWW_STATE_DIR_FILE
|
||||||
# names: its state files, and the file that names their directory.
|
# names: its state files, and the file that names their directory.
|
||||||
MOVED_VOLUME="$NAME-moved-state"
|
MOVED_VOLUME="$NAME-moved-state"
|
||||||
SETTINGS_VOLUME="$NAME-settings"
|
SETTINGS_VOLUME="$NAME-settings"
|
||||||
@@ -94,8 +94,8 @@ start_discriminator() {
|
|||||||
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
||||||
--volume "$VOLUME:/var/lib/sww" \
|
--volume "$VOLUME:/var/lib/sww" \
|
||||||
--env SWW_COMMAND=discriminator \
|
--env SWW_COMMAND=discriminator \
|
||||||
--env SWW_WAF_RATE_LIMIT_PER_MINUTE=1 \
|
--env SWW_RATE_LIMIT_PER_MINUTE=1 \
|
||||||
--env SWW_WAF_LOOKUP_SOURCE=off \
|
--env SWW_LOOKUP_SOURCE=off \
|
||||||
"$IMAGE" >/dev/null
|
"$IMAGE" >/dev/null
|
||||||
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
||||||
}
|
}
|
||||||
@@ -104,14 +104,14 @@ start_discriminator() {
|
|||||||
# healthy.
|
# healthy.
|
||||||
start_app() {
|
start_app() {
|
||||||
docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \
|
docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \
|
||||||
--env SWW_RPROXY_WAF_BACKEND="http://$DISCRIMINATOR:8080" \
|
--env SWRP_WAF_BACKEND="http://$DISCRIMINATOR:8080" \
|
||||||
"$APP_IMAGE" >/dev/null
|
"$APP_IMAGE" >/dev/null
|
||||||
wait_for "the app's health check did not pass" healthy "$APP"
|
wait_for "the app's health check did not pass" healthy "$APP"
|
||||||
address="$(docker port "$APP" 8080/tcp)"
|
address="$(docker port "$APP" 8080/tcp)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# start_moved_discriminator: run discriminator with the state files on a new volume at
|
# start_moved_discriminator: run discriminator with the state files on a new volume at
|
||||||
# /srv/state, which SWW_WAF_STATE_DIR_FILE names in a file only the sww
|
# /srv/state, which SWW_STATE_DIR_FILE names in a file only the sww
|
||||||
# user can read, as under "Settings given as files" in docs/settings.md, and
|
# user can read, as under "Settings given as files" in docs/settings.md, and
|
||||||
# wait until it is healthy. discriminator can write the new volume only once the run
|
# wait until it is healthy. discriminator can write the new volume only once the run
|
||||||
# script has given it to that user.
|
# script has given it to that user.
|
||||||
@@ -126,8 +126,8 @@ start_moved_discriminator() {
|
|||||||
--volume "$MOVED_VOLUME:/srv/state" \
|
--volume "$MOVED_VOLUME:/srv/state" \
|
||||||
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
|
--volume "$SETTINGS_VOLUME:/etc/sww/settings:ro" \
|
||||||
--env SWW_COMMAND=discriminator \
|
--env SWW_COMMAND=discriminator \
|
||||||
--env SWW_WAF_STATE_DIR_FILE=/etc/sww/settings/state-dir \
|
--env SWW_STATE_DIR_FILE=/etc/sww/settings/state-dir \
|
||||||
--env SWW_WAF_LOOKUP_SOURCE=off \
|
--env SWW_LOOKUP_SOURCE=off \
|
||||||
"$IMAGE" >/dev/null
|
"$IMAGE" >/dev/null
|
||||||
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
wait_for "discriminator's health check did not pass" healthy "$DISCRIMINATOR"
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ status_from() {
|
|||||||
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true
|
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# refused: a request to the container gets 403, SWW_WAF_BAN_RESPONSE's
|
# refused: a request to the container gets 403, SWW_BAN_RESPONSE's
|
||||||
# default.
|
# default.
|
||||||
refused() {
|
refused() {
|
||||||
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||||
@@ -203,7 +203,7 @@ main() {
|
|||||||
wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"'
|
wait_for "discriminator did not stop in order" logged "$DISCRIMINATOR" '"msg":"stopped"'
|
||||||
[ "$(status_from 203.0.113.9 /)" = 200 ] ||
|
[ "$(status_from 203.0.113.9 /)" = 200 ] ||
|
||||||
fail "with discriminator stopped, the app did not answer through rproxy"
|
fail "with discriminator stopped, the app did not answer through rproxy"
|
||||||
wait_for "rproxy logged no request SWW_RPROXY_DEFAULT_DISPOSITION decided" \
|
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
|
||||||
logged "$APP" '"default_disposition":true'
|
logged "$APP" '"default_disposition":true'
|
||||||
docker exec "$APP" /usr/local/bin/sww healthcheck ||
|
docker exec "$APP" /usr/local/bin/sww healthcheck ||
|
||||||
fail "with discriminator stopped, the app's health check failed"
|
fail "with discriminator stopped, the app's health check failed"
|
||||||
@@ -238,16 +238,16 @@ main() {
|
|||||||
docker rm --force "$DISCRIMINATOR" >/dev/null
|
docker rm --force "$DISCRIMINATOR" >/dev/null
|
||||||
start_moved_discriminator
|
start_moved_discriminator
|
||||||
echo "example-app: the run script gives the state files' directory named" \
|
echo "example-app: the run script gives the state files' directory named" \
|
||||||
"by SWW_WAF_STATE_DIR_FILE to the sww user"
|
"by SWW_STATE_DIR_FILE to the sww user"
|
||||||
|
|
||||||
docker rm --force "$DISCRIMINATOR" >/dev/null
|
docker rm --force "$DISCRIMINATOR" >/dev/null
|
||||||
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
docker run --detach --name "$DISCRIMINATOR" --network "$NETWORK" \
|
||||||
--env SWW_COMMAND=discriminator \
|
--env SWW_COMMAND=discriminator \
|
||||||
--env SWW_WAF_STATE_DIR=/srv/missing \
|
--env SWW_STATE_DIR=/srv/missing \
|
||||||
--env SWW_WAF_LOOKUP_SOURCE=off \
|
--env SWW_LOOKUP_SOURCE=off \
|
||||||
"$IMAGE" >/dev/null
|
"$IMAGE" >/dev/null
|
||||||
wait_for "discriminator did not report the missing state directory" \
|
wait_for "discriminator did not report the missing state directory" \
|
||||||
logged "$DISCRIMINATOR" "SWW_WAF_STATE_DIR cannot be written"
|
logged "$DISCRIMINATOR" "SWW_STATE_DIR cannot be written"
|
||||||
echo "example-app: discriminator reports a state directory that does not exist"
|
echo "example-app: discriminator reports a state directory that does not exist"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -2,11 +2,11 @@
|
|||||||
# script/run: build bin/sww with script/build and run discriminator and
|
# script/run: build bin/sww with script/build and run discriminator and
|
||||||
# rproxy on the host, with the settings in the environment, until rproxy
|
# rproxy on the host, with the settings in the environment, until rproxy
|
||||||
# exits or the script is told to stop. rproxy listens on port 8080 unless
|
# exits or the script is told to stop. rproxy listens on port 8080 unless
|
||||||
# SWW_RPROXY_LISTEN_ADDR says otherwise, in front of the app at SWW_RPROXY_UPSTREAM_URL.
|
# SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL.
|
||||||
# discriminator listens on port 8090 unless SWW_WAF_LISTEN_ADDR says otherwise, and
|
# discriminator listens on port 8090 unless SWW_LISTEN_ADDR says otherwise, and
|
||||||
# rproxy asks it there unless SWW_RPROXY_WAF_BACKEND says otherwise. Unless
|
# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless
|
||||||
# SWW_WAF_STATE_DIR is set, the state files go in bin/state, beside the binary,
|
# SWW_STATE_DIR is set, the state files go in bin/state, beside the binary,
|
||||||
# and unless SWW_WAF_RULES_DIR is set, the rule files are those of share/rules.d,
|
# and unless SWW_RULES_DIR is set, the rule files are those of share/rules.d,
|
||||||
# which the image ships.
|
# which the image ships.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
@@ -26,18 +26,18 @@ stop_both() {
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
"$SCRIPT_DIR/build"
|
"$SCRIPT_DIR/build"
|
||||||
if [ -z "${SWW_WAF_STATE_DIR+set}" ]; then
|
if [ -z "${SWW_STATE_DIR+set}" ]; then
|
||||||
SWW_WAF_STATE_DIR="$ROOT/bin/state"
|
SWW_STATE_DIR="$ROOT/bin/state"
|
||||||
export SWW_WAF_STATE_DIR
|
export SWW_STATE_DIR
|
||||||
mkdir -p "$SWW_WAF_STATE_DIR"
|
mkdir -p "$SWW_STATE_DIR"
|
||||||
fi
|
fi
|
||||||
if [ -z "${SWW_WAF_RULES_DIR+set}" ]; then
|
if [ -z "${SWW_RULES_DIR+set}" ]; then
|
||||||
SWW_WAF_RULES_DIR="$ROOT/share/rules.d"
|
SWW_RULES_DIR="$ROOT/share/rules.d"
|
||||||
export SWW_WAF_RULES_DIR
|
export SWW_RULES_DIR
|
||||||
fi
|
fi
|
||||||
SWW_WAF_LISTEN_ADDR="${SWW_WAF_LISTEN_ADDR:-:8090}"
|
SWW_LISTEN_ADDR="${SWW_LISTEN_ADDR:-:8090}"
|
||||||
SWW_RPROXY_WAF_BACKEND="${SWW_RPROXY_WAF_BACKEND:-http://127.0.0.1:${SWW_WAF_LISTEN_ADDR##*:}}"
|
SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWW_LISTEN_ADDR##*:}}"
|
||||||
export SWW_WAF_LISTEN_ADDR SWW_RPROXY_WAF_BACKEND
|
export SWW_LISTEN_ADDR SWRP_WAF_BACKEND
|
||||||
|
|
||||||
trap stop_both EXIT
|
trap stop_both EXIT
|
||||||
trap 'exit 1' HUP INT TERM
|
trap 'exit 1' HUP INT TERM
|
||||||
|
|||||||
+7
-7
@@ -24,7 +24,7 @@ main() {
|
|||||||
# give_state_dir_to_sww gives the state directory and every file in
|
# give_state_dir_to_sww gives the state directory and every file in
|
||||||
# it to the sww user, so that a volume mounted there needs no change
|
# it to the sww user, so that a volume mounted there needs no change
|
||||||
# of owner; chown -R changes a symbolic link itself, never what it points
|
# of owner; chown -R changes a symbolic link itself, never what it points
|
||||||
# to. When SWW_WAF_STATE_DIR, in either form, cannot be read or is not the
|
# to. When SWW_STATE_DIR, in either form, cannot be read or is not the
|
||||||
# absolute path of a directory, no owner is changed, and discriminator starts and
|
# absolute path of a directory, no owner is changed, and discriminator starts and
|
||||||
# stops with a message naming the setting. A change of owner that fails, as
|
# stops with a message naming the setting. A change of owner that fails, as
|
||||||
# on a volume mounted read-only, does not end the script either: discriminator
|
# on a volume mounted read-only, does not end the script either: discriminator
|
||||||
@@ -36,14 +36,14 @@ give_state_dir_to_sww() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# state_dir prints SWW_WAF_STATE_DIR, /var/lib/sww when it is unset,
|
# state_dir prints SWW_STATE_DIR, /var/lib/sww when it is unset,
|
||||||
# or what is in the file SWW_WAF_STATE_DIR_FILE names, less every newline at
|
# or what is in the file SWW_STATE_DIR_FILE names, less every newline at
|
||||||
# its end. It fails when both are set or the file cannot be read.
|
# its end. It fails when both are set or the file cannot be read.
|
||||||
state_dir() {
|
state_dir() {
|
||||||
if [[ ! -v SWW_WAF_STATE_DIR_FILE ]]; then
|
if [[ ! -v SWW_STATE_DIR_FILE ]]; then
|
||||||
printf '%s\n' "${SWW_WAF_STATE_DIR-/var/lib/sww}"
|
printf '%s\n' "${SWW_STATE_DIR-/var/lib/sww}"
|
||||||
elif [[ ! -v SWW_WAF_STATE_DIR ]]; then
|
elif [[ ! -v SWW_STATE_DIR ]]; then
|
||||||
cat -- "$SWW_WAF_STATE_DIR_FILE" 2>/dev/null
|
cat -- "$SWW_STATE_DIR_FILE" 2>/dev/null
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in new issue
Block a user