3 Commits
Author SHA1 Message Date
clawbot 6deb9d7581 README: sections, compose examples, env var table (closes #188) (#189)
check / check (push) Successful in 4m40s
2026-11-06 00:06:25 +01:00
clawbot 8632da556a rproxy tests give a loaded host time before their limits run out (closes #185) (#186)
check / check (push) Successful in 6m15s
2026-11-05 23:17:16 +01:00
clawbot 344ce13c96 rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 14m44s
`smallwebwaf` with no command now prints the commands and exits with status 2.
The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The
passing-on code, its settings and its log fields and metric go. internal/proxy
keeps the checks, answers, reports and own endpoints; its tests run through the
real rproxy. The documents describe the pair.

Judgement call: clients.json keeps token_refused, since state files refuse unknown fields.
Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests.
Partially verified: make example-app and make gitea-requests never built the image, as Ubuntu's snapshot service answered 5xx.

Model: opus-5-5
2026-11-05 13:15:05 +01:00
73 changed files with 3121 additions and 5600 deletions
+6 -5
View File
@@ -117,8 +117,9 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o /usr/local/bin/runsvinit . -o /usr/local/bin/runsvinit .
# The image an app's Dockerfile builds FROM, described under "Deployment" # The image, described under "Deployment" in SPEC.md. It runs rproxy unless
# in SPEC.md. It is the last stage, so a plain `docker build .` builds it. # SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it.
# It is the last stage, so a plain `docker build .` builds it.
# #
# ubuntu 26.04, 2026-09-27 # ubuntu 26.04, 2026-09-27
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7 FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
@@ -197,11 +198,11 @@ RUN groupadd --system --gid 65532 smallwebwaf \
# The state files' directory, SWWAF_STATE_DIR by default, where a volume # The state files' directory, SWWAF_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
# smallwebwaf user at each start. # smallwebwaf user at each start of servewaf.
RUN mkdir /var/lib/smallwebwaf RUN mkdir /var/lib/smallwebwaf
# The default rule file, in SWWAF_RULES_DIR by default, where an app's # The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
# Dockerfile 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/smallwebwaf/rules.d/00-default.rules COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv # runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
+653 -236
View File
@@ -1,52 +1,48 @@
# smallwebwaf # smallwebwaf
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by `smallwebwaf` is an MIT-licensed web application firewall, written in Go by
[@sneak](https://sneak.berlin), for one self-hosted web app. It runs inside the [@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
app's own container, in front of the app. It refuses the requests of scrapers, requests of scrapers, scanners and other abusers before they reach an app.
scanners and other abusers before they reach the app.
## What it does
A web application firewall reads each request on its way to a web app and A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
accepts requests on the app's behalf, passes them to the app, and passes the one image, with two commands that do this together:
app's answers back.
It needs no setting. Every `SWWAF_` setting has a default chosen for a service - `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and routes to it. It asks `servewaf` about each request, passes the allowed ones
why. The reference documents under [`docs/`](docs/) are listed under "Documents" to the app, and passes the app's answers back. It keeps nothing and never
below. touches disk.
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `servewaf` serves many sites: a client
banned through one site is refused on all of them, and its requests on all of
them count toward one set of limits.
## What it protects against The site keeps answering while `servewaf` is down, slow or restarting: without
an answer in time, `rproxy` passes the request to the app.
- Scrapers that fetch every page of a site, all day. Rate limiting gives each It protects against:
client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves
room for programmatic use of an API, such as an automation fleet on another - Scrapers that fetch every page of a site, all day. Each client gets a limit on
host. A fleet of about 20 agents makes about 120 requests a minute at its requests and on bytes per minute, per hour and per day. The defaults leave
busiest. A gitea page opened with an empty cache loads about 40 files, so a room for programmatic use of an API, such as an automation fleet, and catch a
few such first visits fit in the minute limit too. The minute limit catches, client firing requests as fast as it can within seconds. A client over a limit
within seconds, a client firing requests as fast as it can. The hour limit is refused and banned: for a while at first, for three times as long at each
catches a client that keeps up more than about 5.5 a second for an hour. The repeat, and permanently when it keeps at it.
day limit catches a client that keeps up 4 a second around the clock. Counting
cannot catch a client that stays under all three limits, such as one that
keeps up 3 a second; only the protections below can. Byte limits give each
client 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over a limit
is refused and banned. The first ban lasts an hour. Each repeat within a day
makes the next ban three times as long. A ban that would last more than seven
days is permanent.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client for seven days at its first probe. Its default rule file bans such a client at its first probe, and its next request
next request during the ban makes the ban permanent. The default rule file during the ban makes the ban permanent. Your own rules, or a list of trap
does not cover probes for other apps, such as a WordPress login. A `ban` rule paths, ban probes for other apps, such as a WordPress login, the same way.
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
- Attacks carried in a request's URL or headers, such as SQL injection and path - Attacks carried in a request's URL or headers, such as SQL injection and path
traversal. The OWASP Core Rule Set is a published set of rules that recognise traversal. The OWASP Core Rule Set, a published set of rules that recognise
common web attacks. Coraza, a Go engine, runs those rules (see "Attack common web attacks, runs on every request through Coraza, a Go engine. A
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is request the rules take for an attack is refused.
refused.
- Abuse from networks and countries you do not want. You can: - Abuse from networks and countries you do not want. You can:
- refuse whole netblocks and countries; - refuse whole netblocks and countries;
- give the clients of chosen networks and countries a fraction of every - give the clients of chosen networks and countries a fraction of every
limit, so that they are banned sooner; limit, so that they are banned sooner;
- refuse or limit clients on a blocklist or a DNS blocklist, or with a high - refuse or limit clients on a blocklist, a DNS blocklist, or with a high
AbuseIPDB score; AbuseIPDB score;
- ban clients named by your own CrowdSec engine. - ban clients named by your own CrowdSec engine.
- Slow and oversized requests. Every request and every answer has a time limit - Slow and oversized requests. Every request and every answer has a time limit
@@ -54,29 +50,27 @@ below.
For the operator, it: For the operator, it:
- writes one JSON log line for every request, saying what it did and why (see - logs each request as JSON lines, saying what it did and why;
[`docs/request-log.md`](docs/request-log.md)); - sends alerts to a webhook, Slack or ntfy;
- sends alerts to a webhook, Slack or ntfy (see - serves Prometheus metrics;
[`docs/alerts.md`](docs/alerts.md));
- serves Prometheus metrics (see [`docs/metrics.md`](docs/metrics.md));
- keeps its bans, counters and history in JSON files you can read and edit, so - keeps its bans, counters and history in JSON files you can read and edit, so
that a restart loses nothing and a ban can be lifted with an editor (see that a restart loses nothing and a ban can be lifted with an editor.
[`docs/state-files.md`](docs/state-files.md)).
Not built, and not planned: Not built, and not planned:
- TLS and routing for several apps. Both stay with traefik, the reverse proxy in - TLS and routing by host name. Both stay with traefik, the reverse proxy in
front of the container. front of `rproxy`.
- Browser challenges such as captchas. - Browser challenges such as captchas.
- A web console. - A web console, or a configuration file.
- Defence against floods large enough to fill the host's network link. - Defence against floods large enough to fill the host's network link.
## Getting started ## Getting started
Build the image from a clone. `make docker` runs the tests and the linter, then ### The image
builds the image, tagged `smallwebwaf`. It builds for amd64, on an amd64 host
only: the `Dockerfile` checks Ubuntu's package lists against the hashes of the No image is published. Build it from a clone. `make docker` runs the tests and
amd64 archive. the linter, then builds the image, tagged `smallwebwaf`. It builds for amd64, on
an amd64 host only.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -84,14 +78,44 @@ cd smallwebwaf
make docker make docker
``` ```
Push the image to a registry your hosts pull from. Then build the app's own Push the image to a registry your hosts pull from, and name it there by digest.
image on it. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
by digest, and adds: The image runs `rproxy` by default. To run `servewaf` from the same image, set
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
container, once `servewaf` answers.
### The usual setup: three containers
The usual setup is three containers on one private network:
- the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
`/var/lib/smallwebwaf` for its state files.
Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `servewaf`. Anything on that network can reach the app around
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
trust. Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
of gitea" examples below show this shape.
### `rproxy` in the app's container
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app in one container. `servewaf` still runs in a container of
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
digest, and adds:
- the app's binary; - the app's binary;
- 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`. - a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`.
```dockerfile ```dockerfile
# The smallwebwaf image, pinned by digest. # The smallwebwaf image, pinned by digest.
@@ -126,117 +150,572 @@ 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 `smallwebwaf` and the app side by side, each as its own user. entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
app, and the "`rproxy` beside the app" example below runs one.
Deploy the app's image as you deploy any app. Point traefik's labels at port The app sees the headers traefik sent, with traefik's address added to
8080 (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Mount a `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
volume at `/var/lib/smallwebwaf`, so that bans and history survive a deploy. For `rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
example, with no setting: on the private network, which the usual default of a trusted-proxy setting
covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
ranges leave out, so the app's own setting must name them too, as `app.run`
does.
```sh ### Checking that it works
docker run --detach --name app \
--volume app-smallwebwaf:/var/lib/smallwebwaf \ - `docker inspect --format '{{.State.Health.Status}}' <container>` says
<registry>/app@sha256:<digest> `healthy` for each container of `smallwebwaf`.
- A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer.
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
about the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes.
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
`servewaf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints").
## Configuration
Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one.
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end. Setting both
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
it has no `_FILE` form, and 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 size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`.
- A count is a whole number of requests, such as `1000`.
- A time in milliseconds is a whole number above zero, such as `2000`.
- A list is comma separated. A list set to an empty value is an empty list, and
replaces the default.
- A netblock is an address and a prefix length, such as `10.0.0.0/8`; a bare
address stands for itself alone. A country is a two-letter code in either
case, such as `de`. An AS number is `AS` and the number, such as `AS64496`. A
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it.
- An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `500` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_HOUR` | `20000` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_DAY` | `300000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `4G` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `20G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWWAF_BYTES_LIMIT_PER_DAY` | `200G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWWAF_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
full, and the limits that are fixed rather than settings.
## docker-compose examples
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
`make docker` gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Only "In front of gitea"
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
out.
### Smallest setup
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
files, every setting at its default.
```yaml
services:
app:
# Your app, which listens on port 8081.
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
``` ```
With no setting, the address of every new visitor is sent to GeoJS, a web ### Custom per-address limits
service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
database file on your own host, looks addresses up in that file (see "Lookup of
AS number and country" in [`SPEC.md`](SPEC.md)).
`deploy/example-app` is such an app. `make example-app` builds it and checks it For a site with no API:
(see "Checking that it works" below). To run `smallwebwaf` outside the image
while working on it, see "Entrypoints" below.
## What happens to a request - tighter limits for everyone;
- no limits for the office and the automation host;
- no checks for monitoring;
- a refused netblock;
- a smaller share of the limits for two hosting networks and one country;
- trap paths;
- `429` for a refused client.
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step ```yaml
refuses a request, the steps after it are skipped, apart from step 15, and services:
nothing of the request reaches the app. "What happens to a request" in app:
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers. image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
# Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a site with no API.
SWWAF_RATE_LIMIT_PER_MINUTE: "120"
SWWAF_RATE_LIMIT_PER_HOUR: "3000"
SWWAF_RATE_LIMIT_PER_DAY: "30000"
# Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
SWWAF_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
```
### In front of gitea
gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time;
- the response size limit and the byte limits raised, for clones over 1 GiB;
- the Actions runners exempt from counting;
- the default request limits kept, since they already leave room for API
clients.
`traefik` is the network traefik is on, made outside this file.
```yaml
services:
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example/
# The host and the port of the SSH clone URLs gitea shows.
GITEA__server__SSH_DOMAIN: git.example
GITEA__server__SSH_PORT: "2222"
# gitea's TCP peer is rproxy, on the private network.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
volumes:
- gitea-data:/data
networks:
- site
ports:
# SSH does not pass through smallwebwaf.
- "2222:22"
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects and image layers over 100 MiB, or slower
# than a minute, are cut off at the defaults.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "600000"
# A clone larger than 1 GiB is cut off at the default.
SWRP_MAX_RESPONSE_SIZE: 4G
networks:
- site
- traefik
labels:
traefik.enable: "true"
# rproxy is on two networks; traefik reaches it on this one.
traefik.docker.network: traefik
traefik.http.routers.git.rule: Host(`git.example`)
traefik.http.routers.git.entrypoints: websecure
traefik.http.routers.git.tls.certresolver: letsencrypt
traefik.http.services.git.loadbalancer.server.port: "8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# Raised with rproxy's response limit, to four times the default
# each, so that one clone of that size breaks no byte limit.
SWWAF_BYTES_LIMIT_PER_MINUTE: 16G
SWWAF_BYTES_LIMIT_PER_HOUR: 80G
SWWAF_BYTES_LIMIT_PER_DAY: 800G
# gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- site
networks:
site:
traefik:
external: true
volumes:
gitea-data:
servewaf-state:
```
[`docs/gitea.md`](docs/gitea.md) records what the defaults do in front of gitea,
request by request.
### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `servewaf` is down,
and `servewaf`:
- observes before it enforces;
- looks addresses up in a local database rather than GeoJS;
- sends alerts to ntfy;
- reads its ntfy token from a file.
```yaml
services:
app:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
# Answer 503 rather than pass a request on while servewaf is down.
SWRP_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
SWWAF_LOOKUP_SOURCE: file
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes:
- servewaf-state:/var/lib/smallwebwaf
- /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
volumes:
servewaf-state:
```
The database is mounted by its directory, not by file, so that a replaced
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
with mode `0400`. This one holds an access token the ntfy server issued, which
`ntfy token add <user>` prints there:
```sh
mkdir -p /srv/smallwebwaf/tokens
# <token> is the access token that ntfy printed.
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
chmod 0400 /srv/smallwebwaf/tokens/ntfy
```
## Design
How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request
A request goes through these steps, in this order. Once `servewaf` refuses a
request, the steps after that are skipped, apart from the report, and nothing of
the request reaches the app.
`rproxy` takes the first two:
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once. 1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
2. The client is identified. 2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
visitor sent it, with the first 64 KiB of its body.
`servewaf` takes the checks:
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`. 3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_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.
7. The reputation sources. 7. The reputation sources.
8. The client's limit percentage. 8. The client's limit percentage, from its AS number, its country or a
reputation hit.
9. The rate limits. 9. The rate limits.
10. The trap paths. 10. The trap paths.
11. The rule files. 11. The rule files.
12. The Core Rule Set. 12. The header limits, and then the Core Rule Set.
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`. 13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
14. The request is passed to the app. that prefix.
15. After the answer: the byte limits, the error burst and the client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see `rproxy` takes the rest, with `servewaf` counting at the end:
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
Core Rule Set match answers `403`. A missing or wrong token gets `401`. A
request over a size or time limit gets the status for that limit, such as `413`
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)).
## Settings you are likely to touch 14. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
bytes each way. For an allowed request, `servewaf` counts the bytes toward
the byte limits and adds the request to the client's history.
Each setting is an environment variable of the app's container, such as A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
`docker run -e SWWAF_MODE=observe ...`, read at start. Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
values it takes. The ones most deployments touch: instead. A request over the header limits of step 12 is answered `431`, and bans
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
and the status for a size or time limit, such as `413` for a body too large.
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a ### Clients and bans
request only for a size or time limit or a missing or wrong token. A request
it would have refused is passed to the app, and its log line says what
`enforce` mode would have done. For trying a configuration before enforcing
it.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
clients are refused only for a size or time limit or a missing or wrong token.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
byte limits, such as a machine that talks to the app all day.
- `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php` in front of a code forge. A request for one bans
the client.
- `SWWAF_WAF_DISABLED_RULES`: Core Rule Set rule ids to switch off when a rule
refuses your app's ordinary requests. The log line names the rules a request
matched. Keep the six default ids in a list you give (see
[`docs/settings.md`](docs/settings.md)).
- `SWWAF_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`: raise them for an app that takes uploads
over 100 MiB or slower than 60 seconds.
- `SWWAF_LOOKUP_SOURCE`: `geojs` (the default), `file` or `off`, as above.
- `SWWAF_ADMIN_TOKEN` and `SWWAF_METRICS_TOKEN`: switch on the ban endpoints and
the metrics. Give them as files to keep them from the app.
- `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or
`SWWAF_ALERT_NTFY_URL`: where alerts go.
- `SWWAF_INSTANCE_NAME`: the name in every log line, alert and metric, such as
`fsn1app1/gitea`.
## Checking that it works - A client is one IPv4 address or one IPv6 netblock of
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
- 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
lifts it.
- Every ban carries notes: the client's AS number and country, the limit it
broke or the rule it matched, the request that caused it, and its earlier
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
- `docker inspect --format '{{.State.Health.Status}}' <container>` says ### Attack detection
`healthy` once `smallwebwaf` answers and the app accepts connections. A log
line with `"msg":"starting"` gives the version, the address and every setting - The Core Rule Set reads the method, the URL with its query string, and the
in effect, with secrets masked. headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
- A request to the site through traefik gets the app's answer, and its log line front of a code forge, bodies carry what people write and publish, which the
has `"action":"forward"`. `https://<site>/_smallwebwaf/healthz` answers `ok`. rules take for attacks.
- To see a refusal without banning yourself, run the container with - `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. Its log requests to a code forge through, such as git over HTTP and sign-in through
line has `"would_action":"banned"` and `"rule_ids":["env-file"]`. In `enforce` Git Credential Manager. No setting undoes them; "Attack detection" in
mode the same request bans your address for seven days, and your next request [`SPEC.md`](SPEC.md) lists them.
makes the ban permanent. Lift the ban by editing `bans.json` (see "Bans" in - Rule files are plain text, one rule to a line, edited by hand and read again
[`SPEC.md`](SPEC.md)). while `servewaf` runs. The image ships one file of probes no real visitor
- With `SWWAF_METRICS_TOKEN` set, sends (see [`docs/rules.md`](docs/rules.md)).
`curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics`
answers with the metrics. ### State files
- `make example-app` builds the image and the example app on it, runs the app,
and checks it. It needs network access. It checks: `servewaf` keeps everything in memory, and the five JSON files in
- the health check; `SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
- a request forwarded to the app; `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
- a ban for a broken limit, and a ban for a probe made permanent; renamed over the real one, so a crash leaves a complete file. `servewaf` notices
- orderly stops with `sv stop` and `docker stop`; a file edited on disk and takes the edit in; an edit that does not parse is set
- bans surviving a new container on the same volume; aside as `<name>.bad`, and never stops the running process (see
- a setting given as a file. [`docs/state-files.md`](docs/state-files.md)).
### The image and its processes
- The image is built on Ubuntu 26.04 LTS, pinned by digest, with its packages
from a snapshot of Ubuntu's archive and nixpkgs fixed at one release. An app's
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)).
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
and gid 65532) and starts it again when it exits. The `run` script of
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
so a volume needs no change of owner.
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
at its own address on the private network; nothing routes the internet there.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them.
- Only faults found at start stop a process: an invalid setting, a rule file or
state file that does not parse, a lookup database that cannot be read, or an
address it cannot listen on. Once running, a failing lookup service,
reputation source or alert destination never takes `servewaf` down.
### Packages
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
- `internal/smallwebwaf`: the process, as the command it is given, and the
health check.
- `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app.
- `internal/proxy`: `servewaf`'s server, which runs the checks and answers its
own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/alerts`: one check or source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the
metrics.
## Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore `robots.txt` and crawl every commit of every repository
on a public git server;
- scanners walking through lists of WordPress and `.env` paths;
- credential-guessing bots.
Most of it comes from a small number of hosting networks and countries. A
single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of its services and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want a database and a web console. None of them can say "clients from
these networks are banned after half as many requests as anyone else".
[`EVALUATION.md`](EVALUATION.md) goes through the candidates one by one.
## Entrypoints ## Entrypoints
@@ -276,102 +755,41 @@ on the host.
- `script/install-precommit`: installs that hook. `make hooks`. - `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/smallwebwaf` with the Go on your host. - `script/build`: builds `bin/smallwebwaf` with the Go on your host.
`make build`. `make build`.
- `script/run`: builds `bin/smallwebwaf` and runs it with the settings in your - `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
environment, listening on port 8080 in front of an app at host, with the settings in your environment. `make run`. Unless your settings
`SWWAF_UPSTREAM_URL`. Its state files go in `bin/state` and its rule files are say otherwise:
those of `share/rules.d`, unless `SWWAF_STATE_DIR` and `SWWAF_RULES_DIR` say - `rproxy` listens on port 8080, in front of an app at
otherwise. `make run`. `http://127.0.0.1:8081`;
- `script/example-app`: builds the image and the example app on it, runs it with - `servewaf` listens on port 8090;
a volume, and checks it as "Checking that it works" above describes. Then it - its state files go in `bin/state`;
removes the containers, volumes and images. Needs network access. - its rule files are those of `share/rules.d`.
`make example-app`. - `script/example-app`: builds the image and the example app
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it at (`deploy/example-app`) on it, runs the app with `rproxy` in its container and
the defaults. It sends through it a sample of gitea's ordinary use and the `servewaf` in another, and checks them. Then it removes the containers, the
requests the Core Rule Set is known to refuse, and prints what `smallwebwaf` network, the volumes and the images. Needs network access. `make example-app`.
did with each. Needs network access, and a docker daemon that pushes to a It checks:
registry on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) - the health checks;
records the results. `make gitea-requests`. - a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent;
## Rationale - that only `servewaf`'s container changes the state directory's owner;
- with `servewaf` stopped, the app still answering through `rproxy`;
Small self-hosted sites receive a great deal of traffic nobody asked for: - with `servewaf` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`;
- scrapers that ignore `robots.txt` and crawl every commit of every repository - bans surviving a new container on the same volume;
on a public git server; - a setting given as a file.
- scanners walking through lists of WordPress and `.env` paths; - `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
- credential-guessing bots. `rproxy` and `servewaf`, three containers on one network. Every setting is at
its default, but for those that join the containers and
Most of it comes from a small number of hosting networks and countries. A `SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
single-person operation has no abuse desk and no CDN contract. It needs It sends through them a sample of gitea's ordinary use and the requests the
something small that can be put in front of one service and left alone. Core Rule Set is known to refuse, and prints what `servewaf` did with each.
Needs network access, and a docker daemon that pushes to a registry on
The existing tools each solve part of this. Rule-based firewalls catch attack `localhost` over plain HTTP. `make gitea-requests`.
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want several containers, a database and a web console. None of them can
say "clients from these networks are banned after half as many requests as
anyone else". That is the useful thing to say when nearly all abuse comes from a
known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the
candidates one by one.
`smallwebwaf` fills that gap with:
- per-client limits that ban;
- lower limits for the networks and countries abuse comes from;
- bans that grow with repetition;
- a log that is easy to search and ship;
- one binary in the app's own container, with no database and no required
setting.
## Design
The `smallwebwaf` image is built on Ubuntu 26.04 LTS, pinned by digest, with
nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
`nix-env -iA nixpkgs.<name>` and gets the same package each time it is built.
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each
as its own user, and starts either again when it exits. The app cannot touch
the state files or the process of `smallwebwaf`.
- `smallwebwaf` listens on port 8080 and passes requests to the app on
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under
`/_smallwebwaf/`, on the same port.
- The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust
`127.0.0.1` and `::1` for forwarded headers, besides the private address
ranges.
- Both processes start with the container's environment variables, so every
setting of this `smallwebwaf` process starts with `SWWAF_`, and the app can
read a token given as one. A token given as a file that only the `smallwebwaf`
user can read is out of its reach (see "Settings given as files" in
[`docs/settings.md`](docs/settings.md)).
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
them across a deploy.
- `smallwebwaf servewaf` runs the same checks for the sites that ask it about
each request, and passes nothing to an app (see "`servewaf`" in
[`SPEC.md`](SPEC.md)).
- `smallwebwaf rproxy` is the reverse proxy in front of one app that asks
`smallwebwaf servewaf` about each request. When `servewaf` cannot be connected
to, does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`,
`SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
answering. The settings of `rproxy` start with `SWRP_`, and it needs
`SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five
seconds after `SIGTERM`.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health check, what
happens when a process stops, moving `smallwebwaf` to another port, and the
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and
what each does.
## TODO ## TODO
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: - Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending
- sharing bans between the instances of several apps; the log over RELP.
- sending the log over RELP.
## Documents ## Documents
@@ -379,8 +797,7 @@ what each does.
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers - [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
and misses, and why none was adopted. and misses, and why none was adopted.
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows. - [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
- [`docs/settings.md`](docs/settings.md): every setting, its default and the - [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
values it takes.
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file. - [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
`/_smallwebwaf/`, with examples. `/_smallwebwaf/`, with examples.
+417 -386
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,8 +1,8 @@
// Command smallwebwaf is a web application firewall for one app: it runs // Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
// between traefik and the app and passes requests through within its limits, // the reverse proxy in front of one app, which asks servewaf about each request.
// or, as `smallwebwaf servewaf`, answers rproxy's questions about them, or, as // `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
// `smallwebwaf rproxy`, is the reverse proxy that asks those questions. // `smallwebwaf healthcheck` is the container's health check. Everything it does
// Everything it does is in internal/smallwebwaf. // is in internal/smallwebwaf.
package main package main
import ( import (
+24 -23
View File
@@ -1,8 +1,8 @@
# Admin endpoints # Admin endpoints
The endpoints under `/_smallwebwaf/`, what each takes and what each answers. The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says which checks they go through `smallwebwaf servewaf` answers them at its own address, on the private network.
and how their tokens work. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone. - `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see - `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
@@ -20,40 +20,41 @@ and how their tokens work.
- 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.
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the - `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
address `<client>`. It answers `404` when none is active. address `<client>`. It answers `404` when none is active.
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client. - `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
Under `client`, its counters and history as `clients.json` holds them, or `client`, its counters and history as `clients.json` holds them, or `null`
`null` when `clients.json` does not hold it. Under `bans`, every ban covering when `clients.json` does not hold it. Under `bans`, every ban covering the
the address, with its notes. address, with its notes.
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
answers `204`, or `400` to a body that is no report (see "The report" in
[`SPEC.md`](../SPEC.md)).
No check runs on these requests. A missing or wrong token is answered `401`. An
endpoint whose token is unset answers `404`, as does any other path.
`smallwebwaf rproxy` answers only the health check of these, at its own address.
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
passes the checks is answered `404`, so that the app never sees that prefix (see
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
## Examples ## Examples
With `SWWAF_ADMIN_TOKEN` set, and the token in `$TOKEN`: With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
`servewaf:8080` on its network:
```sh ```sh
# Every ban. # Every ban.
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
# Ban 203.0.113.0/24 for seven days. # Ban 203.0.113.0/24 for seven days.
curl -H "Authorization: Bearer $TOKEN" \ curl -H "Authorization: Bearer $TOKEN" \
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \ --json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
https://app.example/_smallwebwaf/bans http://servewaf:8080/_smallwebwaf/bans
# Lift the bans on 203.0.113.9. # Lift the bans on 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" -X DELETE \ curl -H "Authorization: Bearer $TOKEN" -X DELETE \
https://app.example/_smallwebwaf/bans/203.0.113.9 http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
# What smallwebwaf knows of 203.0.113.9. # What servewaf knows of 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" \ curl -H "Authorization: Bearer $TOKEN" \
https://app.example/_smallwebwaf/clients/203.0.113.9 http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
``` ```
## `servewaf`
`smallwebwaf servewaf` answers these endpoints at its own address. No check runs
on those requests (see "`servewaf`" in [`SPEC.md`](../SPEC.md)).
## `rproxy`
`smallwebwaf rproxy` answers only the health check, at its own address. It asks
`servewaf` about any other request under `/_smallwebwaf/` (see "`rproxy`" in
[`SPEC.md`](../SPEC.md)).
+7 -6
View File
@@ -6,7 +6,7 @@ back or dropped.
## Events ## Events
- `ban`: a ban `smallwebwaf` made. - `ban`: a ban `servewaf` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request. a request.
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The - `waf_block`: the Core Rule Set scored a request at or over the threshold. The
@@ -34,9 +34,10 @@ on one line:
```json ```json
{ {
"instance": "fsn1app1/gitea", "instance": "fsn1/servewaf",
"time": "2026-10-06T12:00:50.123461Z", "time": "2026-10-06T12:00:50.123461Z",
"event": "ban", "event": "ban",
"host": "app.example",
"client": "203.0.113.9", "client": "203.0.113.9",
"netblock": "203.0.113.9/32", "netblock": "203.0.113.9/32",
"asn": "", "asn": "",
@@ -78,9 +79,9 @@ on one line:
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above. one of the events above.
- `host`: the site of the request that raised the alert. Only `servewaf` sends - `host`: the site of the request that raised the alert, since one `servewaf`
this field, since it serves many sites, and not for `source_failure`, serves many sites. It is left out of `source_failure`, `file_error` and
`file_error` and `summary`. `summary`.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`. `source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise - `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
@@ -110,7 +111,7 @@ on one line:
## Slack and ntfy ## Slack and ntfy
Slack and ntfy get a message with a title and a text. The title is the instance Slack and ntfy get a message with a title and a text. The title is the instance
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
line for each of these that the alert has: line for each of these that the alert has:
- the host, the client, the netblock and the country; - the host, the client, the netblock and the country;
+9 -9
View File
@@ -2,10 +2,11 @@
These notes record what `smallwebwaf` does in front of gitea at the default These notes record what `smallwebwaf` does in front of gitea at the default
settings. They are observations, not requirements; the requirements are in settings. They are observations, not requirements; the requirements are in
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind the image and [`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind `rproxy` and
sends through it a sample of gitea's ordinary use and the requests named below. `servewaf`, and sends through them a sample of gitea's ordinary use and the
It reports each request the Core Rule Set refuses, so that these notes can be requests named below. It reports each request the Core Rule Set refuses, so that
checked again when gitea or the Core Rule Set changes version. these notes can be checked again when gitea or the Core Rule Set changes
version.
## What the defaults cover ## What the defaults cover
@@ -13,7 +14,7 @@ checked again when gitea or the Core Rule Set changes version.
by it. by it.
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the - A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
repository is large or the link slow. A repository whose clone is larger than repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it 1 GiB needs `SWRP_MAX_RESPONSE_SIZE` raised, and the byte limits with it
("Byte limits, per client" in `SPEC.md`). ("Byte limits, per client" in `SPEC.md`).
- A push is a request, and so is every other upload: - A push is a request, and so is every other upload:
- a git push; - a git push;
@@ -23,10 +24,9 @@ checked again when gitea or the Core Rule Set changes version.
- a release attachment. - a release attachment.
At the defaults, an upload larger than 100 MiB or taking more than 60 At the defaults, an upload larger than 100 MiB or taking more than 60
seconds is cut off ("Size and time limits" in `SPEC.md`). A gitea that takes seconds is cut off ("`rproxy`'s limits" in `SPEC.md`). A gitea that takes
large uploads needs `SWWAF_REQUEST_MAX_BYTES`, large uploads needs `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`
`SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised raised to fit.
to fit.
- Archive download and blame or history pages are what scrapers hammer. The - Archive download and blame or history pages are what scrapers hammer. The
request limits do most of the work there. request limits do most of the work there.
+5 -3
View File
@@ -1,6 +1,7 @@
# What the image holds # What the image holds
The contents of the `smallwebwaf` image, and how each part is pinned. The contents of the `smallwebwaf` image, and how each part is pinned. The image
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it "Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
is pinned that way. is pinned that way.
@@ -18,8 +19,9 @@ is pinned that way.
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532). - The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
- The service directory `/etc/service/smallwebwaf`, with the `run` script - The service directory `/etc/service/smallwebwaf`, with the `run` script
`share/smallwebwaf.run`. `share/smallwebwaf.run`.
- `/var/lib/smallwebwaf` for the state files, and `/etc/smallwebwaf/rules.d` - `/var/lib/smallwebwaf` for `servewaf`'s state files, and
with the default rule file `share/rules.d/00-default.rules`. `/etc/smallwebwaf/rules.d` with the default rule file
`share/rules.d/00-default.rules`.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`. - `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
## Pins ## Pins
+10 -10
View File
@@ -1,8 +1,9 @@
# The metrics # The metrics
What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance` "Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
label as `exported_instance` unless the scrape sets `honor_labels: true`. keeps the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics.
## Traffic ## Traffic
@@ -11,14 +12,13 @@ label as `exported_instance` unless the scrape sets `honor_labels: true`.
`action`; `action`;
- `smallwebwaf_request_duration_seconds` and - `smallwebwaf_request_duration_seconds` and
`smallwebwaf_upstream_duration_seconds`, as histograms; `smallwebwaf_upstream_duration_seconds`, as histograms;
- `smallwebwaf_requests_in_flight`. - `smallwebwaf_requests_in_flight`: the questions and requests to its own
endpoints that `servewaf` is handling.
## Limits and bans ## Limits and bans
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`, - `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
`bytes` or `refusals`); `bytes` or `refusals`);
- `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting passed.
`servewaf`'s header limits have no setting, so it leaves them out;
- `smallwebwaf_offences_total`, by `kind`; - `smallwebwaf_offences_total`, by `kind`;
- `smallwebwaf_bans_made_total`, by `cause`; - `smallwebwaf_bans_made_total`, by `cause`;
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. - `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
@@ -81,11 +81,11 @@ By `file`:
`smallwebwaf_alerts_dropped_total`; `smallwebwaf_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics. - Go's own `go_` metrics and the process's `process_` metrics.
## `servewaf` ## When a request is counted
`smallwebwaf servewaf` counts a denied request at its decision. It counts an `servewaf` counts a denied request at its decision, and a request to its own
allowed request when its report comes, with the status, bytes and times the endpoints when it is answered. It counts an allowed request when its report
report gives. These are not counted: comes, with the status, bytes and times the report gives. These are not counted:
- a report itself; - a report itself;
- an allowed request whose report never comes; - an allowed request whose report never comes;
+137 -151
View File
@@ -1,124 +1,9 @@
# The request log's fields # The request log's fields
The fields of the JSON object `smallwebwaf` writes for each request. "Request The fields of the JSON objects `rproxy` and `servewaf` write about each request.
log" in [`SPEC.md`](../SPEC.md) says when a line is written and how the log is "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
sent to a syslog server. A field that does not apply to a request is left out. written and how the log is sent to a syslog server. A field that does not apply
`smallwebwaf rproxy` writes a shorter line, which "`rproxy`'s line" below gives. to a request is left out.
## Example
```
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
```
## Every line
Every line but `rproxy`'s has:
- `type`: `request` (the process's own messages have `process`);
- the standard web log fields;
- `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`;
- `action` and `duration_total`.
## Standard web log fields
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC;
- `instance`: `SWWAF_INSTANCE_NAME`;
- `client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`;
- `status`: what the client was sent, `0` if nothing;
- `request_bytes` and `response_bytes`: body bytes each way;
- `referer` and `user_agent`.
## Request detail
- `request_id`: the `X-Request-ID` a trusted proxy sent, or a new random one of
26 letters and digits.
- `peer_ip`: the TCP peer, normally traefik.
- `forwarded_for`: the `X-Forwarded-For` header as received.
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
/32 or an IPv6 group.
- `asn`, `as_name` and `country`: the client's, as looked up. Each is empty
when:
- lookups are off;
- the client is not looked up;
- the source cannot place the client;
- GeoJS had not answered by the time the request went on.
- `content_type` and `content_length`: the request's own.
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
in lower case.
- `has_authorization` and `has_cookie`: true when the request had those headers.
Their values are never logged.
- `websocket`: true when the app switched the connection to another protocol.
## Response detail
- `upstream_status`: what the app answered. It differs from `status` when
`smallwebwaf` answered itself.
- `response_content_type`, `cache_control` and `location`: the answer's headers.
- `aborted`: true when the client went away early.
## Decision
- `action`: what was done:
- `forward`: passed to the app;
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
the action `deny`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
refused by that check;
- `too_large`: a size limit was passed, or a header limit in `servewaf`;
- `timed_out`: a time limit was passed;
- `upstream_error`: the app could not be reached, or its answer broke off;
- `admin`: answered at one of `smallwebwaf`'s own endpoints.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally
`forward`.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_percent_setting` do the same for the byte limits.
- `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It
holds:
- `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)).
- `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `error_burst`.
A request whose bytes broke a limit was passed on whole, so its `action`
stays `forward`.
- `rule_ids`: the rule file rules that matched, in order, up to the one that
refused.
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order:
- blocklist URLs;
- the CrowdSec decision list's URL;
- DNS blocklist zones;
- `abuseipdb`.
- `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`.
## Timings
All in milliseconds:
- `duration_total`: from the request's headers being read to its log line;
- `duration_checks`: to the end of the checks, before forwarding;
- `duration_waf`: the part of that the Core Rule Set took;
- `duration_upstream_connect`: from the hand-over to the app until there was a
connection;
- `duration_upstream_first_byte`: until the first byte of the answer;
- `duration_upstream_total`: until the end of the answer.
## `servewaf`'s lines ## `servewaf`'s lines
@@ -138,50 +23,151 @@ These questions write no line:
[`SPEC.md`](../SPEC.md)); [`SPEC.md`](../SPEC.md));
- a question whose body stopped arriving, which gets no answer. - a question whose body stopped arriving, which gets no answer.
### The `decision` line A `report` line:
It has the fields above, with these meanings where they differ: ```
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
```
- `client_ip`: `X-Smallwebwaf-Client`. ### Every line
- `peer_ip`: `X-Smallwebwaf-Peer`.
Every one of these lines has:
- `type`: `request` (the process's own messages have `process`);
- the standard web log fields;
- `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`;
- `action` and `duration_total`.
### Standard web log fields
- `time`: when `servewaf` had read the question's body, RFC 3339 with
milliseconds, in UTC. The `report` line gives the same time as the `decision`
line.
- `instance`: `SWWAF_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request.
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
it.
- `host`: the site the visitor asked for. - `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`. - `protocol`: the question's own, `HTTP/1.1`.
- `content_length`: `X-Smallwebwaf-Content-Length`. - `status`:
- `request_id`: the question's `X-Request-ID`. - on the `decision` line, what `servewaf` answered: `200` for `allow`, the
- `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`, status of `deny`, or `0` for `close`, as the visitor is then sent nothing;
or `0` for `close`, as the visitor is then sent nothing. - on the `report` line, what the visitor got, `0` if nothing.
- `request_bytes`: how much of the question's body the Core Rule Set read. - `request_bytes`:
- `response_bytes`: the body of `servewaf`'s answer. - on the `decision` line, how much of the question's body the Core Rule Set
- `action`: `forward` for `allow`, and `admin` for the `404` for a path under read;
`/_smallwebwaf/`. - on the `report` line, the bytes of the visitor's body that `rproxy` read,
with what a WebSocket carried from the visitor.
- `response_bytes`:
- on the `decision` line, the body of `servewaf`'s answer;
- on the `report` line, the bytes of the answer's body, with what a
WebSocket carried from the app.
- `referer` and `user_agent`.
### Request detail
- `request_id`: the id `rproxy` made for the request when it arrived.
- `peer_ip`: the address of `rproxy`'s TCP peer, normally traefik.
- `forwarded_for`: the `X-Forwarded-For` header the visitor's request came with.
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
/32 or an IPv6 group.
- `asn`, `as_name` and `country`: the client's, as looked up. Each is empty
when:
- lookups are off;
- the client is not looked up;
- the source cannot place the client;
- GeoJS had not answered by the time `servewaf` answered.
- `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
in lower case.
- `has_authorization` and `has_cookie`: true when the request had those headers.
Their values are never logged.
### Response detail
- `response_content_type`: the type of `servewaf`'s own answer.
- `aborted`: true when `rproxy` went away before the answer. - `aborted`: true when `rproxy` went away before the answer.
- `duration_total`: from `servewaf` having read the question's body to the line.
- `time`: when `servewaf` had read the question's body.
The line leaves out `upstream_status`, `websocket` and the ### Decision
`duration_upstream_...` fields, since `servewaf` passes nothing to the app.
The bytes of an allowed request are counted when its report comes. So `counts` - `action`: what was done:
holds the client's bytes without this request's. - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
the action `deny`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
refused by that check;
- `too_large`: over a header limit;
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
path under `/_smallwebwaf/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally
`forward`.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
rate limits and the setting that lowered it, when one did. `bytes_percent` and
`bytes_percent_setting` do the same for the byte limits.
- `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It
holds:
- `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
### The `report` line A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
its report comes. So on the `decision` line, the bytes are without this
request's.
It has every field of the `decision` line, and `time` stays as the `decision` - `limit_hit`: the limit broken:
line gives it. These fields say what the report says: - `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `error_burst`.
- `status`: what the visitor got, `0` if nothing; A request whose bytes broke a limit was passed on whole, so its `action`
- `request_bytes` and `response_bytes`: the body bytes each way, with what a stays `forward`. Only its `report` line gives that limit.
WebSocket carried each way;
- `duration_total`: from the request's headers being read by `rproxy` to the end
of the answer;
- `duration_upstream_total`: from the hand-over to the app to the end of the
answer, for an allowed request.
For an allowed request, the report's counting also changes these fields: - `rule_ids`: the rule file rules that matched, in order, up to the one that
refused.
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order:
- blocklist URLs;
- the CrowdSec decision list's URL;
- DNS blocklist zones;
- `abuseipdb`.
- `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`.
- `counts`: the byte totals now include this request's; ### Timings
- `limit_hit`, `offence` and `ban_expires`: set when the report's bytes broke a
byte limit. All in milliseconds:
- `duration_total`:
- on the `decision` line, from `servewaf` having read the question's body to
the line;
- on the `report` line, from `rproxy` having read the request's headers to
the end of the answer.
- `duration_checks`: to the end of the checks;
- `duration_waf`: the part of that the Core Rule Set took;
- `duration_upstream_total`: on the `report` line of an allowed request, from
the hand-over to the app to the end of the answer.
### Requests to its own endpoints
The line of a request to `servewaf`'s own endpoints has these fields, with these
meanings where they differ:
- `client_ip` and `peer_ip`: the request's TCP peer.
- `request_id`: a new random one of 26 letters and digits.
- `scheme`: `http`.
- `time`: when the request's headers had been read.
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
- `action`: `admin`.
- `duration_total`: from the request's headers being read to the line.
## `rproxy`'s line ## `rproxy`'s line
+7 -2
View File
@@ -54,14 +54,19 @@ empty-agent user_agent log ^$
## A file of your own ## A file of your own
An app's `Dockerfile` adds rules of its own in a file named to sort after the `servewaf` reads the rule files in its own container. Rules of your own go in a
default, such as `50-gitea.rules`: file named to sort after the default, such as `50-gitea.rules`:
``` ```
# 50-gitea.rules: WordPress probes, which gitea never serves # 50-gitea.rules: WordPress probes, which gitea never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
``` ```
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
adds it:
```dockerfile ```dockerfile
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
``` ```
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
+51 -58
View File
@@ -4,6 +4,10 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why. that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
chooses between the two (see "The image's command" below).
## Forms of values ## Forms of values
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
@@ -24,26 +28,22 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Core ## Core
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or - `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
`https` URL with a host and an optional port, and nothing more.
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens: `:` 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)).
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance` - `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
name in every log line, alert and metric, such as `fsn1app1/gitea`. Valid name in each line `servewaf` logs, and in each alert and metric, such as
UTF-8. No control character while `SWWAF_ALERT_NTFY_URL` is set. 1 to 48 `fsn1/servewaf`. Valid UTF-8. No control character while
printable ASCII characters without a space while `SWWAF_LOG_REMOTE_URL` is set `SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
and `SWWAF_LOG_REMOTE_APP_NAME` is not. space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
not.
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`. - `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
nothing.
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 - `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
client's group, from 32 to 128. client's group, from 32 to 128.
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole - `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero. number above zero.
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `smallwebwaf` - `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
keeps, a whole number above zero. a whole number above zero.
## Tokens ## Tokens
@@ -63,7 +63,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
## Logging ## Logging
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `smallwebwaf`'s own - `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
messages that are written: `debug`, `info`, `warn` or `error`. messages that are written: `debug`, `info`, `warn` or `error`.
- `SWWAF_LOG_REQUEST_HEADERS` (default - `SWWAF_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the `accept,accept-language,accept-encoding,content-type,origin,range`): the
@@ -125,27 +125,6 @@ that take `off`, the `_FILE` form, and what each group does and why.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: - `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`. `response`, `request` or `both`.
## Size and time limits
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
send its request line and headers, and then, from the end of the headers, its
body. A duration or `off`.
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
line and headers. A size over `4K`; never `off`.
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
may wait for its next request before it is closed. A duration or `off`.
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the answer may take
to reach the client, from the end of the request to the last byte. A duration
or `off`.
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
app and sending it the whole request may take. A duration or `off`.
- `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take
to send its whole answer. A duration or `off`.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
or `off`.
- `SWWAF_RESPONSE_MAX_BYTES` (default `1G`): the largest response body. A size
or `off`.
## Lookup of AS number and country ## Lookup of AS number and country
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
@@ -160,8 +139,9 @@ that take `off`, the `_FILE` form, and what each group does and why.
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its - `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the - `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
client's AS number in `X-Client-ASN` and its country in `X-Client-Country`. the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`.
## Country lists ## Country lists
@@ -206,7 +186,9 @@ that take `off`, the `_FILE` form, and what each group does and why.
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
not inspect. not inspect.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body - `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
the Core Rule Set reads, a size of at most `1G`. the Core Rule Set reads, a size of at most `64K`. A question carries no more
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client - `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
@@ -327,6 +309,7 @@ Then mount its directory into the container read-only, and name the file:
```sh ```sh
docker run \ docker run \
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \ -v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
-e SMALLWEBWAF_COMMAND=servewaf \
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \ -e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
... ...
``` ```
@@ -349,22 +332,7 @@ A few limits are fixed rather than settings:
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers - `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
`502` to a longer one. `502` to a longer one.
## `servewaf` ## `servewaf`'s answer and `rproxy`'s wait
`smallwebwaf servewaf` uses every `SWWAF_` setting but these, which it ignores:
- `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`;
- the five timeouts under "Size and time limits";
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_REQUEST_MAX_BYTES` and
`SWWAF_RESPONSE_MAX_BYTES`.
An invalid value of one of these still stops the start, and the line of settings
at start still shows them.
`SWWAF_ADD_LOOKUP_HEADERS` puts the headers on the answer to `rproxy`, not on a
request to an app. `SWWAF_WAF_BODY_LIMIT` has an effect up to 64 KiB less one
byte, whatever its value. A question carries no more of a body than 64 KiB (see
"`servewaf`" in [`SPEC.md`](../SPEC.md)).
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. `SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
@@ -374,10 +342,10 @@ asked in the background (see "Answering in time" under "`servewaf`" in
## `rproxy` ## `rproxy`
`smallwebwaf rproxy` reads the settings below and no others, and nothing else `smallwebwaf rproxy` reads the settings below and no others. They are read once
reads them. They are read once at start, and an invalid value stops the start. at start, and an invalid value stops the start. None can be given as a file,
None can be given as a file, since `rproxy` never touches disk. "`rproxy`" in since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
[`SPEC.md`](../SPEC.md) says what they do. 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`.
@@ -406,4 +374,29 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
`https` URL with a host and an optional port, and nothing more. `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`): - `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
nothing. nothing. A client outside these netblocks cannot claim another address. Where
other containers reach `rproxy` directly, or visitors on a private network
reach traefik, they can name another address in the header. Narrowing the
setting to traefik's own network closes that gap.
## The image's command
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
## Where earlier settings went
Nothing reads these names any more:
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
takes a request line and headers of at most 32 KiB.
+3 -3
View File
@@ -7,7 +7,7 @@ Each has a top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
those `smallwebwaf` made. Per entry: those `servewaf` 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`;
@@ -63,8 +63,8 @@ since it was first seen:
- total requests, and how many were forwarded and how many refused; - total requests, and how many were forwarded and how many refused;
- bytes in each direction; - bytes in each direction;
- responses by status class; - responses by status class;
- offences by kind: `limit`, `attack`, `rule_blocked`, `waf_blocked` and - offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
`token_refused`. may also hold `token_refused`, which is kept, and which nothing adds to.
## `lookups.json` ## `lookups.json`
+47 -100
View File
@@ -1,9 +1,10 @@
// Package config reads smallwebwaf's settings, and is the one place they are // Package config reads smallwebwaf's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of // read. Every setting is an environment variable. The settings of `smallwebwaf
// `smallwebwaf` and `smallwebwaf servewaf` start with SWWAF_, every one of them // servewaf` start with SWWAF_, every one of them has a default, and most can be
// has a default, and most can be given as a file instead. Those of `smallwebwaf // given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each
// rproxy` start with SWRP_. Each of them but SWRP_WAF_BACKEND has a default, and // of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// none can be given as a file, since rproxy never touches disk. // since rproxy never touches disk. SMALLWEBWAF_COMMAND chooses which of the two
// the image runs.
package config package config
import ( import (
@@ -30,13 +31,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
) )
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero // Config is the settings of `smallwebwaf servewaf`. A time or size limit of
// is off. // zero is off.
type Config struct { type Config struct {
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR). // ListenAddr is where servewaf listens (SWWAF_LISTEN_ADDR).
ListenAddr string ListenAddr string
// UpstreamURL is the app (SWWAF_UPSTREAM_URL).
UpstreamURL *url.URL
// InstanceName is the name every log line and alert gives as instance, // InstanceName is the name every log line and alert gives as instance,
// and every metric carries as its label instance (SWWAF_INSTANCE_NAME), // and every metric carries as its label instance (SWWAF_INSTANCE_NAME),
// by default the host's name, which docker sets to the first 12 // by default the host's name, which docker sets to the first 12
@@ -45,42 +44,14 @@ type Config struct {
// Observe is true in observe mode, when SWWAF_MODE is observe rather // Observe is true in observe mode, when SWWAF_MODE is observe rather
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country // than enforce: a request that SWWAF_DENY_NETS, a ban, the country
// lists, a rate limit, a rule or the Core Rule Set would refuse is // lists, a rate limit, a rule or the Core Rule Set would refuse is
// passed to the app instead, and no ban is made. // allowed instead, and no ban is made.
Observe bool Observe bool
// TrustedProxies are the netblocks whose X-Forwarded-For is
// believed (SWWAF_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client // IPv6GroupPrefix is the length of the IPv6 netblock that is one client
// (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128. // (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128.
IPv6GroupPrefix int IPv6GroupPrefix int
// MaxTrackedClients is the most clients the table of clients holds, in // MaxTrackedClients is the most clients the table of clients holds, in
// memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS). // memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS).
MaxTrackedClients int MaxTrackedClients int
// ClientRequestTimeout bounds reading the whole request from the
// client (SWWAF_CLIENT_REQUEST_TIMEOUT).
ClientRequestTimeout time.Duration
// ClientRequestHeaderMaxBytes is the largest request line and headers
// a client may send (SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES). It is
// never off, and always more than 4K.
ClientRequestHeaderMaxBytes int64
// ClientIdleTimeout bounds how long a kept-open client connection
// may wait for its next request (SWWAF_CLIENT_IDLE_TIMEOUT).
ClientIdleTimeout time.Duration
// ClientResponseTimeout bounds writing the whole response to the
// client (SWWAF_CLIENT_RESPONSE_TIMEOUT).
ClientResponseTimeout time.Duration
// UpstreamRequestTimeout bounds connecting to the app and writing
// the whole request to it (SWWAF_UPSTREAM_REQUEST_TIMEOUT).
UpstreamRequestTimeout time.Duration
// UpstreamResponseTimeout bounds reading the whole response from
// the app (SWWAF_UPSTREAM_RESPONSE_TIMEOUT).
UpstreamResponseTimeout time.Duration
// RequestMaxBytes is the largest request body
// (SWWAF_REQUEST_MAX_BYTES).
RequestMaxBytes int64
// ResponseMaxBytes is the largest response body
// (SWWAF_RESPONSE_MAX_BYTES).
ResponseMaxBytes int64
// AllowNets are the netblocks whose clients skip every check // AllowNets are the netblocks whose clients skip every check
// (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the // (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS). // rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS).
@@ -260,9 +231,8 @@ type Config struct {
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute // ErrorBurstThreshold is the most requests of a client within a minute
// that smallwebwaf may refuse after a rule file or Core Rule Set match // that servewaf may refuse after a rule file or Core Rule Set match; one
// or for a missing or wrong token; one more breaks a limit // more breaks a limit (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
// (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64 ErrorBurstThreshold int64
// LogRemoteURL is where every line on stdout is also sent // LogRemoteURL is where every line on stdout is also sent
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent. // (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
@@ -365,8 +335,8 @@ const (
// masked is what the log shows for a token that is set, and in place of // masked is what the log shows for a token that is set, and in place of
// a secret in another setting. // a secret in another setting.
masked = "********" masked = "********"
// defaultListenAddr and defaultUpstreamURL are the defaults of // defaultListenAddr is the default of SWWAF_LISTEN_ADDR and
// SWWAF_LISTEN_ADDR and SWWAF_UPSTREAM_URL. // SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
defaultListenAddr = ":8080" defaultListenAddr = ":8080"
defaultUpstreamURL = "http://127.0.0.1:8081" defaultUpstreamURL = "http://127.0.0.1:8081"
) )
@@ -399,8 +369,9 @@ var (
errNeedsLookups = errors.New("it needs each client looked up") errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New("it names the file to look clients up in")
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K") errOver64K = errors.New(
errOver1G = errors.New("is more than 1G, the most Coraza reads") "is more than 64K, the most servewaf gets of a body")
errNotCommand = errors.New("is not rproxy or servewaf")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -489,22 +460,11 @@ var (
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr), ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
InstanceName: env.instanceName(), InstanceName: env.instanceName(),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWWAF_MODE", "enforce"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"), IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"), MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
ClientRequestHeaderMaxBytes: env.headerSize(
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"),
ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"),
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "1G"),
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
@@ -612,22 +572,30 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
return cfg, nil return cfg, nil
} }
// ListenAddrAndUpstreamURL reads only SWWAF_LISTEN_ADDR and // Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its
// SWWAF_UPSTREAM_URL, either of which may be given as a file, as // health check checks: rproxy when it is unset or empty, or servewaf. It cannot
// FromEnvironment does. The health check needs no other setting, so it // be given as a file. Any other value is an error that names the setting.
// reads no other, nor a file that another names. func Command(lookupEnv func(string) (string, bool)) (string, error) {
func ListenAddrAndUpstreamURL( command, _ := lookupEnv("SMALLWEBWAF_COMMAND")
lookupEnv func(string) (string, bool),
) (string, *url.URL, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR", defaultListenAddr)
upstreamURL := env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil { switch command {
return "", nil, env.err case "", "rproxy":
return "rproxy", nil
case "servewaf":
return command, nil
default:
return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand)
} }
}
return listenAddr, upstreamURL, nil // ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in servewaf's container needs
// no other setting, so it reads no other, nor a file that another names.
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWWAF_LISTEN_ADDR")
return listenAddr, env.err
} }
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a // InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
@@ -709,9 +677,10 @@ func (e *environment) check(name string, err error) {
} }
} }
// address reads a setting that is an address to listen on. // address reads a setting that is an address to listen on, by default
func (e *environment) address(name, defaultValue string) string { // defaultListenAddr.
address, err := parseListenAddr(e.value(name, defaultValue)) func (e *environment) address(name string) string {
address, err := parseListenAddr(e.value(name, defaultListenAddr))
e.check(name, err) e.check(name, err)
return address return address
@@ -779,15 +748,6 @@ func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
return limit return limit
} }
// headerSize reads the setting that is the largest request line and
// headers.
func (e *environment) headerSize(name, defaultValue string) int64 {
size, err := parseHeaderSize(e.value(name, defaultValue))
e.check(name, err)
return size
}
// count reads a setting that is a number of requests. // count reads a setting that is a number of requests.
func (e *environment) count(name, defaultValue string) int64 { func (e *environment) count(name, defaultValue string) int64 {
count, err := parseCount(e.value(name, defaultValue)) count, err := parseCount(e.value(name, defaultValue))
@@ -1434,30 +1394,17 @@ func parseSize(value string) (int64, error) {
return n * unit, nil return n * unit, nil
} }
// parseHeaderSize reads the largest request line and headers: a size as
// parseSize reads it, but more than 4K and never off. Go's server reads 4K
// past the limit it is given before it refuses, so proxy.New gives it this
// size less 4K, which must leave a limit.
func parseHeaderSize(value string) (int64, error) {
size, err := parseSize(value)
if err != nil || size <= 4*kibibyte {
return 0, fmt.Errorf("%q %w", value, errNotOver4K)
}
return size, nil
}
// parseWAFBodyLimit reads the most of a request body the Core Rule Set // parseWAFBodyLimit reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 1G, since // reads: a size as parseSize reads it, or off, but at most 64K, since servewaf
// Coraza, which runs the Core Rule Set, refuses to load with more. // gets no more of a body than that.
func parseWAFBodyLimit(value string) (int64, error) { func parseWAFBodyLimit(value string) (int64, error) {
limit, err := parseSize(value) limit, err := parseSize(value)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if limit > gibibyte { if limit > 64*kibibyte {
return 0, fmt.Errorf("%q %w", value, errOver1G) return 0, fmt.Errorf("%q %w", value, errOver64K)
} }
return limit, nil return limit, nil
+38 -151
View File
@@ -24,19 +24,9 @@ import (
// The settings, by name. // The settings, by name.
const ( const (
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWWAF_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL"
mode = "SWWAF_MODE" mode = "SWWAF_MODE"
trustedProxies = "SWWAF_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWWAF_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS" denyNets = "SWWAF_DENY_NETS"
@@ -254,14 +244,6 @@ func TestDefaults(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: ":8080", ListenAddr: ":8080",
Observe: false, Observe: false,
ClientRequestTimeout: time.Minute,
ClientRequestHeaderMaxBytes: 32 << 10,
ClientIdleTimeout: 2 * time.Minute,
ClientResponseTimeout: 30 * time.Minute,
UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 500, RateLimitPerMinute: 500,
RateLimitPerHour: 20000, RateLimitPerHour: 20000,
RateLimitPerDay: 300000, RateLimitPerDay: 300000,
@@ -288,12 +270,6 @@ func TestDefaults(t *testing.T) {
BytesLimitPerDay: 200 << 30, BytesCount: "both", BytesLimitPerDay: 200 << 30, BytesCount: "both",
}) })
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
}
wantNetblocks(t, cfg.TrustedProxies,
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets) wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets) wantNetblocks(t, cfg.DenyNets)
@@ -322,8 +298,13 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
cfg := fromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil {
t.Fatalf("settings: %v", err)
}
// The largest request body and the largest response, both counted. // The largest request body and the largest response, both counted.
largest := cfg.RequestMaxBytes + cfg.ResponseMaxBytes largest := rproxy.MaxRequestSize + rproxy.MaxResponseSize
for _, limit := range []int64{ for _, limit := range []int64{
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
} { } {
@@ -338,17 +319,7 @@ func TestValuesAsSet(t *testing.T) {
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
listenAddr: "127.0.0.1:9000", listenAddr: "127.0.0.1:9000",
upstreamURL: "https://app.internal:8443/",
mode: "observe", mode: "observe",
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
clientRequestTimeout: "90s",
clientHeaderMaxBytes: "8K",
clientIdleTimeout: "5m",
clientResponseTimeout: "7d",
upstreamRequestTimeout: "1h30m",
upstreamResponseTimeout: off,
requestMaxBytes: "512K",
responseMaxBytes: "1234",
allowNets: "192.0.2.7", allowNets: "192.0.2.7",
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7", rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
denyNets: "198.51.100.0/24", denyNets: "198.51.100.0/24",
@@ -376,14 +347,6 @@ func TestValuesAsSet(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: "127.0.0.1:9000", ListenAddr: "127.0.0.1:9000",
Observe: true, Observe: true,
ClientRequestTimeout: 90 * time.Second,
ClientRequestHeaderMaxBytes: 8 << 10,
ClientIdleTimeout: 5 * time.Minute,
ClientResponseTimeout: 7 * 24 * time.Hour,
UpstreamRequestTimeout: 90 * time.Minute,
UpstreamResponseTimeout: 0,
RequestMaxBytes: 512 << 10,
ResponseMaxBytes: 1234,
RateLimitPerMinute: 60, RateLimitPerMinute: 60,
RateLimitPerHour: 600, RateLimitPerHour: 600,
RateLimitPerDay: 6000, RateLimitPerDay: 6000,
@@ -403,11 +366,6 @@ func TestValuesAsSet(t *testing.T) {
RulesEnabled: false, RulesEnabled: false,
}) })
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
}
wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32")
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32") wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32") wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24") wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
@@ -581,13 +539,13 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "128K", wafBodyLimit: "32K",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 128 << 10, WAFBodyLimit: 32 << 10,
}, },
}, },
{ {
@@ -615,12 +573,12 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestWAFBodyLimitOf1G(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"}) cfg := fromEnvironment(t, environment{wafBodyLimit: "64K"})
if cfg.WAFBodyLimit != 1<<30 { if cfg.WAFBodyLimit != 64<<10 {
t.Errorf("1G read as %d", cfg.WAFBodyLimit) t.Errorf("64K read as %d", cfg.WAFBodyLimit)
} }
} }
@@ -669,10 +627,10 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
wafBodyLimit, "128KB", wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"128KB" is not a size such as 512K, 100M or 5G, or off`,
}, },
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`}, {wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
{ {
wafBodyLimit, "1073741825", wafBodyLimit, "65537",
`"1073741825" is more than 1G, the most Coraza reads`, `"65537" is more than 64K, the most servewaf gets of a body`,
}, },
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
@@ -1967,45 +1925,15 @@ func TestSizesAndOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
requestMaxBytes: "3G", bytesLimitPerMinute: "3G",
responseMaxBytes: off, bytesLimitPerHour: off,
clientRequestTimeout: off, alertCooldown: off,
clientIdleTimeout: off,
}) })
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 || if cfg.BytesLimitPerMinute != 3<<30 || cfg.BytesLimitPerHour != 0 ||
cfg.ClientRequestTimeout != 0 || cfg.ClientIdleTimeout != 0 { cfg.AlertCooldown != 0 {
t.Errorf("3G, off, off and off read as %d, %d, %s and %s", t.Errorf("3G, off and off read as %d, %d and %s",
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.AlertCooldown)
cfg.ClientIdleTimeout)
}
}
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
if cfg.ClientRequestHeaderMaxBytes != 4097 {
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
}
}
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
t.Parallel()
for _, value := range []string{"32KB", "0", "4K", off} {
t.Run(value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(
environment{clientHeaderMaxBytes: value}.lookupEnv)
want := clientHeaderMaxBytes + `: "` + value +
`" is not a size of more than 4K, such as 32K`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
} }
} }
@@ -2034,34 +1962,12 @@ func TestBanResponseCloseIsZero(t *testing.T) {
} }
} }
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{trustedProxies: ""})
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
func TestInvalidValueStopsTheStart(t *testing.T) { func TestInvalidValueStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartStopped(t, []struct{ name, value string }{ wantStartStopped(t, []struct{ name, value string }{
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"}, {listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
{upstreamURL, "127.0.0.1:8081"},
{upstreamURL, "ftp://127.0.0.1:8081"},
{upstreamURL, "http://"},
{upstreamURL, "http://:8081"},
{upstreamURL, "http://127.0.0.1:0"},
{upstreamURL, "http://127.0.0.1:99999"},
{upstreamURL, "http://127.0.0.1:8081/app"},
{upstreamURL, "http://127.0.0.1:8081/?a=1"},
{upstreamURL, "http://user:secret@127.0.0.1:8081"},
{mode, "Observe"}, {mode, "block"}, {mode, ""}, {mode, "Observe"}, {mode, "block"}, {mode, ""},
{trustedProxies, "10.0.0.0/33"},
{trustedProxies, "traefik"},
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
{trustedProxies, "fe80::1%eth0"},
{ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"}, {ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"},
{ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""}, {ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""},
{maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off}, {maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off},
@@ -2069,19 +1975,18 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{allowNets, "192.0.2.0/24,monitoring"}, {allowNets, "192.0.2.0/24,monitoring"},
{rateLimitExemptNets, "2001:db8::/129"}, {rateLimitExemptNets, "2001:db8::/129"},
{denyNets, "198.51.100.0/24,"}, {denyNets, "198.51.100.0/24,"},
{clientRequestTimeout, "60"}, {clientRequestTimeout, ""}, {alertCooldown, "60"}, {alertCooldown, ""},
{clientIdleTimeout, "0s"}, {alertCooldown, "0s"},
{clientIdleTimeout, "2 minutes"}, {alertCooldown, "2 minutes"},
{clientResponseTimeout, "1y"}, {alertCooldown, "1y"},
{upstreamRequestTimeout, "-1s"}, {alertCooldown, "-1s"},
{upstreamResponseTimeout, "0s"}, {alertCooldown, "1.5d"},
{upstreamResponseTimeout, "1.5d"}, {bytesLimitPerMinute, "100MB"},
{requestMaxBytes, "100MB"}, {bytesLimitPerMinute, "100m"},
{requestMaxBytes, "100m"}, {bytesLimitPerMinute, "1.5M"},
{requestMaxBytes, "1.5M"}, {bytesLimitPerHour, "0"},
{responseMaxBytes, "0"}, {bytesLimitPerHour, "-5"},
{responseMaxBytes, "-5"}, {bytesLimitPerHour, "99999999999G"},
{responseMaxBytes, "99999999999G"},
{rateLimitPerMinute, ""}, {rateLimitPerMinute, ""},
{rateLimitPerMinute, "1K"}, {rateLimitPerMinute, "1K"},
{rateLimitPerHour, "0"}, {rateLimitPerHour, "0"},
@@ -2246,10 +2151,10 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
requestMaxBytes + "_FILE": writeFile(t, "lots\n"), bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv) }.lookupEnv)
want := requestMaxBytes + `: "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 {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2354,28 +2259,18 @@ func writeFile(t *testing.T, contents string) string {
func TestLogsEachSettingWithItsValue(t *testing.T) { func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"}) cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"})
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
want := map[string]string{ want := map[string]string{
listenAddr: ":8080", listenAddr: ":8080",
upstreamURL: "http://127.0.0.1:8081",
mode: "enforce", mode: "enforce",
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
ipv6GroupPrefix: "64", ipv6GroupPrefix: "64",
maxTrackedClients: "20000", maxTrackedClients: "20000",
clientRequestTimeout: "45s",
clientHeaderMaxBytes: "32K",
clientIdleTimeout: "120s",
clientResponseTimeout: "30m",
upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m",
requestMaxBytes: "100M",
responseMaxBytes: "1G",
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "500", rateLimitPerMinute: "45",
rateLimitPerHour: "20000", rateLimitPerHour: "20000",
rateLimitPerDay: "300000", rateLimitPerDay: "300000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
@@ -2481,14 +2376,6 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
if got.ListenAddr != want.ListenAddr || if got.ListenAddr != want.ListenAddr ||
got.Observe != want.Observe || got.Observe != want.Observe ||
got.ClientRequestTimeout != want.ClientRequestTimeout ||
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
got.ClientIdleTimeout != want.ClientIdleTimeout ||
got.ClientResponseTimeout != want.ClientResponseTimeout ||
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
got.RequestMaxBytes != want.RequestMaxBytes ||
got.ResponseMaxBytes != want.ResponseMaxBytes ||
got.RateLimitPerMinute != want.RateLimitPerMinute || got.RateLimitPerMinute != want.RateLimitPerMinute ||
got.RateLimitPerHour != want.RateLimitPerHour || got.RateLimitPerHour != want.RateLimitPerHour ||
got.RateLimitPerDay != want.RateLimitPerDay { got.RateLimitPerDay != want.RateLimitPerDay {
+33 -8
View File
@@ -64,15 +64,9 @@ var (
// invalid is an error that names it. Since rproxy never touches disk, its // invalid is an error that names it. Since rproxy never touches disk, its
// settings have no _FILE form. // settings have no _FILE form.
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
env := &environment{lookupEnv: func(name string) (string, bool) { env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}}
cfg := &RProxy{ cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr), ListenAddr: env.address("SWRP_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
@@ -93,6 +87,37 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
return cfg, nil return cfg, nil
} }
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool),
) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
listenAddr := env.address("SWRP_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil {
return "", nil, env.err
}
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 {
+25
View File
@@ -125,7 +125,19 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
{rproxyDefault, "maybe"}, {rproxyDefault, "maybe"},
{rproxyListen, "8080"}, {rproxyListen, "8080"},
{rproxyUpstream, "app"}, {rproxyUpstream, "app"},
{rproxyUpstream, "127.0.0.1:8081"},
{rproxyUpstream, "ftp://127.0.0.1:8081"},
{rproxyUpstream, "http://"},
{rproxyUpstream, "http://:8081"},
{rproxyUpstream, "http://127.0.0.1:0"},
{rproxyUpstream, "http://127.0.0.1:99999"},
{rproxyUpstream, "http://127.0.0.1:8081/app"},
{rproxyUpstream, "http://127.0.0.1:8081/?a=1"},
{rproxyUpstream, "http://user:secret@127.0.0.1:8081"},
{rproxyTrusted, "not a netblock"}, {rproxyTrusted, "not a netblock"},
{rproxyTrusted, "10.0.0.0/33"},
{rproxyTrusted, "10.0.0.0/8,,192.168.0.0/16"},
{rproxyTrusted, "fe80::1%eth0"},
{rproxyMaxRequest, notASize}, {rproxyMaxRequest, notASize},
{rproxyMaxResponse, notASize}, {rproxyMaxResponse, notASize},
{rproxyRequestTime, "0"}, {rproxyRequestTime, "0"},
@@ -143,6 +155,19 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
} }
} }
func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
t.Parallel()
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL, rproxyTrusted: ""})
if err != nil {
t.Fatalf("settings: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
func TestRProxyNeedsItsBackend(t *testing.T) { func TestRProxyNeedsItsBackend(t *testing.T) {
t.Parallel() t.Parallel()
+12 -23
View File
@@ -35,7 +35,6 @@ type Metrics struct {
requestDuration prometheus.Histogram requestDuration prometheus.Histogram
upstreamDuration prometheus.Histogram upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec rateLimitHits *prometheus.CounterVec
sizeAndTimeLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec offences *prometheus.CounterVec
wafMatches *prometheus.CounterVec wafMatches *prometheus.CounterVec
// ruleMatches are made by AddRules, and reputationHits by // ruleMatches are made by AddRules, and reputationHits by
@@ -100,9 +99,6 @@ func New(topN int, instanceName string) *Metrics {
"Requests that broke a rate limit, a byte limit or the error burst, by "+ "Requests that broke a rate limit, a byte limit or the error burst, by "+
"its window and its kind, requests, bytes or refusals.", "its window and its kind, requests, bytes or refusals.",
[]string{"window", "kind"}), []string{"window", "kind"}),
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
"Requests that passed a size or time limit, by its setting.",
[]string{"limit"}),
offences: counterVec("smallwebwaf_offences_total", offences: counterVec("smallwebwaf_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
wafMatches: counterVec("smallwebwaf_waf_matches_total", wafMatches: counterVec("smallwebwaf_waf_matches_total",
@@ -143,7 +139,7 @@ func New(topN int, instanceName string) *Metrics {
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.inFlight, m.requests, m.requestBytes, m.responseBytes, m.inFlight, m.requests, m.requestBytes, m.responseBytes,
m.requestDuration, m.upstreamDuration, m.requestDuration, m.upstreamDuration,
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.wafMatches, m.rateLimitHits, m.offences, m.wafMatches,
m.countries, m.asns, m.countries, m.asns,
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered, m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
m.stateFileWrites, m.stateFileWriteFailures, m.stateFileWrites, m.stateFileWriteFailures,
@@ -395,15 +391,11 @@ func (m *Metrics) RequestStarted() {
m.inFlight.Inc() m.inFlight.Inc()
} }
// RequestEnded counts a request that has ended, from its log line. limit // RequestEnded counts a request that has ended without being passed to the app,
// is the setting whose size or time limit the request passed, "" if none. // from its log line. duration is how long the request took.
// duration is how long the request took, and upstreamDuration how long it func (m *Metrics) RequestEnded(line *requestlog.Line, duration time.Duration) {
// took from when it was passed to the app, zero if it was not.
func (m *Metrics) RequestEnded(
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
) {
m.inFlight.Dec() m.inFlight.Dec()
m.count(line, limit, duration, upstreamDuration) m.count(line, duration, 0)
} }
// RequestAllowed counts a question of rproxy's, answered allow, as no longer // RequestAllowed counts a question of rproxy's, answered allow, as no longer
@@ -412,12 +404,13 @@ func (m *Metrics) RequestAllowed() {
m.inFlight.Dec() m.inFlight.Dec()
} }
// RequestReported counts a request rproxy passed to the app, as RequestEnded // RequestReported counts a request rproxy passed to the app, from what its
// does, from what its report says, which line holds. // report says, which line holds: duration is how long the request took, and
// upstreamDuration how long it took from when it was passed to the app.
func (m *Metrics) RequestReported( func (m *Metrics) RequestReported(
line *requestlog.Line, duration, upstreamDuration time.Duration, line *requestlog.Line, duration, upstreamDuration time.Duration,
) { ) {
m.count(line, "", duration, upstreamDuration) m.count(line, duration, upstreamDuration)
} }
// LimitHit counts a request that broke a rate limit, a byte limit or the // LimitHit counts a request that broke a rate limit, a byte limit or the
@@ -435,7 +428,6 @@ func (m *Metrics) Offences(r ratelimit.Request) {
"attack": r.Attack, "attack": r.Attack,
"rule_blocked": r.RuleBlocked, "rule_blocked": r.RuleBlocked,
"waf_blocked": r.WAFBlocked, "waf_blocked": r.WAFBlocked,
"token_refused": r.TokenRefused,
} { } {
if committed { if committed {
m.offences.WithLabelValues(kind).Inc() m.offences.WithLabelValues(kind).Inc()
@@ -486,9 +478,10 @@ func (m *Metrics) StateFileEditSetAside(name string) {
m.stateFileEditsSetAside.WithLabelValues(name).Inc() m.stateFileEditsSetAside.WithLabelValues(name).Inc()
} }
// count counts a request that has ended, as RequestEnded describes. // count counts a request that has ended, as RequestEnded and RequestReported
// describe.
func (m *Metrics) count( func (m *Metrics) count(
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration, line *requestlog.Line, duration, upstreamDuration time.Duration,
) { ) {
class := statusClass(line.Status) class := statusClass(line.Status)
m.requests.WithLabelValues(class, line.Action).Inc() m.requests.WithLabelValues(class, line.Action).Inc()
@@ -500,10 +493,6 @@ func (m *Metrics) count(
m.upstreamDuration.Observe(upstreamDuration.Seconds()) m.upstreamDuration.Observe(upstreamDuration.Seconds())
} }
if limit != "" {
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
}
if line.Country != "" { if line.Country != "" {
m.countries.add(line.Country, line) m.countries.add(line.Country, line)
} }
+11 -41
View File
@@ -9,7 +9,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"strings" "strings"
"time" "time"
@@ -50,16 +49,13 @@ func (rq *request) answerHealth() {
_, _ = io.WriteString(rq.out, "ok\n") _, _ = io.WriteString(rq.out, "ok\n")
} }
// answerAdmin answers a request for smallwebwaf itself, under // answerAdmin answers a request for one of servewaf's own endpoints. Each
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a // needs a token, sent as Authorization: Bearer <token>: the metrics
// token, sent as Authorization: Bearer <token>: the metrics // SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without // refused with 401. An endpoint whose token is unset answers 404, as any other
// it is refused with 401, which counts toward the error burst. An // path does.
// endpoint whose token is unset answers 404, as any other request under
// /_smallwebwaf/ does.
func (rq *request) answerAdmin() { func (rq *request) answerAdmin() {
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionAdmin
rq.startClientResponseTimeout()
token, answer := rq.endpoint() token, answer := rq.endpoint()
@@ -67,12 +63,8 @@ func (rq *request) answerAdmin() {
case token == "": case token == "":
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
case !hasToken(rq.in, token): case !hasToken(rq.in, token):
rq.tokenRefused = true
rq.out.Header().Set("WWW-Authenticate", "Bearer") rq.out.Header().Set("WWW-Authenticate", "Bearer")
rq.answer(refusal{ http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
status: http.StatusUnauthorized,
action: requestlog.ActionAdmin,
})
default: default:
answer() answer()
} }
@@ -129,26 +121,7 @@ type banToAdd struct {
// an admin, from now for the duration the body gives, with its reason, // an admin, from now for the duration the body gives, with its reason,
// and answers with that ban. // and answers with that ban.
func (rq *request) addBan() { func (rq *request) addBan() {
// The body must arrive within SWWAF_CLIENT_REQUEST_TIMEOUT, as any
// other request's must.
rq.stopReadingBody(rq.clientRequestDeadline())
toAdd, err := rq.readBanToAdd() toAdd, err := rq.readBanToAdd()
if refused := rq.refused.Load(); refused != nil {
rq.answer(*refused) // the body is over SWWAF_REQUEST_MAX_BYTES
return
}
if errors.Is(err, os.ErrDeadlineExceeded) {
rq.answer(refusal{
status: http.StatusRequestTimeout,
action: requestlog.ActionTimedOut,
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
})
return
}
var ( var (
netblock netip.Prefix netblock netip.Prefix
@@ -177,12 +150,9 @@ func (rq *request) addBan() {
// readBanToAdd reads the body of POST BansPath: a JSON object with // readBanToAdd reads the body of POST BansPath: a JSON object with
// nothing but whitespace after it, in at most banBodyMaxBytes. // nothing but whitespace after it, in at most banBodyMaxBytes.
func (rq *request) readBanToAdd() (banToAdd, error) { func (rq *request) readBanToAdd() (banToAdd, error) {
var body io.ReadCloser = http.NoBody data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
if rq.body != nil { rq.requestBytes = int64(len(data))
body = rq.body
}
data, err := io.ReadAll(http.MaxBytesReader(nil, body, banBodyMaxBytes))
if err != nil { if err != nil {
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err) return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
} }
@@ -280,9 +250,9 @@ type clientAnswer struct {
Bans []state.BanEntry `json:"bans"` Bans []state.BanEntry `json:"bans"`
} }
// showClient answers GET ClientsPath<ip> with what smallwebwaf knows of // showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
// the client: its counters, its history, which holds its country as last // its counters, its history, which holds its country as last looked up and its
// looked up and its offences, and its bans with their notes. // offences, and its bans with their notes.
func (rq *request) showClient() { func (rq *request) showClient() {
addr, err := pathAddress(rq.in.URL.Path, ClientsPath) addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
if err != nil { if err != nil {
+18 -136
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -23,8 +22,6 @@ const (
// how a request carries it. // how a request carries it.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
adminBearer = "Bearer " + adminSecret adminBearer = "Bearer " + adminSecret
// adminClient is the client the tests' admin sends its requests from.
adminClient = "192.0.2.10"
// banOtherClient is the body of a request to ban otherClient for an // banOtherClient is the body of a request to ban otherClient for an
// hour. // hour.
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` + banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
@@ -43,8 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest(adminClient, authorization, e.method, e.path, e.body, s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
http.StatusNotFound, requestlog.ActionAdmin)
} }
} }
@@ -71,8 +67,8 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{ for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} { } {
got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body, got := s.adminRequest(authorization, e.method, e.path, e.body,
http.StatusUnauthorized, requestlog.ActionAdmin) http.StatusUnauthorized)
if got.header.Get("WWW-Authenticate") != "Bearer" { if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
e.method, e.path, authorization) e.method, e.path, authorization)
@@ -265,64 +261,12 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
} }
} }
func TestBanToAddOverTheRequestSizeLimitIsRefused(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
adminToken: adminSecret,
requestMaxBytes: "16",
})
// Sent in a chunk, its length is not announced, so that it is found
// over SWWAF_REQUEST_MAX_BYTES only as it is read.
chunk := `{"netblock": "203.0.113.9", "duration": "1h"}`
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
http.MethodPost, proxy.BansPath,
strconv.FormatInt(int64(len(chunk)), 16)+"\r\n"+chunk+"\r\n0\r\n\r\n",
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
if held := server.Ledger.Snapshot(); len(held) != 0 {
t.Errorf("the ledger holds %+v, want no ban", held)
}
}
func TestBanToAddSlowerThanTheClientRequestTimeoutIsRefused(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
adminToken: adminSecret,
metricsToken: token,
clientRequestTimeout: shortTimeoutSetting,
})
// The chunk announces 256 bytes and the rest of it never comes, so only
// the timeout ends the wait. A hold-up of the test process can only
// make the answer later, so the time is checked only for not being
// shorter than the timeout.
start := time.Now()
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
http.MethodPost, proxy.BansPath, "100\r\n"+`{"netblock": "203.0.113.9", `,
http.StatusRequestTimeout, requestlog.ActionTimedOut)
if took := time.Since(start); took < shortTimeout {
t.Errorf("answered after %s, before the timeout of %s ran out", took, shortTimeout)
}
wantLimitHits(t, s.addr, clientRequestTimeout, 1)
if held := server.Ledger.Snapshot(); len(held) != 0 {
t.Errorf("the ledger holds %+v, want no ban", held)
}
}
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
t.Parallel() t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{ s, clk, _ := startWithClock(t, "", map[string]string{
adminToken: adminSecret, adminToken: adminSecret,
rateLimitPerMinute: "2", rateLimitPerMinute: "2",
rateLimitExemptNets: adminClient,
}) })
start := clk.Now() start := clk.Now()
@@ -371,61 +315,6 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
http.StatusBadRequest) http.StatusBadRequest)
} }
func TestBannedClientIsRefusedAtTheEndpointsEvenWithTheToken(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{adminToken: adminSecret})
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
// otherClient cannot lift its own ban either.
for _, e := range adminEndpoints() {
s.adminRequest(otherClient, adminBearer, e.method, e.path, e.body,
http.StatusForbidden, requestlog.ActionBanned)
}
}
func TestAdminRequestsCountTowardTheLimits(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
adminToken: adminSecret,
rateLimitPerMinute: "2",
})
// A request refused for a missing token and one answered count toward
// the limit of two a minute, so the next breaks it.
s.adminRequest(client, "", http.MethodGet, proxy.BansPath, "",
http.StatusUnauthorized, requestlog.ActionAdmin)
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
http.StatusOK, requestlog.ActionAdmin)
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
http.StatusForbidden, requestlog.ActionRateLimited)
}
func TestClientInAllowNetsSkipsTheChecksButNeedsTheToken(t *testing.T) {
t.Parallel()
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
s, clk, server := startWithClock(t, "", map[string]string{
adminToken: adminSecret,
allowNets: allowed,
rateLimitPerMinute: "1",
})
// A ban on it refuses nothing, and its requests are not counted.
server.Ledger.BanForAdmin(netip.MustParsePrefix(allowed+"/32"), clk.Now(),
time.Time{}, "")
for range 2 {
s.adminRequest(allowed, "", http.MethodGet, proxy.BansPath, "",
http.StatusUnauthorized, requestlog.ActionAdmin)
s.adminRequest(allowed, adminBearer, http.MethodGet, proxy.BansPath, "",
http.StatusOK, requestlog.ActionAdmin)
}
}
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) { func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
t.Parallel() t.Parallel()
@@ -435,8 +324,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}) })
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest(adminClient, "", e.method, e.path, e.body, s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
http.StatusUnauthorized, requestlog.ActionAdmin)
} }
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -461,39 +349,33 @@ func adminEndpoints() []adminEndpoint {
} }
} }
// admin sends a request with method for path, with body, from // admin sends a request with method for path, with body, to servewaf's own
// adminClient, with the admin token, and checks that it is answered with // address, with the admin token, and checks that it is answered with status, its
// status, its log line's action admin. It returns the answer. // log line's action admin. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer { func (s *sender) admin(method, path, body string, status int) answer {
s.t.Helper() s.t.Helper()
return s.adminRequest(adminClient, adminBearer, method, path, body, status, return s.adminRequest(adminBearer, method, path, body, status)
requestlog.ActionAdmin)
} }
// adminRequest sends a request with method for path, with body, from the // adminRequest is admin with authorization as the request's Authorization header
// client at from, with authorization as its Authorization header unless // unless it is "".
// it is "", and checks its answer's status and its log line's action, as
// request does. authorization may end in more header lines. A body that
// is not "" has its length announced, unless authorization names
// Transfer-Encoding. It returns the answer.
func (s *sender) adminRequest( func (s *sender) adminRequest(
from, authorization, method, path, body string, status int, action string, authorization, method, path, body string, status int,
) answer { ) answer {
s.t.Helper() s.t.Helper()
var header []string req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
if authorization != "" { if authorization != "" {
header = append(header, "Authorization: "+authorization) req.Header.Set("Authorization", authorization)
} }
if body != "" && !strings.Contains(authorization, "Transfer-Encoding") { got := do(s.t, req)
header = append(header, "Content-Length: "+strconv.Itoa(len(body))) wantStatus(s.t, got, status)
}
_, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"), line := s.out.endedLines(s.t, s.sent+1)[s.sent]
body, status, action) s.sent++
wantLine(s.t, line, status, requestlog.ActionAdmin)
return got return got
} }
+4 -1
View File
@@ -224,7 +224,9 @@ func startAppWithAlerts(
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
return &sender{t: t, addr: addr, out: out}, clk, server, queue s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
return s, clk, server, queue
} }
// banAlert returns the alert for event, raised by a request from client at // banAlert returns the alert for event, raised by a request from client at
@@ -237,6 +239,7 @@ func banAlert(
Instance: alertInstance, Instance: alertInstance,
Time: raised, Time: raised,
Event: event, Event: event,
Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: ban.Netblock, Netblock: ban.Netblock,
Reason: ban.Reason, Reason: ban.Reason,
+1
View File
@@ -117,6 +117,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
Instance: alertInstance, Instance: alertInstance,
Time: start, Time: start,
Event: alerts.EventAnomaly, Event: alerts.EventAnomaly,
Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: scope.netblock, Netblock: scope.netblock,
ASN: asnDE, ASN: asnDE,
+3 -39
View File
@@ -315,20 +315,6 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
} }
} }
func TestWithoutACommandTheHeaderLimitsDoNotApply(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, nil)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
fillHeaders(req, headerFieldLimit+1)
wantStatus(t, do(t, req), http.StatusOK)
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
}
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never // newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
// answers, a setting needs its answer, and everything is at its default but // answers, a setting needs its answer, and everything is at its default but
// for env. No request to it may use the network. // for env. No request to it may use the network.
@@ -338,8 +324,7 @@ func newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *o
settings := map[string]string{deniedCountries: "KP"} settings := map[string]string{deniedCountries: "KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newServerWith(t, proxy.NewServeWAF, "http://app.invalid", server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
unansweredGeoJSURL, time.Now, settings)
return server, out return server, out
} }
@@ -521,27 +506,6 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
} }
} }
func TestWithoutACommandARefusedRequestStillWaitsForGeoJS(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
time.Now, map[string]string{deniedCountries: "KP", blocklistURLs: dropURL})
listClient(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) {
req.RemoteAddr = net.JoinHostPort(client, "1234")
})
if status != http.StatusForbidden || took != defaultLookupWait {
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
status, took, http.StatusForbidden, defaultLookupWait, out.text())
}
wantLine(t, out.requestLine(t), http.StatusForbidden, requestlog.ActionDenied)
})
}
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) { func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
t.Parallel() t.Parallel()
@@ -555,11 +519,11 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
t.Cleanup(func() { _ = resolver.Close() }) t.Cleanup(func() { _ = resolver.Close() })
server, out, _ := newServerWith(t, func(params proxy.Params) *proxy.Server { server, out, _ := newServeWAFWith(t, func(params proxy.Params) *proxy.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL params.AbuseIPDBURL = silentAbuseIPDBURL
return proxy.NewServeWAF(params) return proxy.NewServeWAF(params)
}, "http://app.invalid", held, time.Now, map[string]string{ }, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block, deniedCountries: "KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(), dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey, abuseIPDBKey: accountKey,
+12 -25
View File
@@ -58,13 +58,12 @@ func (rq *request) limitBroken(now time.Time) bool {
} }
// countBytes counts the request's bytes, as countedBytes gives them, for // countBytes counts the request's bytes, as countedBytes gives them, for
// the byte limits, once its response has ended, and notes the client's // the byte limits, once its report has come, and notes the client's byte totals
// byte totals for the log line; its requests stay there as the rate limits // for the log line; its requests stay there as the rate limits counted them.
// counted them. Only a request passed to the app has them counted, and // Only an allowed request has them counted, and only one the rate limits
// only one the rate limits counted; in observe mode, not one that enforce // counted; in observe mode, not one that enforce mode would have refused. Bytes
// mode would have refused. Bytes that take the client over a byte limit, // that take the client over a byte limit, as its limit percentage for the byte
// as its limit percentage for the byte limits lowers it, break it; the // limits lowers it, break it; the response was passed on whole.
// response was passed on whole.
func (rq *request) countBytes() { func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" { if !rq.counted || rq.line.WouldAction != "" {
return return
@@ -84,9 +83,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 smallwebwaf refused it after a rule file match, a trap path // answered, if servewaf refused it after a rule file match, a trap path or a
// or a Core Rule Set match, or for a missing or wrong token, and in // Core Rule Set match, and in observe mode if enforce mode would have: more than
// observe mode if enforce mode would have: more than
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute // SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is // break a limit. A client in SWWAF_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.
@@ -96,11 +94,7 @@ func (rq *request) countRefusal() {
return return
} }
// In observe mode, a request that enforce mode would have refused if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
// before it reached the endpoint has had no token refused there.
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
!isInside(rq.client, cfg.AllowNets)
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked && !tokenRefused {
return return
} }
@@ -127,16 +121,9 @@ func (rq *request) countRefusal() {
// countedBytes returns the request's bytes, once it has ended, as the // countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body // byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an // bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
// upgraded connection, such as a WebSocket, which has closed by then, what
// it carried from the app counts with the response's and what it carried
// from the client with the request's.
func (rq *request) countedBytes() int64 { func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes() response, request := rq.out.bytes, rq.requestBytes
if rq.upgraded != nil {
response += rq.upgraded.fromApp.Load()
request += rq.upgraded.toApp.Load()
}
switch rq.h.config.BytesCount { switch rq.h.config.BytesCount {
case "response": case "response":
@@ -151,7 +138,7 @@ func (rq *request) countedBytes() int64 {
// banForLimit bans the client's netblock at now for a broken limit, the // banForLimit bans the client's netblock at now for a broken limit, the
// one hit names, notes the offence for the log line and counts the hit in // one hit names, notes the offence for the log line and counts the hit in
// the metrics. status is what the client was sent, or is sent: // the metrics. status is what the client was sent, or is sent:
// SWWAF_BAN_RESPONSE for a request over a rate limit, the app's answer for // SWWAF_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
+21 -18
View File
@@ -9,6 +9,7 @@ import (
"net/netip" "net/netip"
"reflect" "reflect"
"slices" "slices"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -374,7 +375,7 @@ func (c *clock) advance(d time.Duration) {
c.now = c.now.Add(d) c.now = c.now.Add(d)
} }
// startWithClock starts smallwebwaf in front of an app that answers 200, // startWithClock starts servewaf, and rproxy in front of an app that answers 200,
// with the settings in env on top of trusting localhost's // with the settings in env on top of trusting localhost's
// X-Forwarded-For, clients' AS numbers and countries looked up at // X-Forwarded-For, clients' AS numbers and countries looked up at
// geojsURL, and a clock set to midnight, the start of a bucket in every // geojsURL, and a clock set to midnight, the start of a bucket in every
@@ -391,16 +392,17 @@ func startWithClock(
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings) addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
return &sender{t: t, addr: addr, out: out}, clk, server return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
} }
// sender sends requests to smallwebwaf one after another, each on a // sender sends requests to rproxy one after another, each on a connection of its
// connection of its own, and checks each one's answer and log line. They // own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
// must be the only requests smallwebwaf is sent, since the log lines are // own endpoints are, which adminRequest asks. Every request servewaf gets must
// matched to them in order. // come through the sender, since the log lines are matched to them in order.
type sender struct { type sender struct {
t *testing.T t *testing.T
addr string addr string
serveWAFAddr string
out *output out *output
sent int sent int
} }
@@ -419,22 +421,19 @@ func (s *sender) get(from string, status int, action string) logLine {
func (s *sender) request(from, path string, status int, action string) logLine { func (s *sender) request(from, path string, status int, action string) logLine {
s.t.Helper() s.t.Helper()
line, _ := s.requestWithHeader(from, path, "", status, action) return s.requestWithHeader(from, path, "", status, action)
return line
} }
// requestWithHeader is request with header, such as "Authorization: // requestWithHeader is request with header, such as "Authorization:
// Bearer x", added to the request unless it is "". It returns the body of // Bearer x", added to the request unless it is "".
// the answer too.
func (s *sender) requestWithHeader( func (s *sender) requestWithHeader(
from, path, header string, status int, action string, from, path, header string, status int, action string,
) (logLine, string) { ) logLine {
s.t.Helper() s.t.Helper()
line, got := s.requestWithBody(http.MethodGet, from, path, header, "", status, action) line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
return line, string(got.body) return line
} }
// requestWithBody is requestWithHeader for a request with method, whose // requestWithBody is requestWithHeader for a request with method, whose
@@ -450,10 +449,14 @@ func (s *sender) requestWithBody(
header += "\r\n" header += "\r\n"
} }
// rproxy refuses a request with more than one.
if !strings.Contains(header, "User-Agent:") {
header += "User-Agent: " + userAgent + "\r\n"
}
conn := dial(s.t, s.addr) conn := dial(s.t, s.addr)
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+ send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+"\r\n"+
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+ forwardedFor+": "+from+"\r\n"+header+"\r\n"+body)
header+"\r\n"+body)
err := conn.SetReadDeadline(time.Now().Add(waitLimit)) err := conn.SetReadDeadline(time.Now().Add(waitLimit))
if err != nil { if err != nil {
@@ -478,7 +481,7 @@ func (s *sender) requestWithBody(
got.status, status) got.status, status)
} }
line := s.out.requestLines(s.t, s.sent+1)[s.sent] line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++ s.sent++
wantLine(s.t, line, status, action) wantLine(s.t, line, status, action)
+11 -16
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"io" "io"
"maps" "maps"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -281,12 +280,12 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL, server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
time.Now, map[string]string{unknownLimitPercent: "0"}) map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client // Once the time the request waits for its answer is up, the client
// counts as without a country, and its zero allowance refuses the // counts as without a country, and its zero allowance refuses the
// request before it reaches the app. // request.
serveFromDE(t, server, http.MethodGet, http.NoBody) serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t) line := out.requestLine(t)
@@ -320,13 +319,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// The request's body is over SWWAF_REQUEST_MAX_BYTES, so that it server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
// is refused after the checks, and never reaches the app. map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
time.Now, map[string]string{
lookupTimeout: timeout.String(), requestMaxBytes: "1",
tc.setting: tc.value,
})
began := time.Now() began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab")) serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
@@ -340,8 +334,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
t.Errorf("the request waited %s for its answer, want %s", waited, want) t.Errorf("the request waited %s for its answer, want %s", waited, want)
} }
wantLine(t, out.requestLine(t), http.StatusRequestEntityTooLarge, wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
requestlog.ActionTooLarge)
// The bubble's clock stops once this function returns, so the // The bubble's clock stops once this function returns, so the
// request to GeoJS, which a request that did not wait leaves // request to GeoJS, which a request that did not wait leaves
@@ -467,13 +460,15 @@ func (s *sender) uploadFrom(from string) logLine {
return line return line
} }
// serveFromDE hands a request from fromDE with method and body straight to // serveFromDE hands a question about a request from fromDE with method and body
// server's handler, without the network, and returns once it is answered. // straight to server's handler, without the network, and returns once it is
// answered.
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) { func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, "/", body) req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
req.RemoteAddr = net.JoinHostPort(fromDE, "1234") req.Header.Set(proxy.ClientHeader, fromDE)
req.Header.Set(requestIDHeader, "the-id")
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
} }
-239
View File
@@ -1,239 +0,0 @@
package proxy
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"sync/atomic"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// errResponseTooLarge ends an app's response body that is longer than
// SWWAF_RESPONSE_MAX_BYTES.
var errResponseTooLarge = errors.New(
"the response body is over SWWAF_RESPONSE_MAX_BYTES")
// requestBody is the client's request body on its way to the app. The
// transport reads it on a goroutine of its own.
type requestBody struct {
// body is the client's body, ending in an *http.MaxBytesError past
// SWWAF_REQUEST_MAX_BYTES.
body io.ReadCloser
rq *request
// readByCoreRuleSet is what the Core Rule Set read of the body before
// the request went to the app, and Read gives first.
readByCoreRuleSet []byte
// waiting is true while a Read waits for the client to send more.
waiting atomic.Bool
// received is true once the client has sent the whole body.
received atomic.Bool
// bytes is how much of the body has been read.
bytes atomic.Int64
}
// Read reads from the client's body, after what the Core Rule Set read of
// it, which has been counted already.
func (b *requestBody) Read(p []byte) (int, error) {
if len(b.readByCoreRuleSet) > 0 {
n := copy(p, b.readByCoreRuleSet)
b.readByCoreRuleSet = b.readByCoreRuleSet[n:]
return n, nil
}
b.waiting.Store(true)
n, err := b.body.Read(p)
b.waiting.Store(false)
b.bytes.Add(int64(n))
var tooLarge *http.MaxBytesError
switch {
case errors.Is(err, io.EOF):
b.received.Store(true)
b.rq.bodyReceived()
case errors.As(err, &tooLarge):
b.rq.refuse(refusal{
status: http.StatusRequestEntityTooLarge,
action: requestlog.ActionTooLarge,
limit: "SWWAF_REQUEST_MAX_BYTES",
})
}
return n, err
}
// Close closes the client's body.
func (b *requestBody) Close() error {
return b.body.Close()
}
// responseBody is the app's response body on its way to the client.
type responseBody struct {
// body is the app's body, ending in an *http.MaxBytesError past
// SWWAF_RESPONSE_MAX_BYTES.
body io.ReadCloser
rq *request
}
// Read reads from the app's body.
func (b *responseBody) Read(p []byte) (int, error) {
n, err := b.body.Read(p)
if err == nil {
return n, nil
}
var tooLarge *http.MaxBytesError
switch {
case errors.Is(err, io.EOF):
b.rq.responseReceived()
case errors.As(err, &tooLarge):
b.rq.refuse(refusal{
status: http.StatusBadGateway,
action: requestlog.ActionTooLarge,
limit: "SWWAF_RESPONSE_MAX_BYTES",
})
return n, errResponseTooLarge
case b.rq.in.Context().Err() == nil:
// The answer broke off, not because the client went away. If a
// timeout cut it, that refusal came first and is the one kept.
b.rq.refuse(refusal{
status: http.StatusBadGateway,
action: requestlog.ActionUpstreamError,
})
}
return n, err
}
// Close closes the app's body.
func (b *responseBody) Close() error {
return b.body.Close()
}
// upgradedConn is the connection to the app once the app has switched
// protocols, as for a WebSocket. ReverseProxy writes to it what the client
// sends and reads from it what the app sends, on goroutines of its own,
// until the connection closes; it counts the bytes each way, for the byte
// limits.
type upgradedConn struct {
io.ReadWriteCloser
// fromApp is how many bytes the app has sent, and toApp how many the
// client has.
fromApp atomic.Int64
toApp atomic.Int64
}
// Read reads what the app sends.
func (c *upgradedConn) Read(p []byte) (int, error) {
n, err := c.ReadWriteCloser.Read(p)
c.fromApp.Add(int64(n))
return n, err
}
// Write sends the app what the client sent.
func (c *upgradedConn) Write(p []byte) (int, error) {
n, err := c.ReadWriteCloser.Write(p)
c.toApp.Add(int64(n))
return n, err
}
// CloseWrite tells the app that the client sends no more, while what the
// app sends still passes. ReverseProxy calls it once the client has
// stopped sending, and closes the connection there if it is not supported.
func (c *upgradedConn) CloseWrite() error {
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
if !ok {
return http.ErrNotSupported
}
return conn.CloseWrite()
}
// limitBody returns body, cut off with an *http.MaxBytesError after
// maxBytes, or unchanged if maxBytes is zero, which is off.
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
if maxBytes == 0 {
return body
}
// Without a ResponseWriter, MaxBytesReader only counts and cuts off.
return http.MaxBytesReader(nil, body, maxBytes)
}
// responseWriter is the response to the client. It notes the status and
// size for the log line, and the first error writing to the client.
type responseWriter struct {
http.ResponseWriter
// status is the final status sent, or zero before one is.
status int
bytes int64
err error
}
// WriteHeader sends the status and headers. An informational 1xx status
// is passed on and the final status still comes later.
func (w *responseWriter) WriteHeader(status int) {
if status >= http.StatusOK && w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
// Write sends part of the body.
func (w *responseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(p)
w.bytes += int64(n)
w.noteError(err)
return n, err
}
// FlushError sends what has been written so far.
// http.ResponseController calls it, as ReverseProxy does after each
// write.
func (w *responseWriter) FlushError() error {
err := http.NewResponseController(w.ResponseWriter).Flush()
w.noteError(err)
return err
}
// Hijack takes over the connection to the client, which ReverseProxy does
// only to pass on the app's 101 Switching Protocols: that is then the
// status sent.
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
if err == nil {
w.status = http.StatusSwitchingProtocols
}
return conn, buffered, err
}
// Unwrap lets http.ResponseController reach net/http's own
// ResponseWriter for what responseWriter does not do itself.
func (w *responseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// noteError keeps the first error writing to the client.
func (w *responseWriter) noteError(err error) {
if w.err == nil {
w.err = err
}
}
+3 -60
View File
@@ -47,8 +47,6 @@ const (
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) { func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
for _, tc := range []struct { for _, tc := range []struct {
setting, window string setting, window string
// apart is the time between the two requests, which the window // apart is the time between the two requests, which the window
@@ -94,7 +92,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantMetric(t, s.scrape(scraper), `smallwebwaf_rate_limit_hits_total{`+ wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
}) })
} }
@@ -126,7 +124,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
// The 70 bytes passed on before the app broke off are over the limit of // The 70 bytes passed on before the app broke off are over the limit of
// 50, and ban the client for an hour. // 50, and ban the client for an hour.
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK, line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
requestlog.ActionUpstreamError) requestlog.ActionForward)
if len(got.body) != answerBytes || line.LimitHit != minuteBytes || if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
line.BanExpires != expires { line.BanExpires != expires {
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+ t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
@@ -168,39 +166,6 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
} }
} }
func TestWebSocketPassesTheAnswerAfterTheClientStopsSending(t *testing.T) {
t.Parallel()
app := startApp(t, echoOnceTheClientStops)
addr, out := startProxy(t, app.URL,
map[string]string{trustedProxies: trustLocalhost})
s := &sender{t: t, addr: addr, out: out}
conn, reader := s.openWebSocket()
send(t, conn, uploadBody)
// The client closes its sending side and waits for the answer, which the
// app sends only once it has seen the client stop. smallwebwaf passes the
// close on to the app through CloseWrite on upgradedConn; without that,
// it closes both connections, and the answer is lost.
tcp, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("connection is a %T, want a *net.TCPConn", conn)
}
err := tcp.CloseWrite()
if err != nil {
t.Fatalf("close the sending side: %v", err)
}
got, err := io.ReadAll(reader)
if err != nil || string(got) != uploadBody {
t.Errorf("got %q (%v), want %q", got, err, uploadBody)
}
s.closeWebSocket(conn)
}
func TestBytesCountSaysWhichBytesCount(t *testing.T) { func TestBytesCountSaysWhichBytesCount(t *testing.T) {
t.Parallel() t.Parallel()
@@ -465,28 +430,6 @@ func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
} }
} }
// echoOnceTheClientStops is an app that switches protocols, as for a
// WebSocket, reads what the client sends until the client stops sending,
// and then sends it all back.
func echoOnceTheClientStops(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_ = buffered.Flush()
received, _ := io.ReadAll(buffered)
_, _ = buffered.Write(received)
_ = buffered.Flush()
}
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a // webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
// line of bodyBytes on it, reads the answer, and closes it. It checks the // line of bodyBytes on it, reads the answer, and closes it. It checks the
// answer, and the log line as request does, and returns the log line. // answer, and the log line as request does, and returns the log line.
@@ -543,7 +486,7 @@ func (s *sender) closeWebSocket(conn net.Conn) logLine {
_ = conn.Close() _ = conn.Close()
line := s.out.requestLines(s.t, s.sent+1)[s.sent] line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++ s.sent++
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward) wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
+12 -92
View File
@@ -5,10 +5,9 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strings"
) )
// peerAddress is the address of the request's TCP peer, normally traefik. // peerAddress is the address of the request's TCP peer.
func peerAddress(r *http.Request) netip.Addr { func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr) addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil { if err != nil {
@@ -18,58 +17,24 @@ func peerAddress(r *http.Request) netip.Addr {
return addrPort.Addr().Unmap() return addrPort.Addr().Unmap()
} }
// clientAddress works out who the client is. A peer outside the trusted // requestIDHeader carries the request's id, from rproxy.
// proxies is the client, and what it says in X-Forwarded-For is ignored.
// For a peer inside them, X-Forwarded-For is read from the right, and the
// first address outside them is the client; if every address in it is
// inside, the leftmost is, and with no header, the peer. An entry that is
// not an address ends the reading, since nothing to its left can be
// believed.
func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr {
client := peer
if !isInside(peer, trusted) {
return client
}
entries := strings.Split(strings.Join(forwardedFor, ","), ",")
for _, entry := range slices.Backward(entries) {
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
if err != nil {
break
}
client = addr.Unmap()
if !isInside(client, trusted) {
break
}
}
return client
}
// requestIDHeader carries the request's id, from traefik and to the app.
const requestIDHeader = "X-Request-ID" const requestIDHeader = "X-Request-ID"
// requestID is the request's id: the one a trusted proxy sent, or a new // requestID is the request's id: the one a question gives, or for any other
// random one. A peer outside the trusted proxies did not come through // request a new random one.
// traefik, so the id it sends is its own claim, and is replaced. func requestID(r *http.Request, isQuestion bool) string {
func requestID(r *http.Request, peerTrusted bool) string { if isQuestion {
id := r.Header.Get(requestIDHeader) return r.Header.Get(requestIDHeader)
if !peerTrusted || id == "" {
id = rand.Text()
} }
return id return rand.Text()
} }
// scheme is how the client reached traefik, as a trusted proxy says in // scheme is how the visitor reached rproxy, or the trusted proxy in front of
// X-Forwarded-Proto, or otherwise http, the only scheme smallwebwaf // it, as a question says in X-Forwarded-Proto, or otherwise http.
// serves. func scheme(r *http.Request, isQuestion bool) string {
func scheme(r *http.Request, peerTrusted bool) string {
proto := r.Header.Get("X-Forwarded-Proto") proto := r.Header.Get("X-Forwarded-Proto")
if !peerTrusted || proto == "" { if !isQuestion || proto == "" {
return "http" return "http"
} }
@@ -95,48 +60,3 @@ func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
return netblock.Contains(addr) return netblock.Contains(addr)
}) })
} }
// setForwardedHeaders sets the headers in which the app learns about the
// client, so that it sees what it would see from traefik directly. A
// trusted proxy's forwarded headers pass on, with the proxy's own address
// added to X-Forwarded-For. Those of any other peer are its own claims and
// are replaced: X-Forwarded-For names the peer, X-Forwarded-Host the host
// it asked for, and X-Forwarded-Proto plain http, which is how it reached
// smallwebwaf.
func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
forwardedFor := peer.String()
if trusted {
// ReverseProxy removes these from out before Rewrite.
for _, name := range []string{"Forwarded", "X-Forwarded-Host", "X-Forwarded-Proto"} {
values, ok := in.Header[name]
if ok {
out.Header[name] = values
}
}
prior := in.Header.Values("X-Forwarded-For")
if len(prior) > 0 {
forwardedFor = strings.Join(prior, ", ") + ", " + forwardedFor
}
out.Header.Set("X-Forwarded-For", forwardedFor)
return
}
// ReverseProxy has removed Forwarded and the three set below; these
// are the other headers in which traefik tells the app about the
// client and its request.
for _, name := range []string{
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
} {
out.Header.Del(name)
}
out.Header.Set("X-Forwarded-For", forwardedFor)
out.Header.Set("X-Forwarded-Host", in.Host)
out.Header.Set("X-Forwarded-Proto", "http")
}
-165
View File
@@ -1,165 +0,0 @@
package proxy_test
import (
"encoding/json"
"net/http"
"testing"
)
const (
// trustLocalhost trusts the address every test connects from, and a
// network for proxies in front of it.
trustLocalhost = localhost + "/32,10.0.0.0/8"
// appHost is the host every test asks for.
appHost = "app.example"
// client is the client's address, as a proxy names it.
client = "203.0.113.9"
// forwardedFor is the header that lists the client and its proxies,
// and forwardedProto the one that gives the scheme the client used.
forwardedFor = "X-Forwarded-For"
forwardedProto = "X-Forwarded-Proto"
// secure is the scheme a client reached traefik with, and plain the
// one smallwebwaf serves.
secure = "https"
plain = "http"
)
// appHeaders is what the app tells about the headers it received.
type appHeaders struct {
Host string `json:"host"`
ForwardedFor string `json:"forwardedFor"`
ForwardedHost string `json:"forwardedHost"`
ForwardedProto string `json:"forwardedProto"`
RealIP string `json:"realIp"`
}
// clientAddressCase is a request and what smallwebwaf makes of it.
type clientAddressCase struct {
name string
env map[string]string
header http.Header
wantClient string
wantApp appHeaders
}
func TestClientAddressAndForwardedHeaders(t *testing.T) {
t.Parallel()
for _, tc := range clientAddressCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, line := requestWithHeaders(t, tc.env, tc.header)
tc.wantApp.Host = appHost
if got != tc.wantApp {
t.Errorf("app received %+v, want %+v", got, tc.wantApp)
}
if line.ClientIP != tc.wantClient || line.PeerIP != localhost {
t.Errorf("log line has client_ip %q and peer_ip %q, want %q and %q",
line.ClientIP, line.PeerIP, tc.wantClient, localhost)
}
})
}
}
// clientAddressCases are the requests TestClientAddressAndForwardedHeaders
// sends, from 127.0.0.1, which the default trusted proxies leave out.
func clientAddressCases() []clientAddressCase {
trusted := map[string]string{trustedProxies: trustLocalhost}
forged := http.Header{
forwardedFor: {client},
"X-Forwarded-Host": {"forged.example"},
forwardedProto: {secure},
"X-Real-Ip": {client},
}
replaced := appHeaders{
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
}
return []clientAddressCase{{
name: "a peer outside the trusted proxies is the client, " +
"and its forwarded headers are replaced",
header: forged, wantClient: localhost, wantApp: replaced,
}, {
name: "set but empty, the trusted proxies trust nothing",
env: map[string]string{trustedProxies: ""},
header: forged, wantClient: localhost, wantApp: replaced,
}, {
name: "behind a trusted peer, the client is the first address " +
"outside the trusted proxies from the right",
env: trusted,
header: http.Header{
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
"X-Forwarded-Host": {appHost},
forwardedProto: {secure},
"X-Real-Ip": {client},
},
wantClient: client,
wantApp: appHeaders{
ForwardedFor: "198.51.100.7, " + client + ", 10.0.0.2, " + localhost,
ForwardedHost: appHost, ForwardedProto: secure, RealIP: client,
},
}, {
name: "when every address is a trusted proxy, the leftmost is the client",
env: trusted,
header: http.Header{forwardedFor: {"10.0.0.5, 10.0.0.2"}},
wantClient: "10.0.0.5",
wantApp: appHeaders{ForwardedFor: "10.0.0.5, 10.0.0.2, " + localhost},
}, {
name: "with no header, a trusted peer is the client",
env: trusted,
wantClient: localhost,
wantApp: appHeaders{ForwardedFor: localhost},
}, {
name: "an entry that is not an address ends the reading",
env: trusted,
header: http.Header{forwardedFor: {client + ", unknown, 10.0.0.2"}},
wantClient: "10.0.0.2",
wantApp: appHeaders{
ForwardedFor: client + ", unknown, 10.0.0.2, " + localhost,
},
}, {
name: "several header lines are read as one list",
env: trusted,
header: http.Header{forwardedFor: {"2001:db8::7", "10.0.0.2"}},
wantClient: "2001:db8::7",
wantApp: appHeaders{ForwardedFor: "2001:db8::7, 10.0.0.2, " + localhost},
}}
}
// requestWithHeaders sends a request for appHost with header through
// smallwebwaf, with the settings in env, and returns the headers the app
// received and the request's log line.
func requestWithHeaders(
t *testing.T, env map[string]string, header http.Header,
) (appHeaders, logLine) {
t.Helper()
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(appHeaders{
Host: r.Host,
ForwardedFor: r.Header.Get(forwardedFor),
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
ForwardedProto: r.Header.Get(forwardedProto),
RealIP: r.Header.Get("X-Real-IP"),
})
})
addr, out := startProxy(t, app.URL, env)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Host = appHost
req.Header = header.Clone()
answered := do(t, req)
var got appHeaders
err := json.Unmarshal(answered.body, &got)
if err != nil {
t.Fatalf("decode the app's answer %q: %v", answered.body, err)
}
return got, out.requestLine(t)
}
+5 -42
View File
@@ -1,9 +1,6 @@
package proxy package proxy
import ( import (
"errors"
"net/http"
"os"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -19,8 +16,7 @@ import (
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // SWWAF_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, and for one whose body meets a size or time // request it does not refuse.
// limit while the Core Rule Set reads it, which it notes nothing of.
func (rq *request) checkCoreRuleSet() string { func (rq *request) checkCoreRuleSet() string {
cfg := rq.h.config cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) { if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
@@ -29,10 +25,10 @@ func (rq *request) checkCoreRuleSet() string {
start := time.Now() start := time.Now()
result := rq.inspect() // A question's body has been read into memory, so that reading it here
if rq.refused.Load() != nil { // cannot fail.
return "" // the refusal for that limit, which check returns result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
} rq.requestBytes = int64(len(read))
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start))) rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
rq.line.WAFRuleIDs = result.RuleIDs rq.line.WAFRuleIDs = result.RuleIDs
@@ -58,39 +54,6 @@ func (rq *request) checkCoreRuleSet() string {
return requestlog.ActionWAFBlocked return requestlog.ActionWAFBlocked
} }
// inspect runs the Core Rule Set on the request, which reads the part of
// its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that
// part for the app. A client that runs out of time is refused with 408
// here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read;
// check returns the refusal. A body that breaks off for any other reason
// is passed on as far as it came, and the request to the app fails there,
// as it would have without the Core Rule Set.
func (rq *request) inspect() waf.Result {
if rq.body == nil {
// Nothing is read of no body, so nothing can go wrong reading it.
result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody)
return result
}
_ = rq.rc.SetReadDeadline(rq.clientRequestDeadline())
result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body)
// The timeouts that run while the request goes to the app take over.
_ = rq.rc.SetReadDeadline(time.Time{})
rq.body.readByCoreRuleSet = read
if errors.Is(err, os.ErrDeadlineExceeded) {
rq.refuse(refusal{
status: http.StatusRequestTimeout,
action: requestlog.ActionTimedOut,
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
})
}
return result
}
// alertWAFBlock raises the waf_block alert for the request, which the Core // alertWAFBlock raises the waf_block alert for the request, which the Core
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its // Rule Set scored at result, at or over SWWAF_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
+13 -123
View File
@@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
@@ -29,6 +28,13 @@ const (
// formData is the type of a form's body. // formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded" const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
// setting.
const (
sizeLimit = 1 << 10
sizeLimitSetting = "1K"
)
// sqlInjection asks for / with an SQL injection in its query, which only // sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default // the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWWAF_WAF_ANOMALY_THRESHOLD. // SWWAF_WAF_ANOMALY_THRESHOLD.
@@ -96,7 +102,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
} { } {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
line, _ := s.requestWithHeader(client, attack.path, attack.header, line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action) tc.status, tc.action)
wantWAF(t, line, &attack.score, attack.ruleIDs...) wantWAF(t, line, &attack.score, attack.ruleIDs...)
} }
@@ -299,7 +305,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
tc.name, len(got.body), got.status, len(tc.body)) tc.name, len(got.body), got.status, len(tc.body))
} }
line := out.requestLines(t, i+1)[i] line := out.endedLines(t, i+1)[i]
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.RequestBytes != int64(len(tc.body)) { if line.RequestBytes != int64(len(tc.body)) {
@@ -309,119 +315,6 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
} }
} }
func TestFormBodyLongerThanTheLimitStreamsOnToTheApp(t *testing.T) {
t.Parallel()
const (
first = "a=" // and twice the limit of b's, then the rest
rest = 64 * sizeLimit
)
// past is closed once the app has received twice what the Core Rule
// Set reads, and got is the length of the whole body it received.
past := make(chan struct{})
got := make(chan int64, 1)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
n, _ := io.CopyN(io.Discard, r.Body, 2*sizeLimit)
close(past)
m, _ := io.Copy(io.Discard, r.Body)
got <- n + m
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
})
// The client sends the rest only once the app has received the first
// part: were smallwebwaf to hold the body until the end, it would
// never come.
body, sender := io.Pipe()
go func() {
_, _ = io.WriteString(sender, first+strings.Repeat("b", 2*sizeLimit))
select {
case <-past:
case <-time.After(waitLimit):
t.Error("the app got no more than the Core Rule Set reads " +
"before the whole body was sent")
_ = sender.CloseWithError(io.ErrUnexpectedEOF)
return
}
_, _ = io.WriteString(sender, strings.Repeat("b", rest))
_ = sender.Close()
}()
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set("Content-Type", formData)
wantStatus(t, do(t, req), http.StatusOK)
want := int64(len(first) + 2*sizeLimit + rest)
if n := <-got; n != want {
t.Errorf("the app got %d bytes, want %d", n, want)
}
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
}
func TestClientTooSlowToSendWhatTheCoreRuleSetReads(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
clientRequestTimeout: shortTimeoutSetting, metricsToken: token,
})
conn := dial(t, addr)
send(t, conn, "POST /comment HTTP/1.1\r\nHost: app\r\nContent-Type: "+formData+
"\r\nContent-Length: 100\r\n\r\ncontent=the first bytes")
wantStatus(t, readResponse(t, conn), http.StatusRequestTimeout)
line := out.requestLine(t)
wantLine(t, line, http.StatusRequestTimeout, requestlog.ActionTimedOut)
wantNotSentToTheApp(t, line)
wantLimitHits(t, addr, clientRequestTimeout, 1)
}
func TestBodyOverTheSizeLimitWhileTheCoreRuleSetReadsIt(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: "4K",
requestMaxBytes: sizeLimitSetting, metricsToken: token,
})
// Sent in chunks, its length is not announced, and is found to be over
// the limit as the Core Rule Set reads it.
body := io.MultiReader(strings.NewReader("a=" + strings.Repeat("b", 2*sizeLimit)))
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set("Content-Type", formData)
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
line := out.requestLine(t)
wantLine(t, line, http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
wantNotSentToTheApp(t, line)
wantLimitHits(t, addr, requestMaxBytes, 1)
}
// wantNotSentToTheApp checks that the request of line was not sent to the
// app at all.
func wantNotSentToTheApp(t *testing.T, line logLine) {
t.Helper()
_, sent := line.fields["duration_upstream_total"]
if sent {
t.Error("log line has duration_upstream_total, for a request sent to the app")
}
}
func TestResponsesAreNotInspected(t *testing.T) { func TestResponsesAreNotInspected(t *testing.T) {
t.Parallel() t.Parallel()
@@ -441,14 +334,12 @@ func TestResponsesAreNotInspected(t *testing.T) {
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body) t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
} }
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward) wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
} }
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) { func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
s, _, server := startWithClock(t, "", map[string]string{ s, _, server := startWithClock(t, "", map[string]string{
wafMode: block, errorBurstThreshold: "2", metricsToken: token, wafMode: block, errorBurstThreshold: "2", metricsToken: token,
}) })
@@ -474,7 +365,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
metrics := s.scrape(scraper) metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) `smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -486,8 +377,6 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) { func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
s, _, server := startWithClock(t, "", map[string]string{ s, _, server := startWithClock(t, "", map[string]string{
wafMode: detect, errorBurstThreshold: "2", metricsToken: token, wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
}) })
@@ -503,7 +392,7 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
t.Errorf("history counts the offences %+v, want none", offences) t.Errorf("history counts the offences %+v, want none", offences)
} }
metrics := s.scrape(scraper) metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) `smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
@@ -576,6 +465,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventWAFBlock, Event: alerts.EventWAFBlock,
Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"), Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
+4 -8
View File
@@ -65,7 +65,7 @@ func TestCountryLists(t *testing.T) {
req.Header.Set(forwardedFor, sent.client) req.Header.Set(forwardedFor, sent.client)
got := do(t, req) got := do(t, req)
line := out.requestLines(t, i+1)[i] line := out.endedLines(t, i+1)[i]
if line.ASN != sent.asn || line.ASName != sent.asName || if line.ASN != sent.asn || line.ASName != sent.asName ||
line.Country != sent.country { line.Country != sent.country {
t.Errorf("log line has %q, %q and %q, want %q, %q and %q", t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
@@ -90,7 +90,7 @@ func TestCountryLists(t *testing.T) {
} }
} }
func TestCountryRefusalComesBeforeTheBody(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 var calls atomic.Int32
@@ -109,13 +109,9 @@ func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
req.Header.Set(forwardedFor, fromKP) req.Header.Set(forwardedFor, fromKP)
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
line := out.requestLine(t) line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied) wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied)
if line.RequestBytes != 0 {
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
}
if calls.Load() != 0 { if calls.Load() != 0 {
t.Errorf("the app was called %d times, want none", calls.Load()) t.Errorf("the app was called %d times, want none", calls.Load())
} }
@@ -211,7 +207,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
line := out.requestLines(t, i+1)[i] line := out.endedLines(t, i+1)[i]
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
for _, field := range []string{"asn", "as_name", "country"} { for _, field := range []string{"asn", "as_name", "country"} {
+1 -1
View File
@@ -84,7 +84,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
// The ban and the hit are counted, and the list has the metrics of any // The ban and the hit are counted, and the list has the metrics of any
// list fetched from a URL. // list fetched from a URL.
metrics := s.scrape(unplaced) metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}` labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+ wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
+17 -46
View File
@@ -17,8 +17,8 @@ import (
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
// refused is a request the tests here send, which smallwebwaf refuses // refused is a request the tests here send, which servewaf refuses after a rule
// after a rule file match, or for a missing or wrong token. // file match.
type refused int type refused int
const ( const (
@@ -27,11 +27,6 @@ const (
// banRule is one its ban rule refuses with 403, and bans the client // banRule is one its ban rule refuses with 403, and bans the client
// for. // for.
banRule banRule
// noMetricsToken is one for the metrics without a token, and
// wrongAdminToken one for the bans with the metrics token, each
// refused with 401.
noMetricsToken
wrongAdminToken
) )
// send sends r from the client at from, checks its answer and log line as // send sends r from the client at from, checks its answer and log line as
@@ -45,14 +40,6 @@ func (r refused) send(s *sender, from string) logLine {
requestlog.ActionRuleBlocked) requestlog.ActionRuleBlocked)
case banRule: case banRule:
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned) return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
case noMetricsToken:
return s.request(from, proxy.MetricsPath, http.StatusUnauthorized,
requestlog.ActionAdmin)
case wrongAdminToken:
line, _ := s.requestWithHeader(from, proxy.BansPath, "Authorization: "+bearer,
http.StatusUnauthorized, requestlog.ActionAdmin)
return line
} }
s.t.Fatalf("no request for the refusal %d", r) s.t.Fatalf("no request for the refusal %d", r)
@@ -60,7 +47,7 @@ func (r refused) send(s *sender, from string) logLine {
return logLine{} return logLine{}
} }
// startForErrorBurst is startWithClock with testRules, both tokens and // startForErrorBurst is startWithClock with testRules, the metrics token and
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. // SWWAF_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,
@@ -70,7 +57,6 @@ func startForErrorBurst(
settings := map[string]string{ settings := map[string]string{
errorBurstThreshold: threshold, errorBurstThreshold: threshold,
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
adminToken: adminSecret,
metricsToken: token, metricsToken: token,
} }
maps.Copy(settings, env) maps.Copy(settings, env)
@@ -87,14 +73,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
refusals []refused refusals []refused
}{ }{
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}}, {"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
{ {"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
"missing or wrong token",
[]refused{noMetricsToken, wrongAdminToken, noMetricsToken, wrongAdminToken},
},
{
"a mix ending in a ban rule",
[]refused{blockRule, noMetricsToken, blockRule, banRule},
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -130,13 +109,11 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
s, clk, server := startForErrorBurst(t, "2", nil) s, clk, server := startForErrorBurst(t, "2", nil)
start := clk.Now() start := clk.Now()
blockRule.send(s, client) blockRule.send(s, client)
wrongAdminToken.send(s, client) blockRule.send(s, client)
line := blockRule.send(s, client) line := blockRule.send(s, client)
expires := start.Add(time.Hour) expires := start.Add(time.Hour)
@@ -173,19 +150,17 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want) t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
} }
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 2, TokenRefused: 1} wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
if offences := historyOf(t, server, client).Offences; offences != wantOffences { if offences := historyOf(t, server, client).Offences; offences != wantOffences {
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences) t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
} }
metrics := s.scrape(scraper) metrics := s.scrape()
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
`kind="refusals",window="minute"}`, 1) `kind="refusals",window="minute"}`, 1)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 2) `smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3)
wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="token_refused"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
} }
@@ -291,19 +266,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
allowNets: allowed, rateLimitExemptNets: exempt, allowNets: allowed, rateLimitExemptNets: exempt,
}) })
// A client in SWWAF_ALLOW_NETS still needs the token, but is not // A client in SWWAF_ALLOW_NETS is not refused, and so not counted.
// counted.
for range 3 { for range 3 {
line := noMetricsToken.send(s, allowed) s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
}
} }
// One the rate limits do not apply to is. // One the rate limits do not apply to is.
noMetricsToken.send(s, exempt) blockRule.send(s, exempt)
line := wrongAdminToken.send(s, exempt) line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitErrorBurst { if line.LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit) t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
} }
@@ -336,7 +307,6 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
mode: observe, mode: observe,
errorBurstThreshold: "1", errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
adminToken: adminSecret,
}) })
start := clk.Now() start := clk.Now()
held := bans.Ban{ held := bans.Ban{
@@ -347,10 +317,11 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
} }
server.Ledger.Load([]bans.Ban{held}) server.Ledger.Load([]bans.Ban{held})
// Under a ban, enforce mode would have refused these before the // Under a ban, enforce mode would have refused these before the rule, so
// endpoint, so their tokens are not counted. // they are not counted.
for range 2 { for range 2 {
line := wrongAdminToken.send(s, otherClient) line := s.request(otherClient, blockedPath, http.StatusOK,
requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionBanned)
if line.LimitHit != "" { if line.LimitHit != "" {
@@ -360,7 +331,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
// The block rule's refusal, which enforce mode would have answered 403, // The block rule's refusal, which enforce mode would have answered 403,
// is the second of the client's, and would have banned it. // is the second of the client's, and would have banned it.
wrongAdminToken.send(s, client) s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked) wantWouldAction(t, line, requestlog.ActionRuleBlocked)
+5 -21
View File
@@ -2,24 +2,18 @@ package proxy_test
import ( import (
"net/http" "net/http"
"sync/atomic"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) { func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
// With a limit of one request a minute, any request counted before // With a limit of one request a minute, any request counted before
// the last one would have it refused. // the last one would have it refused.
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"}) s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
const ( const (
healthChecks = 3 healthChecks = 3
@@ -27,8 +21,7 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
) )
for range healthChecks { for range healthChecks {
got := get(t, addr, proxy.HealthPath) got := s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK)
wantStatus(t, got, http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
@@ -36,21 +29,12 @@ func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
} }
} }
wantStatus(t, get(t, addr, "/"), http.StatusOK) s.get(client, http.StatusOK, requestlog.ActionForward)
lines := out.requestLines(t, healthChecks+1)
for _, line := range lines[:healthChecks] {
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
for _, line := range s.out.endedLines(t, healthChecks)[:healthChecks] {
if line.ResponseContentType != contentType { if line.ResponseContentType != contentType {
t.Errorf("health check's log line has response_content_type %q, "+ t.Errorf("health check's log line has response_content_type %q, "+
"want %q", line.ResponseContentType, contentType) "want %q", line.ResponseContentType, contentType)
} }
} }
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
if calls.Load() != 1 {
t.Errorf("the app was called %d times, want once", calls.Load())
}
} }
+11 -16
View File
@@ -86,7 +86,7 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
wantStatus(t, got, http.StatusOK) wantStatus(t, got, http.StatusOK)
out.requestLine(t) out.endedLine(t)
history := historyOf(t, server, localhost) history := historyOf(t, server, localhost)
if history.RequestBytes != 3 || history.ResponseBytes != 5 { if history.RequestBytes != 3 || history.ResponseBytes != 5 {
@@ -95,38 +95,33 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
} }
} }
func TestHealthEndpointIsNotInTheHistory(t *testing.T) { func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
t.Parallel() t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK) s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK)
out.requestLine(t) s.adminRequest("", http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized)
s.scrape()
if clients := server.Limiter.Snapshot(); len(clients) != 0 { if clients := server.Limiter.Snapshot(); len(clients) != 0 {
t.Errorf("the table holds %+v, want no client", clients) t.Errorf("the table holds %+v, want no client", clients)
} }
} }
func TestRequestForSmallwebwafIsRefusedOnlyWithoutTheToken(t *testing.T) { func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
t.Parallel() t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
map[string]string{metricsToken: token})
// The metrics and the 404 are neither forwarded nor refused; the 401
// is refused.
scrape(t, addr)
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
wantStatus(t, get(t, addr, proxy.MetricsPath), http.StatusUnauthorized) out.endedLine(t)
out.requestLines(t, 3)
history := historyOf(t, server, localhost) history := historyOf(t, server, localhost)
if history.Requests != 3 || history.Forwarded != 0 || history.Refused != 1 { if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 {
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+ t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
"want 3, 0 and 1", history.Requests, history.Forwarded, history.Refused) "want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused)
} }
} }
-162
View File
@@ -1,162 +0,0 @@
package proxy_test
import (
"bytes"
"errors"
"io"
"net/http"
"strconv"
"sync/atomic"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// sizeLimit is the size limit the tests set, 1K as a setting.
const (
sizeLimit = 1 << 10
sizeLimitSetting = "1K"
)
func TestRequestBodyLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
size int
// announced sends the size in Content-Length; otherwise the body
// is sent in chunks with no length given.
announced bool
want int
action string
// refusedBeforeApp is a refusal before anything reaches the app.
// A body over the limit with no length given has already partly
// reached the app when it is refused.
refusedBeforeApp bool
}{
{"announced, over the limit", 2 * sizeLimit, true,
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, true},
{"announced, at the limit", sizeLimit, true,
http.StatusOK, requestlog.ActionForward, false},
{"not announced, over the limit", 4 * sizeLimit, false,
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, false},
{"not announced, at the limit", sizeLimit, false,
http.StatusOK, requestlog.ActionForward, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var calls atomic.Int32
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.Copy(io.Discard, r.Body)
})
addr, out := startProxy(t, app.URL, map[string]string{
requestMaxBytes: sizeLimitSetting,
metricsToken: token,
})
var body io.Reader = bytes.NewReader(make([]byte, tc.size))
if !tc.announced {
body = io.MultiReader(body) // hides the length
}
wantStatus(t, do(t, newRequest(t, http.MethodPost, addr, "/upload", body)),
tc.want)
wantLine(t, out.requestLine(t), tc.want, tc.action)
hits := 0
if tc.action == requestlog.ActionTooLarge {
hits = 1
}
wantLimitHits(t, addr, requestMaxBytes, hits)
if tc.refusedBeforeApp && calls.Load() != 0 {
t.Errorf("the app was called %d times, want never", calls.Load())
}
})
}
}
func TestResponseBodyLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
size int
// announced sends the size in Content-Length; otherwise the body
// is sent in chunks with no length given.
announced bool
want int
action string
// received is how much of a body the client gets, and cutOff
// whether the connection is then cut.
received int
cutOff bool
}{
{"announced, over the limit", 2 * sizeLimit, true, http.StatusBadGateway,
requestlog.ActionTooLarge, len("Bad Gateway\n"), false},
{"announced, at the limit", sizeLimit, true, http.StatusOK,
requestlog.ActionForward, sizeLimit, false},
{"not announced, over the limit", 4 * sizeLimit, false, http.StatusOK,
requestlog.ActionTooLarge, sizeLimit, true},
{"not announced, at the limit", sizeLimit, false, http.StatusOK,
requestlog.ActionForward, sizeLimit, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
answerWithSize(w, tc.size, tc.announced)
})
addr, out := startProxy(t, app.URL, map[string]string{
responseMaxBytes: sizeLimitSetting,
metricsToken: token,
})
got := get(t, addr, "/download")
wantStatus(t, got, tc.want)
if len(got.body) != tc.received ||
errors.Is(got.err, io.ErrUnexpectedEOF) != tc.cutOff {
t.Errorf("client got %d bytes (%v), want %d",
len(got.body), got.err, tc.received)
}
line := out.requestLine(t)
wantLine(t, line, tc.want, tc.action)
if line.UpstreamStatus != http.StatusOK {
t.Errorf("log line has upstream_status %d", line.UpstreamStatus)
}
hits := 0
if tc.action == requestlog.ActionTooLarge {
hits = 1
}
wantLimitHits(t, addr, responseMaxBytes, hits)
})
}
}
// answerWithSize answers with a body of size bytes, announced in
// Content-Length or sent in chunks with no length given.
func answerWithSize(w http.ResponseWriter, size int, announced bool) {
body := make([]byte, size)
if announced {
w.Header().Set("Content-Length", strconv.Itoa(size))
_, _ = w.Write(body)
return
}
// Sending part of it before the end keeps Go's server from working
// out the length.
_, _ = w.Write(body[:size/2])
_ = http.NewResponseController(w).Flush()
_, _ = w.Write(body[size/2:])
}
+7 -8
View File
@@ -9,11 +9,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
) )
// The headers in which the app is passed the client's AS number and // The headers in which an answer of allow gives rproxy the client's AS number
// country while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes every header // and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes
// name in this form, as it sends it and as it receives it, so X-Client-ASN // every header name in this form, as it sends it and as it receives it, so
// arrives as X-Client-Asn, and Del removes a client's own whatever their // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
// case; header names are not case-sensitive.
const ( const (
asnHeader = "X-Client-Asn" asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country" countryHeader = "X-Client-Country"
@@ -37,7 +36,7 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
switch { switch {
case rq.h.config.LookupSource == "file": case rq.h.config.LookupSource == "file":
rq.lookupAnswer = rq.h.lookupFile.LookUp(client) rq.lookupAnswer = rq.h.lookupFile.LookUp(client)
case rq.h.answersQuestions() && rq.refusedWhateverTheAnswer(now): case rq.refusedWhateverTheAnswer(now):
rq.lookupAnswer = rq.h.geojs.LookUpWithoutWaiting(ctx, client) rq.lookupAnswer = rq.h.geojs.LookUpWithoutWaiting(ctx, client)
rq.skippedLookupWait = rq.lookupAnswer.Answered.IsZero() rq.skippedLookupWait = rq.lookupAnswer.Answered.IsZero()
default: default:
@@ -60,8 +59,8 @@ func (h *handler) addLookup(answer lookup.Answer) {
answer.ASN, answer.ASName, answer.Country) answer.ASN, answer.ASName, answer.Country)
} }
// setLookupHeaders sets the headers in which the app is passed the // setLookupHeaders sets the headers that give the client's AS number and
// client's AS number and country, leaving out one that is unknown. // country, leaving out one that is unknown.
func setLookupHeaders(header http.Header, asn, country string) { func setLookupHeaders(header http.Header, asn, country string) {
if asn != "" { if asn != "" {
header.Set(asnHeader, asn) header.Set(asnHeader, asn)
+5 -10
View File
@@ -1,9 +1,7 @@
package proxy_test package proxy_test
import ( import (
"net"
"net/http" "net/http"
"net/http/httptest"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -142,7 +140,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
t.Fatalf("first request: %v", err) t.Fatalf("first request: %v", err)
} }
wantLine(t, out.requestLines(t, 2)[1], http.StatusOK, requestlog.ActionForward) wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
// The third request's count holds all three: the first was counted // The third request's count holds all three: the first was counted
// when it went on, in the minute under way, and the second's count // when it went on, in the minute under way, and the second's count
@@ -150,7 +148,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk.advance(500 * time.Millisecond) clk.advance(500 * time.Millisecond)
wantStatus(t, do(t, requestFromDE()), http.StatusOK) wantStatus(t, do(t, requestFromDE()), http.StatusOK)
if got := out.requestLines(t, 3)[2].Counts.Minute; got != 3 { if got := out.endedLines(t, 3)[2].Counts.Minute; got != 3 {
t.Errorf("the third request's count in the minute is %v, want 3", got) t.Errorf("the third request's count in the minute is %v, want 3", got)
} }
} }
@@ -387,18 +385,15 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL, server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
time.Now, map[string]string{ map[string]string{
lookupTimeout: timeout.String(), lookupTimeout: timeout.String(),
allowedCountries: "DE", allowedCountries: "DE",
}) })
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
http.NoBody)
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
began := time.Now() began := time.Now()
server.Handler.ServeHTTP(httptest.NewRecorder(), req) serveFromDE(t, server, http.MethodGet, http.NoBody)
if waited := time.Since(began); waited != timeout { if waited := time.Since(began); waited != timeout {
t.Errorf("the request waited %s for its answer, want %s", waited, timeout) t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
+35 -149
View File
@@ -7,8 +7,6 @@ import (
"net/netip" "net/netip"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -30,41 +28,21 @@ const (
func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) { func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 s, _, _ := startWithClock(t, "", nil)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
addr, out := startProxy(t, app.URL, nil)
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for i, authorization := range []string{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody) s.adminRequest(authorization, http.MethodGet, proxy.MetricsPath, "",
if authorization != "" { http.StatusNotFound)
req.Header.Set("Authorization", authorization)
}
wantStatus(t, do(t, req), http.StatusNotFound)
wantLine(t, out.requestLines(t, i+1)[i], http.StatusNotFound,
requestlog.ActionAdmin)
}
if calls.Load() != 0 {
t.Errorf("the app was called %d times, want never", calls.Load())
} }
} }
func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) { func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 s, _, _ := startWithClock(t, "", map[string]string{metricsToken: token})
app := startApp(t, func(http.ResponseWriter, *http.Request) { for _, tc := range []struct {
calls.Add(1)
})
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
for i, tc := range []struct {
method, path, authorization string method, path, authorization string
status int status int
}{ }{
@@ -81,14 +59,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound}, {http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
{http.MethodPost, proxy.HealthPath, "", http.StatusNotFound}, {http.MethodPost, proxy.HealthPath, "", http.StatusNotFound},
} { } {
req := newRequest(t, tc.method, addr, tc.path, http.NoBody) got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
if tc.authorization != "" {
req.Header.Set("Authorization", tc.authorization)
}
got := do(t, req)
wantStatus(t, got, tc.status)
wantLine(t, out.requestLines(t, i+1)[i], tc.status, requestlog.ActionAdmin)
if tc.status == http.StatusUnauthorized && if tc.status == http.StatusUnauthorized &&
got.header.Get("WWW-Authenticate") != "Bearer" { got.header.Get("WWW-Authenticate") != "Bearer" {
@@ -101,58 +72,28 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
t.Errorf("the metrics are\n%s", got.body) t.Errorf("the metrics are\n%s", got.body)
} }
} }
if calls.Load() != 0 {
t.Errorf("the app was called %d times, want never", calls.Load())
}
}
func TestMetricsAreAskedForThroughTheChecks(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
metricsToken: token,
rateLimitPerMinute: "1",
})
// Asking for the metrics counts toward the client's limit of one
// request a minute, so its next request breaks it, and bans it. A
// banned client is refused the metrics too.
s.scrape(client)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.requestWithHeader(client, proxy.MetricsPath, "Authorization: "+bearer,
http.StatusForbidden, requestlog.ActionBanned)
} }
func TestMetricsCountTheTraffic(t *testing.T) { func TestMetricsCountTheTraffic(t *testing.T) {
t.Parallel() t.Parallel()
arrived, release := make(chan struct{}), make(chan struct{})
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body) _, _ = io.Copy(io.Discard, r.Body)
if r.URL.Path == "/held" {
close(arrived)
<-release
}
_, _ = io.WriteString(w, "hello") _, _ = io.WriteString(w, "hello")
}) })
releaseApp := sync.OnceFunc(func() { close(release) }) addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
t.Cleanup(releaseApp) map[string]string{metricsToken: token})
addr, out := startProxy(t, app.URL, map[string]string{metricsToken: token})
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
wantStatus(t, got, http.StatusOK) wantStatus(t, got, http.StatusOK)
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
out.requestLines(t, 2) out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}` forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="admin",instance="app",status_class="4xx"}` notFound := `{action="admin",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way. // The request for the metrics is itself under way.
metrics := scrape(t, addr) metrics := scrape(t, server.Addr)
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1) wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1) wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3) wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
@@ -166,46 +107,16 @@ func TestMetricsCountTheTraffic(t *testing.T) {
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1) wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
metric(t, metrics, `go_goroutines{instance="app"}`) metric(t, metrics, `go_goroutines{instance="app"}`)
metric(t, metrics, `process_start_time_seconds{instance="app"}`) metric(t, metrics, `process_start_time_seconds{instance="app"}`)
// A request the app holds is under way until it ends.
httpClient := newClient(t)
held := newRequest(t, http.MethodGet, addr, "/held", http.NoBody)
ended := make(chan error, 1)
go func() {
res, err := httpClient.Do(held)
if err == nil {
err = readAnswer(res).err
}
ended <- err
}()
<-arrived
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
releaseApp()
err := <-ended
if err != nil {
t.Fatalf("held request: %v", err)
}
out.requestLines(t, 5)
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
} }
func TestMetricsCountLimitsAndBans(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) {
t.Parallel() t.Parallel()
const ( const denied = "192.0.2.50" // in SWWAF_DENY_NETS
scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.50" // in SWWAF_DENY_NETS
)
s, clk, _ := startWithClock(t, "", map[string]string{ s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token, metricsToken: token,
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
rateLimitExemptNets: scraper,
denyNets: denied, denyNets: denied,
banResponse: banResponseClose, banResponse: banResponseClose,
limitBanDuration: "1h", limitBanDuration: "1h",
@@ -219,7 +130,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
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)
metrics := s.scrape(scraper) metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1) `smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
@@ -230,38 +141,35 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0) wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
clk.advance(time.Hour) clk.advance(time.Hour)
wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0) wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent. // than SWWAF_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)
metrics = s.scrape(scraper) metrics = s.scrape()
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 2) `kind="requests",window="minute"}`, 2)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2) wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
// denied, client, and the scraper as of its earlier requests. // denied and client.
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 3) wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2)
} }
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
s, clk, server := startWithClock(t, "", map[string]string{ s, clk, server := startWithClock(t, "", map[string]string{
metricsToken: token, metricsToken: token,
adminToken: adminSecret, adminToken: adminSecret,
rateLimitExemptNets: scraper,
}) })
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}` const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
wantMetric(t, s.scrape(scraper), admins, 0) wantMetric(t, s.scrape(), admins, 0)
// As an admin's edit of bans.json that adds a ban is taken in. // As an admin's edit of bans.json that adds a ban is taken in.
server.Ledger.LoadEdit([]bans.Ban{{ server.Ledger.LoadEdit([]bans.Ban{{
@@ -269,11 +177,11 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
Start: clk.Now(), Start: clk.Now(),
}}) }})
wantMetric(t, s.scrape(scraper), admins, 1) wantMetric(t, s.scrape(), admins, 1)
// And a ban made through the endpoint. // And a ban made through the endpoint.
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK) s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
wantMetric(t, s.scrape(scraper), admins, 2) wantMetric(t, s.scrape(), admins, 2)
} }
func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
@@ -312,7 +220,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
// Each is counted before the next is sent, so that the // Each is counted before the next is sent, so that the
// countries are ranked in the order sent. // countries are ranked in the order sent.
lines++ lines++
out.requestLines(t, lines) out.endedLines(t, lines)
} }
} }
@@ -321,7 +229,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
send(fromDE, 2, http.StatusOK) send(fromDE, 2, http.StatusOK)
send(fromFR, 1, http.StatusOK) send(fromFR, 1, http.StatusOK)
metrics := scrape(t, addr) metrics := scrape(t, server.Addr)
lines++ lines++
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -349,7 +257,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
// from then on, and DE's is gone. // from then on, and DE's is gone.
send(fromFR, 3, http.StatusOK) send(fromFR, 3, http.StatusOK)
metrics = scrape(t, addr) metrics = scrape(t, server.Addr)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) `smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -383,13 +291,12 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
answer(fromDE, "AS64501"), answer(fromKP, "AS64502"), answer(fromDE, "AS64501"), answer(fromKP, "AS64502"),
}) })
// With one AS number of its own, the other is counted as other. The // With one AS number of its own, the other is counted as other.
// metrics are asked for from a private address, which has no AS number.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromKP, http.StatusOK, requestlog.ActionForward) s.get(fromKP, http.StatusOK, requestlog.ActionForward)
metrics := s.scrape("10.0.0.9") metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2) `smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
@@ -412,7 +319,8 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
t.Cleanup(geojs.Close) t.Cleanup(geojs.Close)
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{ addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
deniedCountries: "kp", deniedCountries: "kp",
@@ -427,13 +335,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so // The client stops waiting for GeoJS once SWWAF_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, addr) metrics := scrape(t, server.Addr)
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 && for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
time.Now().Before(deadline) { time.Now().Before(deadline) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
metrics = scrape(t, addr) metrics = scrape(t, server.Addr)
} }
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
@@ -452,8 +360,8 @@ func keptAnswer(addr, country string) lookup.Answer {
} }
} }
// scrape asks smallwebwaf at addr for the metrics, with the token, and // scrape asks servewaf at addr for the metrics, with the token, and returns
// returns them. // them.
func scrape(t *testing.T, addr string) string { func scrape(t *testing.T, addr string) string {
t.Helper() t.Helper()
@@ -468,15 +376,12 @@ func scrape(t *testing.T, addr string) string {
return string(got.body) return string(got.body)
} }
// scrape asks for the metrics, with the token, from the client at from, // scrape asks for the metrics, with the token, and returns them.
// and returns them. func (s *sender) scrape() string {
func (s *sender) scrape(from string) string {
s.t.Helper() s.t.Helper()
_, metrics := s.requestWithHeader(from, proxy.MetricsPath, "Authorization: "+bearer, return string(s.adminRequest(bearer, http.MethodGet, proxy.MetricsPath, "",
http.StatusOK, requestlog.ActionAdmin) http.StatusOK).body)
return metrics
} }
// metric returns the value of series in metrics, which are in the // metric returns the value of series in metrics, which are in the
@@ -524,22 +429,3 @@ func wantNoSeries(t *testing.T, metrics, series string) {
t.Errorf("there is a series %s", series) t.Errorf("there is a series %s", series)
} }
} }
// wantLimitHits checks that the metrics of smallwebwaf at addr count hits
// requests that passed the size or time limit of the setting limit, with
// no series for it when hits is 0.
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
t.Helper()
series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
limit + `"}`
metrics := scrape(t, addr)
if hits == 0 {
wantNoSeries(t, metrics, series)
return
}
wantMetric(t, metrics, series, float64(hits))
}
-65
View File
@@ -1,17 +1,13 @@
package proxy_test package proxy_test
import ( import (
"bytes"
"io"
"net/http" "net/http"
"net/netip" "net/netip"
"reflect" "reflect"
"sync/atomic"
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -76,11 +72,6 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
// Passed to the app, which answered it. // Passed to the app, which answered it.
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward) line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, sent.refusal) wantWouldAction(t, line, sent.refusal)
if line.UpstreamStatus != http.StatusOK {
t.Errorf("log line has upstream_status %d, want 200",
line.UpstreamStatus)
}
} }
}) })
} }
@@ -133,62 +124,6 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
} }
} }
func TestObserveModeKeepsTheSizeLimitsAndTheToken(t *testing.T) {
t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
var calls atomic.Int32
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
answerWithSize(w, 2*sizeLimit, true)
})
addr, out := startProxy(t, app.URL, map[string]string{
mode: observe,
trustedProxies: trustLocalhost,
denyNets: denied,
requestMaxBytes: sizeLimitSetting,
responseMaxBytes: sizeLimitSetting,
metricsToken: token,
})
// SWWAF_DENY_NETS would refuse each request; instead a size limit or
// the missing token does.
for i, tc := range []struct {
method, path string
body io.Reader
status int
action string
}{
{
http.MethodPost, "/upload", bytes.NewReader(make([]byte, 2*sizeLimit)),
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge,
},
{
http.MethodGet, "/download", http.NoBody,
http.StatusBadGateway, requestlog.ActionTooLarge,
},
{
http.MethodGet, proxy.MetricsPath, http.NoBody,
http.StatusUnauthorized, requestlog.ActionAdmin,
},
} {
req := newRequest(t, tc.method, addr, tc.path, tc.body)
req.Header.Set(forwardedFor, denied)
wantStatus(t, do(t, req), tc.status)
line := out.requestLines(t, i+1)[i]
wantLine(t, line, tc.status, tc.action)
wantWouldAction(t, line, requestlog.ActionDenied)
}
// The upload was refused before it reached the app.
if calls.Load() != 1 {
t.Errorf("the app was called %d times, want once", calls.Load())
}
}
// wantWouldAction checks the request log line's would_action, and that a // wantWouldAction checks the request log line's would_action, and that a
// line that should have none has no such field. // line that should have none has no such field.
func wantWouldAction(t *testing.T, line logLine, want string) { func wantWouldAction(t *testing.T, line logLine, want string) {
-556
View File
@@ -1,556 +0,0 @@
package proxy_test
import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"reflect"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// A request target with an escaped slash and space in its path, and a
// query with a parameter ReverseProxy cannot parse.
const (
rawPath = "/some%2Fpath/with%20space"
rawQuery = "b=2&a=1&bad=%zz;x"
)
// chunkSize is the size of each part of a body a test sends in parts.
const chunkSize = 1 << 10
var errNotStreamed = errors.New("the first part never reached the app")
// appSaw is what the app received.
type appSaw struct {
method string
target string
header http.Header
body []byte
}
func TestPassesRequestAndAnswerUnchanged(t *testing.T) {
t.Parallel()
requestBody := bytes.Repeat([]byte("request body "), 8000)
answerBody := bytes.Repeat([]byte("answer body "), 8000)
saw := make(chan appSaw, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
saw <- appSaw{r.Method, r.RequestURI, r.Header.Clone(), body}
w.Header().Set("X-App", "yes")
w.Header().Add("Set-Cookie", "a=1")
w.Header().Add("Set-Cookie", "b=2")
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write(answerBody)
})
addr, out := startProxy(t, app.URL, nil)
req := newRequest(t, http.MethodPatch, addr, rawPath+"?"+rawQuery,
bytes.NewReader(requestBody))
req.Header.Add("X-Test", "one")
req.Header.Add("X-Test", "two")
req.Header.Set("User-Agent", "test-agent")
got := do(t, req)
wantAppSaw(t, <-saw, requestBody)
wantAnswer(t, got, answerBody)
line := out.requestLine(t)
wantLine(t, line, http.StatusTeapot, requestlog.ActionForward)
wantRequestFields(t, line, addr, len(requestBody), len(answerBody))
}
func TestPassesThePathAsSent(t *testing.T) {
t.Parallel()
for _, path := range []string{
// An encoded slash and a character Go escapes: |, ^, and é sent
// as its UTF-8 bytes.
"/a%2Fb|c", "/feat%2Fx/file^1", "/x%2Fy/café",
// A path starting with //, which Go would send as a scheme and
// host if it were put in URL.Opaque.
"//a%2Fb",
} {
t.Run(path, func(t *testing.T) {
t.Parallel()
saw := make(chan string, 1)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
saw <- r.RequestURI
})
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET "+path+"?q=1 HTTP/1.1\r\nHost: "+appHost+"\r\n\r\n")
got := readResponse(t, conn)
if got.status != http.StatusOK {
t.Fatalf("status %d, want %d", got.status, http.StatusOK)
}
target := <-saw
if target != path+"?q=1" {
t.Errorf("app saw %s, want %s", target, path+"?q=1")
}
line := out.requestLine(t)
if line.Path != path {
t.Errorf("log line has path %s, want %s", line.Path, path)
}
})
}
}
// wantAppSaw checks that the app received the test's request unchanged.
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
t.Helper()
if saw.method != http.MethodPatch || saw.target != rawPath+"?"+rawQuery {
t.Errorf("app saw %s %s, want %s %s", saw.method, saw.target,
http.MethodPatch, rawPath+"?"+rawQuery)
}
if !slices.Equal(saw.header.Values("X-Test"), []string{"one", "two"}) {
t.Errorf("app saw X-Test %q", saw.header.Values("X-Test"))
}
if saw.header.Get("User-Agent") != "test-agent" {
t.Errorf("app saw User-Agent %q", saw.header.Get("User-Agent"))
}
if !bytes.Equal(saw.body, body) {
t.Errorf("app saw a body of %d bytes, want the %d sent",
len(saw.body), len(body))
}
}
// wantAnswer checks that the client received the app's answer unchanged.
func wantAnswer(t *testing.T, got answer, body []byte) {
t.Helper()
wantStatus(t, got, http.StatusTeapot)
if got.header.Get("X-App") != "yes" {
t.Errorf("client got X-App %q", got.header.Get("X-App"))
}
if !slices.Equal(got.header.Values("Set-Cookie"), []string{"a=1", "b=2"}) {
t.Errorf("client got Set-Cookie %q", got.header.Values("Set-Cookie"))
}
if got.err != nil || !bytes.Equal(got.body, body) {
t.Errorf("client got %d bytes (%v), want the %d the app sent",
len(got.body), got.err, len(body))
}
}
// wantRequestFields checks the log line's fields about the request. Its
// time, its id and its timings are checked only for being there.
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
t.Helper()
bytes := float64(sent + received)
want := withTimings(line, requestlog.Line{
Type: requestType, Time: line.Time, Instance: "app",
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
Path: rawPath, Query: rawQuery, Protocol: protocol,
Status: http.StatusTeapot, RequestBytes: int64(sent),
ResponseBytes: int64(received), UserAgent: "test-agent",
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
Counts: ratelimit.Counts{
Minute: 1, Hour: 1, Day: 1,
MinuteBytes: bytes, HourBytes: bytes, DayBytes: bytes,
},
})
if !reflect.DeepEqual(line.Line, want) {
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
}
_, err := time.Parse(time.RFC3339, line.Time)
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
t.Errorf("log line has time %q, request_id %q and durations %v and %v",
line.Time, line.RequestID, line.DurationTotal,
line.fields["duration_upstream_total"])
}
}
func TestAddsNoContentTypeTheAppDidNotSend(t *testing.T) {
t.Parallel()
const typed = "text/plain"
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/typed" {
w.Header().Set("Content-Type", typed)
} else {
// The key with no value keeps Go's server from adding one.
w.Header()["Content-Type"] = nil
}
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
})
addr, _ := startProxy(t, app.URL, nil)
got := get(t, addr, "/")
if got.header.Values("Content-Type") != nil {
t.Errorf("client got Content-Type %q, want none",
got.header.Get("Content-Type"))
}
got = get(t, addr, "/typed")
if got.header.Get("Content-Type") != typed {
t.Errorf("client got Content-Type %q, want %q",
got.header.Get("Content-Type"), typed)
}
}
func TestStreamsTheRequestBody(t *testing.T) {
t.Parallel()
chunk := bytes.Repeat([]byte("x"), chunkSize)
firstArrived := make(chan struct{})
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
first := make([]byte, len(chunk))
_, err := io.ReadFull(r.Body, first)
if err != nil {
return
}
close(firstArrived)
rest, _ := io.ReadAll(r.Body)
_, _ = w.Write(rest)
})
addr, _ := startProxy(t, app.URL, nil)
body, writer := io.Pipe()
go func() {
_, _ = writer.Write(chunk)
select {
case <-firstArrived:
_, _ = writer.Write(chunk)
_ = writer.Close()
case <-time.After(waitLimit):
_ = writer.CloseWithError(errNotStreamed)
}
}()
got := do(t, newRequest(t, http.MethodPost, addr, "/upload", body))
if got.err != nil || !bytes.Equal(got.body, chunk) {
t.Errorf("app read %d bytes after the first part (%v), want %d",
len(got.body), got.err, len(chunk))
}
}
func TestStreamsTheAnswerBody(t *testing.T) {
t.Parallel()
chunk := bytes.Repeat([]byte("y"), chunkSize)
firstArrived := make(chan struct{})
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(chunk)
_ = http.NewResponseController(w).Flush()
select {
case <-firstArrived:
_, _ = w.Write(chunk)
case <-time.After(waitLimit):
}
})
addr, _ := startProxy(t, app.URL, nil)
req := newRequest(t, http.MethodGet, addr, "/download", http.NoBody)
res, err := newClient(t).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
first := make([]byte, len(chunk))
_, err = io.ReadFull(res.Body, first)
close(firstArrived)
got := readAnswer(res)
if err != nil || got.err != nil || !bytes.Equal(got.body, chunk) {
t.Errorf("client read %d bytes after the first part (%v, %v), want %d",
len(got.body), err, got.err, len(chunk))
}
}
func TestUpgradedConnectionOutlastsTheTimeouts(t *testing.T) {
t.Parallel()
app := startApp(t, echoAfterUpgrade)
addr, out := startProxy(t, app.URL, map[string]string{
clientRequestTimeout: shortTimeoutSetting,
clientResponseTimeout: shortTimeoutSetting,
upstreamRequestTimeout: shortTimeoutSetting,
upstreamResponseTimeout: shortTimeoutSetting,
})
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer to the upgrade: %v", err)
}
answered := time.Now()
_ = res.Body.Close()
if res.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
}
// Every timeout started before the upgrade was answered, the response
// timeouts last, at the end of the request: wait until just past
// shortTimeout after the answer was read, then use the connection.
time.Sleep(time.Until(answered.Add(shortTimeout + 100*time.Millisecond)))
send(t, conn, "still here\n")
echoed, err := reader.ReadString('\n')
if err != nil || echoed != "still here\n" {
t.Errorf("echo %q (%v), want %q", echoed, err, "still here\n")
}
_ = conn.Close()
wantLine(t, out.requestLine(t), http.StatusSwitchingProtocols,
requestlog.ActionForward)
}
// echoAfterUpgrade is an app that switches protocols on request, and then
// sends back each line it receives.
func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") != "websocket" {
http.Error(w, "not an upgrade", http.StatusBadRequest)
return
}
conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer func() {
_ = conn.Close()
}()
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
_ = buffered.Flush()
for {
line, err := buffered.ReadString('\n')
if err != nil {
return
}
_, _ = buffered.WriteString(line)
_ = buffered.Flush()
}
}
func TestAnswers502WhenTheAppSwitchesToAnotherProtocol(t *testing.T) {
t.Parallel()
// answerAfterUpgrade switches to websocket whatever the client asked
// for, and ReverseProxy passes on a switch only to the protocol the
// client asked for, here "websocket, foo".
app := startApp(t, answerAfterUpgrade)
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /ws HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket, foo\r\n\r\n")
wantStatus(t, readResponse(t, conn), http.StatusBadGateway)
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
}
func TestServerHasTheDefaultLimits(t *testing.T) {
t.Parallel()
cfg, err := config.FromEnvironment(func(string) (string, bool) { return "", false })
if err != nil {
t.Fatalf("default settings: %v", err)
}
server := proxy.New(proxy.Params{
Config: cfg,
RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName, cfg.LogLevel),
})
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
"header timeout %s", server.Addr, server.MaxHeaderBytes,
server.IdleTimeout, server.ReadHeaderTimeout)
}
}
func TestRefusesHeadersOverTheLimit(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
limit int
}{
{"by default", nil, 32 << 10},
{"as set", map[string]string{clientHeaderMaxBytes: "8K"}, 8 << 10},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var calls atomic.Int32
app := startApp(t, func(http.ResponseWriter, *http.Request) {
calls.Add(1)
})
addr, _ := startProxy(t, app.URL, tc.env)
// size counts every byte of the request: the request line,
// the headers and the blank line that ends them.
const (
start = "GET / HTTP/1.1\r\nHost: app\r\nX-Large: "
end = "\r\n\r\n"
)
for _, sent := range []struct{ size, want int }{
{tc.limit, http.StatusOK},
{tc.limit + 1, http.StatusRequestHeaderFieldsTooLarge},
} {
conn := dial(t, addr)
send(t, conn,
start+strings.Repeat("a", sent.size-len(start)-len(end))+end)
wantStatus(t, readResponse(t, conn), sent.want)
}
if calls.Load() != 1 {
t.Errorf("the app was called %d times, want once", calls.Load())
}
})
}
}
func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
t.Parallel()
// No test can listen on port 1: listening on port 0 gets one from 32768 up.
addr, out := startProxy(t, "http://"+localhost+":1", nil)
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
line := out.requestLine(t)
wantLine(t, line, http.StatusBadGateway, requestlog.ActionUpstreamError)
// There never was a connection to the app, nor an answer from it.
wantTimings(t, line, "duration_total", "duration_checks",
"duration_upstream_total")
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
return line["type"] == "process" && line["msg"] == "request to the app failed"
})
if !logged {
t.Errorf("no process line says the request to the app failed")
}
}
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
t.Parallel()
// At error the warning that the request to the app failed is held back,
// and is written before the answer is.
addr, out := startProxy(t, "http://"+localhost+":1", map[string]string{
"SWWAF_LOG_LEVEL": "error",
})
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
for _, line := range out.lines(t) {
if line["type"] == "process" {
t.Errorf("process line %v, want none at error", line)
}
}
}
func TestLogsAnAnswerThatBrokeOff(t *testing.T) {
t.Parallel()
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "the first part")
_ = http.NewResponseController(w).Flush()
panic(http.ErrAbortHandler) // drops the connection mid-answer
})
addr, out := startProxy(t, app.URL, nil)
got := get(t, addr, "/")
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
}
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionUpstreamError)
}
func TestLogsAClientThatWentAway(t *testing.T) {
t.Parallel()
arrived := make(chan struct{})
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
close(arrived)
<-r.Context().Done()
})
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /slow HTTP/1.1\r\nHost: app\r\n\r\n")
select {
case <-arrived:
case <-time.After(waitLimit):
t.Fatal("the request never reached the app")
}
_ = conn.Close()
line := out.requestLine(t)
if !line.Aborted || line.Status != 0 || line.Action != requestlog.ActionForward {
t.Errorf("log line has aborted %v, status %d and action %q, "+
"want true, 0 and %q",
line.Aborted, line.Status, line.Action, requestlog.ActionForward)
}
}
+38 -143
View File
@@ -1,16 +1,15 @@
// Package proxy runs the checks on each request and writes its request log // Package proxy is servewaf's server. It runs the checks on the request each of
// line. New passes each request it lets through to the app and the app's // rproxy's questions carries, and answers rproxy what to do with that request. It
// answer back, unchanged, within the size and time limits. NewServeWAF instead // takes rproxy's report once the visitor's answer has ended, which counts the
// answers rproxy's question about each request, and takes rproxy's report once // request's bytes and writes its last log line. It answers servewaf's own
// the visitor's answer has ended, which writes the request a second log line. // endpoints: the health check, the metrics, the bans and the clients. It passes
// nothing to an app: internal/rproxy does that.
package proxy package proxy
import ( import (
"io" "io"
"log"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
lru "github.com/hashicorp/golang-lru/v2" lru "github.com/hashicorp/golang-lru/v2"
@@ -27,18 +26,12 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// How smallwebwaf keeps connections to the app open between requests. // adminPrefix starts the path of each of servewaf's own endpoints. The app never
const ( // sees a path under it.
appIdleConns = 100
appIdleConnTimeout = 90 * time.Second
)
// adminPrefix starts the path of every request for smallwebwaf itself,
// which never reaches the app.
const adminPrefix = "/_smallwebwaf/" const adminPrefix = "/_smallwebwaf/"
// HealthPath is smallwebwaf's health endpoint, which the container's // HealthPath is the health endpoint of servewaf and of rproxy, which the
// health check asks. // container's health check asks.
const HealthPath = "/_smallwebwaf/healthz" const HealthPath = "/_smallwebwaf/healthz"
// MetricsPath is where the metrics are, for a request that carries // MetricsPath is where the metrics are, for a request that carries
@@ -49,15 +42,15 @@ const MetricsPath = "/_smallwebwaf/metrics"
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN. // a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
const BansPath = "/_smallwebwaf/bans" const BansPath = "/_smallwebwaf/bans"
// ClientsPath is where an admin asks what smallwebwaf knows of a client, // ClientsPath is where an admin asks what servewaf knows of a client, by the
// by the client's address after it, with SWWAF_ADMIN_TOKEN. // client's address after it, with SWWAF_ADMIN_TOKEN.
const ClientsPath = "/_smallwebwaf/clients/" const ClientsPath = "/_smallwebwaf/clients/"
// Params are what New needs. // Params are what NewServeWAF needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
// RequestLog receives one JSON line per request, and from servewaf a second // RequestLog receives two JSON lines per question, one at its decision and
// when the request's report comes. // one when its report comes, and one per request to servewaf's own endpoints.
RequestLog io.Writer RequestLog io.Writer
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
@@ -77,7 +70,7 @@ type Params struct {
// Rules are the rule files' rules, which each request is checked // Rules are the rule files' rules, which each request is checked
// against. // against.
Rules *rules.Files Rules *rules.Files
// Alerts receive the alert for each ban the proxy makes or makes // Alerts receive the alert for each ban servewaf makes or makes
// 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
@@ -87,11 +80,10 @@ type Params struct {
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server smallwebwaf runs, with the parts of the proxy // Server is the server servewaf runs, with the parts whose state the state
// whose state the state files keep, the lookup database, nil unless // files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks // AbuseIPDB's scores and checks spent, and the metrics.
// spent, and the metrics.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -106,41 +98,24 @@ type Server struct {
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
// New returns the server smallwebwaf runs: each request it reads passes
// through the proxy. Go's server itself refuses a request line and
// headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, with 431, closes a
// connection idle for SWWAF_CLIENT_IDLE_TIMEOUT, and applies
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
// applies the timeouts and size limits from then on.
func New(params Params) *Server {
h := newHandler(params)
return h.server(&http.Server{
Handler: h,
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
// Off is an IdleTimeout of 0, which Go's server replaces with
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
IdleTimeout: params.Config.ClientIdleTimeout,
// Go's server reads 4 KiB past MaxHeaderBytes before it
// refuses, so the limit a client meets is the setting.
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
})
}
// newHandler returns the handler with what every request shares, as the // newHandler returns the handler with what every request shares, as the
// settings in params make it. // settings in params make it.
func newHandler(params Params) *handler { func newHandler(params Params) *handler {
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName) m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
lists, dnsbl, abuseIPDB := newReputation(params, m) lists, dnsbl, abuseIPDB := newReputation(params, m)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
if err != nil {
panic(err) // New fails only for a size below one
}
h := &handler{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
processLog: params.ProcessLog, processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
now: params.Now, now: params.Now,
metrics: m, metrics: m,
awaitingReport: awaitingReport,
limiter: ratelimit.New(ratelimit.Limits{ limiter: ratelimit.New(ratelimit.Limits{
PerMinute: params.Config.RateLimitPerMinute, PerMinute: params.Config.RateLimitPerMinute,
PerHour: params.Config.RateLimitPerHour, PerHour: params.Config.RateLimitPerHour,
@@ -232,7 +207,10 @@ func newReputation(
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. // up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_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
// JSON or XML body over its limit.
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
if cfg.WAFMode == config.WAFModeOff { if cfg.WAFMode == config.WAFModeOff {
return nil return nil
@@ -240,26 +218,24 @@ func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: cfg.WAFBodyLimit, BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
}) })
if err != nil { if err != nil {
// The Core Rule Set is built in, and the settings cannot break it: // The Core Rule Set is built in, and the settings cannot break it:
// the paranoia level is from 1 to 4, the body limit at most 1G, and // the paranoia level is from 1 to 4, and the id of no rule switches
// the id of no rule switches nothing off. // nothing off.
panic(err) panic(err)
} }
return coreRuleSet return coreRuleSet
} }
// handler is the proxy. It holds what every request shares; what belongs // handler is servewaf's handler. It holds what every request shares; what
// to one request is in a request. // belongs to one request is in a request.
type handler struct { type handler struct {
config *config.Config config *config.Config
requestLog io.Writer requestLog io.Writer
processLog *slog.Logger processLog *slog.Logger
errorLog *log.Logger
transport http.RoundTripper
now func() time.Time now func() time.Time
metrics *metrics.Metrics metrics *metrics.Metrics
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
@@ -273,88 +249,7 @@ type handler struct {
rules *rules.Files rules *rules.Files
coreRuleSet *waf.CoreRuleSet coreRuleSet *waf.CoreRuleSet
alerts *alerts.Queue alerts *alerts.Queue
// awaitingReport holds, in servewaf, the decision on each answered // awaitingReport holds the decision on each answered question by its id
// question by its id until its report comes, and nil for a question still // until its report comes, and nil for a question still being handled.
// being handled.
awaitingReport *lru.Cache[string, *decision] awaitingReport *lru.Cache[string, *decision]
} }
// newTransport returns what carries requests to the app. It never goes
// through a proxy named in the environment, and leaves the app's answers
// compressed or not as the app sent them.
func newTransport() *http.Transport {
return &http.Transport{
MaxIdleConns: appIdleConns,
MaxIdleConnsPerHost: appIdleConns,
IdleConnTimeout: appIdleConnTimeout,
DisableCompression: true,
}
}
// ServeHTTP handles one request: it works out the client, runs the
// checks, passes the request to the app and the answer back within the
// limits, or answers it itself if it is for smallwebwaf, and writes the
// request's log line.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r)
defer rq.finish()
// The health endpoint is answered at once, before any check, so that
// a health checker is never refused. It does not ask the app.
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
rq.answerHealth()
return
}
// Once the request has ended, before its log line is written. The
// last deferred runs first: countRefusal before addToHistory, so that
// a broken error burst is in the client's history.
defer rq.addToHistory()
defer rq.countAnomalies()
defer rq.countRefusal()
refused := rq.check(r.Context())
rq.checked = time.Now()
if refused != nil {
rq.answer(*refused)
return
}
// A request for smallwebwaf itself is answered where another would be
// passed to the app, so that it goes through every check first.
if strings.HasPrefix(r.URL.Path, adminPrefix) {
rq.answerAdmin()
return
}
// Once the response has ended, before the request is added to its
// client's history. Deferred, since ReverseProxy panics to end a
// response it cannot finish.
defer rq.countBytes()
rq.forward(r.Context())
}
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
// logs through h, with what httpServer sets.
func (h *handler) server(httpServer *http.Server) *Server {
httpServer.Addr = h.config.ListenAddr
httpServer.ErrorLog = h.errorLog
return &Server{
Server: httpServer,
Ledger: h.ledger,
Limiter: h.limiter,
GeoJS: h.geojs,
Anomalies: h.anomalies,
LookupFile: h.lookupFile,
Lists: h.lists,
DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
Metrics: h.metrics,
}
}
+175 -70
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"log/slog"
"maps" "maps"
"net" "net"
"net/http" "net/http"
@@ -19,48 +20,43 @@ import (
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
const ( const (
// shortTimeout is what a test sets a timeout to, to see it run out.
// It starts before the test has set up its case, such as an upgrade
// or the app's buffers filling, so it is as long as the hold-up of the
// test process that wantTimedOut allows: a shorter one can run out
// first on a busy host.
shortTimeout = waitLimit / 2
// longTimeoutSetting is a timeout that does not run out in a test.
longTimeoutSetting = "1m"
// waitLimit bounds how long a test waits for what should happen. // waitLimit bounds how long a test waits for what should happen.
waitLimit = 10 * time.Second waitLimit = 10 * time.Second
// pollInterval is how often a test looks for a log line. // pollInterval is how often a test looks for a log line.
pollInterval = 10 * time.Millisecond pollInterval = 10 * time.Millisecond
// localhost is where every test server listens, and so the address // localhost is where every test server listens, and so the address rproxy
// smallwebwaf sees each test's requests come from. // sees each test's requests come from.
localhost = "127.0.0.1" localhost = "127.0.0.1"
// requestType is the type that marks a request log line. // requestType is the type that marks a request log line.
requestType = "request" requestType = "request"
// protocol is the protocol of every test's requests. // protocol is the protocol of every question.
protocol = "HTTP/1.1" protocol = "HTTP/1.1"
// trustLocalhost trusts the address every test connects from, and a
// network for proxies in front of it.
trustLocalhost = localhost + "/32,10.0.0.0/8"
// appHost is the host every test asks for.
appHost = "app.example"
// client is the client's address, as a proxy names it.
client = "203.0.113.9"
// forwardedFor is the header that lists the client and its proxies,
// and forwardedProto the one that gives the scheme the client used.
forwardedFor = "X-Forwarded-For"
forwardedProto = "X-Forwarded-Proto"
// secure is the scheme a client reached traefik with, and plain the one
// rproxy serves.
secure = "https"
plain = "http"
) )
// shortTimeoutSetting is shortTimeout as a setting's value.
//
//nolint:gochecknoglobals // a constant cannot call String
var shortTimeoutSetting = shortTimeout.String()
// The settings the tests set. // The settings the tests set.
const ( const (
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
mode = "SWWAF_MODE" mode = "SWWAF_MODE"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" trustedProxies = "SWRP_TRUSTED_PROXIES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
trustedProxies = "SWWAF_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWWAF_ALLOW_NETS"
@@ -179,6 +175,73 @@ func (o *output) requestLine(t *testing.T) logLine {
return o.requestLines(t, 1)[0] return o.requestLines(t, 1)[0]
} }
// endedLines waits for count requests that went through rproxy, or to servewaf's
// own endpoints, to have ended, and returns the last log line of each, in the
// order servewaf took them. A question's last line is the one its report writes.
func (o *output) endedLines(t *testing.T, count int) []logLine {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
found := o.lastLines(t)
ended := len(found) >= count
for _, line := range found[:min(count, len(found))] {
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
ended = false
}
}
if ended {
return found
}
time.Sleep(pollInterval)
}
t.Fatalf("fewer than %d requests have ended after %s", count, waitLimit)
return nil
}
// lastLines returns the last request log line written so far of each request,
// in the order servewaf took them.
func (o *output) lastLines(t *testing.T) []logLine {
t.Helper()
var found []logLine
places := map[string]int{}
for _, fields := range o.lines(t) {
if fields["type"] != requestType {
continue
}
line := decodeLine(t, fields)
place, seen := places[line.RequestID]
if seen {
found[place] = line
continue
}
places[line.RequestID] = len(found)
found = append(found, line)
}
return found
}
// endedLine waits for a test's one request to have ended, and returns its last
// log line.
func (o *output) endedLine(t *testing.T) logLine {
t.Helper()
return o.endedLines(t, 1)[0]
}
// decodeLine reads a request log line's fields into a logLine. // decodeLine reads a request log line's fields into a logLine.
func decodeLine(t *testing.T, fields map[string]any) logLine { func decodeLine(t *testing.T, fields map[string]any) logLine {
t.Helper() t.Helper()
@@ -198,7 +261,7 @@ func decodeLine(t *testing.T, fields map[string]any) logLine {
return line return line
} }
// startApp starts app as the app smallwebwaf passes requests to. // startApp starts app as the app rproxy passes requests to.
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server { func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
t.Helper() t.Helper()
@@ -208,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
return server return server
} }
// startProxy starts smallwebwaf in front of the app at appURL, with the // startProxy starts servewaf, and rproxy in front of the app at appURL, each with
// settings in env on top of the defaults, and returns where it listens and // the settings in env on top of the defaults, and returns where rproxy listens
// what it writes. // and what servewaf writes.
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) { func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
t.Helper() t.Helper()
@@ -230,10 +293,10 @@ 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 the server as well. Unless // bans made by the time now tells, and returns servewaf's server as well, whose
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and // Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of // directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
// 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,
env map[string]string, env map[string]string,
@@ -246,14 +309,70 @@ func startProxyWithClock(
} }
// startProxyWithAlerts is startProxyWithClock, and returns the queue of // startProxyWithAlerts is startProxyWithClock, and returns the queue of
// the alerts the proxy raises as well, as newProxy makes them. // the alerts servewaf raises as well, as newServeWAF makes them.
func startProxyWithAlerts( func startProxyWithAlerts(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
) (string, *output, *proxy.Server, *alerts.Queue) { ) (string, *output, *proxy.Server, *alerts.Queue) {
t.Helper() t.Helper()
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env) server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
serve(t, server)
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
}
// serve serves server on a port of its own until the test ends, and sets its
// Addr to where it listens.
func serve(t *testing.T, server *proxy.Server) {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server.Addr = listener.Addr().String()
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
})
}
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
func startRProxy(
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
) string {
t.Helper()
settings := map[string]string{
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
"SWRP_UPSTREAM_URL": appURL,
"SWRP_TIMEOUT_MS": "60000",
}
maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := settings[name]
return value, ok
})
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
})
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil { if err != nil {
@@ -268,38 +387,35 @@ func startProxyWithAlerts(
_ = server.Close() _ = server.Close()
}) })
return listener.Addr().String(), out, server, alertQueue return listener.Addr().String()
} }
// newProxy makes the server startProxyWithClock starts, without starting // newServeWAF makes servewaf's server, without starting it, and returns it, what
// it, and returns it, what it writes, and the queue of the alerts the // it writes, and the queue of the alerts it raises, as the settings in env make
// proxy raises, as the settings in env make it. No alert is sent from the // it. No alert is sent from the queue: they wait in it, for the test to look at.
// queue: they wait in it, for the test to look at. With no geojsURL, there // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE // SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
// is off unless env sets it. While it is file, the lookup database // database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
// SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB // at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off // env sets it, so that only the tests of the Core Rule Set have their requests
// unless env sets it, so that only the tests of the Core Rule Set have // inspected by it.
// their requests inspected by it. func newServeWAF(
func newProxy( t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string,
) (*proxy.Server, *output, *alerts.Queue) { ) (*proxy.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
return newServerWith(t, proxy.New, appURL, geojsURL, now, env) return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env)
} }
// newServerWith is newProxy for the server build makes. // newServeWAFWith is newServeWAF for the server build makes.
func newServerWith( func newServeWAFWith(
t *testing.T, build func(proxy.Params) *proxy.Server, t *testing.T, build func(proxy.Params) *proxy.Server,
appURL, geojsURL string, now func() time.Time, env map[string]string, geojsURL string, now func() time.Time, env map[string]string,
) (*proxy.Server, *output, *alerts.Queue) { ) (*proxy.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app", rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
wafMode: off,
} }
if geojsURL == "" { if geojsURL == "" {
settings[lookupSource] = off settings[lookupSource] = off
@@ -391,7 +507,7 @@ func readAnswer(res *http.Response) answer {
return answer{status: res.StatusCode, header: res.Header, body: body, err: err} return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
} }
// newRequest makes a request for path to smallwebwaf at addr. // newRequest makes a request for path to addr, where rproxy or servewaf listens.
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request { func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
t.Helper() t.Helper()
@@ -415,15 +531,15 @@ func do(t *testing.T, req *http.Request) answer {
return readAnswer(res) return readAnswer(res)
} }
// get sends a GET request for path to smallwebwaf at addr. // get sends a GET request for path to addr.
func get(t *testing.T, addr, path string) answer { func get(t *testing.T, addr, path string) answer {
t.Helper() t.Helper()
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody)) return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
} }
// dial opens a connection to smallwebwaf at addr, for requests the HTTP // dial opens a connection to addr, for requests the HTTP client cannot make,
// client cannot make, such as one that stops sending halfway. // such as one that stops sending halfway.
func dial(t *testing.T, addr string) net.Conn { func dial(t *testing.T, addr string) net.Conn {
t.Helper() t.Helper()
@@ -484,14 +600,3 @@ func wantStatus(t *testing.T, got answer, status int) {
t.Errorf("status %d, want %d", got.status, status) t.Errorf("status %d, want %d", got.status, status)
} }
} }
// wantTimedOut checks that what began at start ended once shortTimeout
// had run out, and not much later.
func wantTimedOut(t *testing.T, start time.Time) {
t.Helper()
took := time.Since(start)
if took < shortTimeout || took > shortTimeout+waitLimit/2 {
t.Errorf("took %s, want %s", took, shortTimeout)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
req.Header.Set(forwardedFor, sent.client) req.Header.Set(forwardedFor, sent.client)
wantStatus(t, do(t, req), sent.want) wantStatus(t, do(t, req), sent.want)
line := out.requestLines(t, i+1)[i] line := out.endedLines(t, i+1)[i]
if line.ClientIP != sent.logged { if line.ClientIP != sent.logged {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged) t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
} }
+8 -10
View File
@@ -272,6 +272,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventReputationHit, Event: alerts.EventReputationHit,
Host: appHost,
Client: netip.MustParseAddr(fromDE), Client: netip.MustParseAddr(fromDE),
Netblock: netip.MustParsePrefix(fromDE + "/32"), Netblock: netip.MustParsePrefix(fromDE + "/32"),
ASN: asnDE, ASN: asnDE,
@@ -289,7 +290,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// Each list's hits, none of its fetches failed, and when its copy was // Each list's hits, none of its fetches failed, and when its copy was
// fetched, 0 for the one without. // fetched, 0 for the one without.
metrics := s.scrape(unplaced) metrics := s.scrape()
fetched := float64(listsFetched().Unix()) fetched := float64(listsFetched().Unix())
for listURL, want := range map[string]struct{ hits, fetched float64 }{ for listURL, want := range map[string]struct{ hits, fetched float64 }{
@@ -513,6 +514,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventReputationHit, Event: alerts.EventReputationHit,
Host: appHost,
Client: netip.MustParseAddr(fromDE), Client: netip.MustParseAddr(fromDE),
Netblock: netip.MustParsePrefix(fromDE + "/32"), Netblock: netip.MustParsePrefix(fromDE + "/32"),
ASN: asnDE, ASN: asnDE,
@@ -530,7 +532,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// Each zone's hits, and its queries and their failures, none, since // Each zone's hits, and its queries and their failures, none, since
// every client had its verdicts. // every client had its verdicts.
metrics := s.scrape(unplaced) metrics := s.scrape()
for _, zone := range []string{dnsblZone, otherZone} { for _, zone := range []string{dnsblZone, otherZone} {
labels := `{instance="` + alertInstance + `",source="` + zone + `"}` labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
@@ -564,7 +566,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
masked) masked)
} }
metrics := s.scrape(unplaced) metrics := s.scrape()
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+ wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
alertInstance+`",source="`+masked+`"}`, 1) alertInstance+`",source="`+masked+`"}`, 1)
@@ -737,10 +739,6 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned, "a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
ratelimit.Offences{Attack: 1}, ratelimit.Offences{Attack: 1},
}, },
{
"a missing token", proxy.MetricsPath, http.StatusUnauthorized,
requestlog.ActionAdmin, ratelimit.Offences{TokenRefused: 1},
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -748,7 +746,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
s, clk, server := startWithClock(t, "", map[string]string{ s, clk, server := startWithClock(t, "", map[string]string{
abuseIPDBKey: accountKey, reputationAction: actionLog, abuseIPDBKey: accountKey, reputationAction: actionLog,
rulesDir: writeRules(t, testRules), attackBanDuration: "1h", rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
trapPaths: trapPathList, metricsToken: token, wafMode: block, trapPaths: trapPathList, wafMode: block,
}) })
s.request(client, tc.path, tc.status, tc.action) s.request(client, tc.path, tc.status, tc.action)
@@ -862,7 +860,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
// The hits, and the checks, none, since no client committed an // The hits, and the checks, none, since no client committed an
// offence, so that the whole budget is left. // offence, so that the whole budget is left.
metrics := s.scrape(unplaced) metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}` labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
@@ -889,7 +887,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+ wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`) `instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
} }
+60 -554
View File
@@ -2,17 +2,10 @@ package proxy
import ( import (
"context" "context"
"errors"
"io"
"net/http" "net/http"
"net/http/httptrace"
"net/http/httputil"
"net/netip" "net/netip"
"os"
"slices" "slices"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
@@ -24,19 +17,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
// flushAfterEachWrite has ReverseProxy pass on each part of the app's // refusal is servewaf refusing a request: the status the visitor is to get, 0
// answer as soon as it arrives. // to close its connection without an answer, and the action the log line names.
const flushAfterEachWrite time.Duration = -1
// refusal is smallwebwaf refusing a request, or refusing to go on with it:
// the status the client is answered if the response has not started yet,
// 0 to close the connection without an answer, the action the log line
// names, and the setting whose size or time limit the request passed, if
// that is why.
type refusal struct { type refusal struct {
status int status int
action string action string
limit string
} }
// decision is what the checks note of a request: its log line, and what // decision is what the checks note of a request: its log line, and what
@@ -48,8 +33,8 @@ type decision struct {
client netip.Addr client netip.Addr
// requestURI is the path with the query, as a ban's notes keep it. // requestURI is the path with the query, as a ban's notes keep it.
requestURI string requestURI string
// site is, in servewaf, the host the visitor asked for, which the // site is, for a question, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. It is "" otherwise. // alerts name, since one servewaf serves many sites.
site string site string
// allowed is true for a question answered with DispositionAllow. // allowed is true for a question answered with DispositionAllow.
allowed bool allowed bool
@@ -78,75 +63,34 @@ type decision struct {
reputation []bans.ReputationHit reputation []bans.ReputationHit
} }
// request is one request on its way through smallwebwaf, from the moment // request is one request to servewaf, from the moment its headers have been
// its headers have been read to its log line. // read to its log line.
type request struct { type request struct {
decision decision
h *handler h *handler
in *http.Request in *http.Request
// rc sets the deadlines of the connection to the client.
rc *http.ResponseController
out *responseWriter out *responseWriter
body *requestBody // nil for a request without a body
peer netip.Addr
peerTrusted bool
// tokenRefused is true for a request refused for a missing or wrong
// token, an offence its client's history counts.
tokenRefused bool
// blocklisted is true once a blocklist is found to list the client, // blocklisted is true once a blocklist is found to list the client,
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once // dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
// AbuseIPDB's score of it is a hit. // AbuseIPDB's score of it is a hit.
blocklisted, dnsblListed, abuseIPDBHit bool blocklisted, dnsblListed, abuseIPDBHit bool
start time.Time start time.Time
// checked is when the checks were done, and upstreamStart when the // checked is when the checks were done.
// request was handed to the app.
checked time.Time checked time.Time
upstreamStart time.Time // refused is what the request was refused with, nil if it was not.
// cancel ends the request to the app. refused *refusal
cancel context.CancelFunc // requestBytes is how many bytes of the request's body have been read.
// refused is the first refusal, from whichever goroutine meets it. requestBytes int64
refused atomic.Pointer[refusal]
// complete is true once the app's whole answer has been passed on.
complete bool
// upgraded is the connection to the app once the app has switched
// protocols, as for a WebSocket, and nil otherwise.
upgraded *upgradedConn
// mu guards what follows. The timeouts run on goroutines of their
// own, and the transport starts and stops them, and notes the times
// below, from its own; once timersStopped is set, none of the timeouts
// acts any more.
mu sync.Mutex
timersStopped bool
clientRequestTimer *time.Timer
upstreamRequestTimer *time.Timer
upstreamResponseTimer *time.Timer
// connected is when there was a connection to the app, requestSent
// when the app had been sent the whole request, and answerStarted
// when the first byte of its answer arrived.
connected time.Time
requestSent time.Time
answerStarted time.Time
}
// newRequest starts handling r: it works out the client from the TCP peer
// and X-Forwarded-For, and goes on as startRequest does.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
return h.startRequest(w, r, client, peer, isInside(peer, trusted))
} }
// startRequest starts handling r from client, which came through peer: it // startRequest starts handling r from client, which came through peer: it
// notes the time, counts the request as under way, and starts the log line // notes the time, counts the request as under way, and starts the log line
// with what is known of the request. peerTrusted says whether to believe // with what is known of the request. isQuestion says whether r is a question,
// the request's id and scheme, which r's headers give. // whose headers give the request's id and scheme.
func (h *handler) startRequest( func (h *handler) startRequest(
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, peerTrusted bool, w http.ResponseWriter, r *http.Request, client, peer netip.Addr, isQuestion bool,
) *request { ) *request {
h.metrics.RequestStarted() h.metrics.RequestStarted()
@@ -162,14 +106,14 @@ func (h *handler) startRequest(
Instance: h.config.InstanceName, Instance: h.config.InstanceName,
ClientIP: client.String(), ClientIP: client.String(),
Method: r.Method, Method: r.Method,
Scheme: scheme(r, peerTrusted), Scheme: scheme(r, isQuestion),
Host: r.Host, Host: r.Host,
Path: rules.Path(r), Path: rules.Path(r),
Query: r.URL.RawQuery, Query: r.URL.RawQuery,
Protocol: r.Proto, Protocol: r.Proto,
Referer: r.Referer(), Referer: r.Referer(),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
RequestID: requestID(r, peerTrusted), RequestID: requestID(r, isQuestion),
PeerIP: peer.String(), PeerIP: peer.String(),
ForwardedFor: strings.Join(forwardedFor, ", "), ForwardedFor: strings.Join(forwardedFor, ", "),
ClientGroup: h.clientGroup(client).String(), ClientGroup: h.clientGroup(client).String(),
@@ -182,10 +126,7 @@ func (h *handler) startRequest(
}, },
h: h, h: h,
in: r, in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w}, out: &responseWriter{ResponseWriter: w},
peer: peer,
peerTrusted: peerTrusted,
start: start, start: start,
} }
@@ -194,10 +135,6 @@ func (h *handler) startRequest(
rq.line.ContentLength = r.ContentLength rq.line.ContentLength = r.ContentLength
} }
if r.Body != http.NoBody {
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
}
return rq return rq
} }
@@ -222,29 +159,21 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
return headers return headers
} }
// check is the one place where a request can be refused once its client // check is the one place where a request can be refused. It returns nil to let
// is known, before anything reaches the app, and before its body is read, // the request through. The checks of checkClient decide: a request they refuse
// but for the part the Core Rule Set reads. It returns nil to let the // is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
// request through. The checks of checkClient come first, answered with // or 431 for the header limits. In observe mode nothing is refused, and the log
// SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, or 431 for // line names what enforce mode would have done. ctx is the request's own context.
// servewaf's header limits, and then the size limit, so that a request the
// rate limits count is counted even when it is refused for its size. In
// observe mode a request checkClient refuses goes on to the size limit like
// any other. A size or time limit the Core Rule Set's reading of the body
// meets ends the request in either mode. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) action := rq.checkClient(ctx)
refused := rq.refused.Load()
if refused != nil {
return refused
}
switch { switch {
case action == "": case action == "":
return nil
case rq.h.config.Observe: case rq.h.config.Observe:
// The log line names what enforce mode would have done.
rq.line.WouldAction = action rq.line.WouldAction = action
return nil
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked: case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionTooLarge: case action == requestlog.ActionTooLarge:
@@ -252,17 +181,6 @@ func (rq *request) check(ctx context.Context) *refusal {
default: default:
return rq.banResponse(action) return rq.banResponse(action)
} }
maxBytes := rq.h.config.RequestMaxBytes
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
return &refusal{
status: http.StatusRequestEntityTooLarge,
action: requestlog.ActionTooLarge,
limit: "SWWAF_REQUEST_MAX_BYTES",
}
}
return nil
} }
// checkClient runs the checks on the request's client, and returns the // checkClient runs the checks on the request's client, and returns the
@@ -277,10 +195,9 @@ func (rq *request) check(ctx context.Context) *refusal {
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the // rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that // request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit // every other request is counted, each of them by the client's limit
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then, in // percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
// servewaf, the header limits, and last the Core Rule Set. A request exempt // limits, and last the Core Rule Set. A request exempt from the rate limits
// from the rate limits is exempt from the byte limits too. ctx is the // is exempt from the byte limits too. ctx is the request's own context.
// request's own context.
func (rq *request) checkClient(ctx context.Context) string { func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) { if isInside(rq.client, cfg.AllowNets) {
@@ -381,251 +298,32 @@ func pathExempt(r *http.Request, prefixes []string) bool {
}) })
} }
// forward passes the request to the app and the app's answer back. ctx // finish counts the request in the metrics and writes its log line.
// is the request's own context.
func (rq *request) forward(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rq.cancel = cancel
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
GotConn: rq.gotConn,
WroteRequest: rq.wroteRequest,
GotFirstResponseByte: rq.gotFirstResponseByte,
})
out := rq.in.WithContext(ctx)
if rq.body != nil {
out.Body = rq.body
}
reverseProxy := &httputil.ReverseProxy{
Rewrite: rq.rewrite,
Transport: rq.h.transport,
FlushInterval: flushAfterEachWrite,
ErrorLog: rq.h.errorLog,
ModifyResponse: rq.modifyResponse,
ErrorHandler: rq.answerError,
}
rq.startRequestTimers()
rq.upstreamStart = time.Now()
reverseProxy.ServeHTTP(rq.out, out)
}
// rewrite makes the request the app receives: the client's request,
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
// the request's id set, without any X-Client-ASN or X-Client-Country the
// client sent, whatever SWWAF_ADD_LOOKUP_HEADERS says, and, while it is
// set, with the client's AS number and country in them.
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
upstream := rq.h.config.UpstreamURL
pr.Out.URL.Scheme = upstream.Scheme
pr.Out.URL.Host = upstream.Host
// ReverseProxy drops query parameters it cannot parse; the app gets
// the query as the client sent it.
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
// ReverseProxy sends the path URL.EscapedPath gives, which, for a path
// holding a character Go escapes, such as |, is the whole path decoded
// and escaped again, %2F turned into /. Opaque is sent as it is, so the
// app gets the path as the client sent it. Left out are a target that
// is no path, such as *, and a path starting with //, which Go would
// send in Opaque as a scheme and host.
path := rules.Path(pr.In)
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
pr.Out.URL.Opaque = path
}
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
pr.Out.Header.Del(asnHeader)
pr.Out.Header.Del(countryHeader)
if rq.h.config.AddLookupHeaders {
setLookupHeaders(pr.Out.Header, rq.line.ASN, rq.line.Country)
}
}
// modifyResponse looks at the app's answer before ReverseProxy passes it
// on.
func (rq *request) modifyResponse(res *http.Response) error {
rq.line.UpstreamStatus = res.StatusCode
if res.StatusCode == http.StatusSwitchingProtocols {
// An upgraded connection, such as a WebSocket, is not cut by the
// timeouts. ReverseProxy writes this answer straight to the
// connection it takes over, not through rq.out, and then copies
// what passes each way through res.Body, the connection to the app.
// It may still refuse the switch, as when the app's Upgrade is not
// the client's, so the status is noted only once it has taken over
// the connection, by rq.out's Hijack.
rq.stopTimers()
rq.line.Websocket = true
conn, ok := res.Body.(io.ReadWriteCloser)
if ok {
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
res.Body = rq.upgraded
}
return nil
}
maxBytes := rq.h.config.ResponseMaxBytes
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
rq.refuse(refusal{
status: http.StatusBadGateway,
action: requestlog.ActionTooLarge,
limit: "SWWAF_RESPONSE_MAX_BYTES",
})
return errResponseTooLarge
}
// Go's server adds a Content-Type it guesses from the body to a
// response whose headers have no Content-Type key. The key with no
// value sends none, as the app sent none.
if res.Header.Get("Content-Type") == "" {
rq.out.Header()["Content-Type"] = nil
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.startClientResponseTimeout()
return nil
}
// answerError is ReverseProxy's ErrorHandler: the request could not be
// passed to the app, or the app's answer cannot be passed on.
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
if rq.upgraded != nil {
// The app switched protocols, but the switch was not passed on,
// and ReverseProxy leaves the app's connection open when it
// refuses it.
_ = rq.upgraded.Close()
}
refused := rq.refused.Load()
if refused == nil {
if rq.in.Context().Err() != nil {
return // the client has gone, and there is no one to answer
}
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
refused = &refusal{
status: http.StatusBadGateway,
action: requestlog.ActionUpstreamError,
}
}
rq.answer(*refused)
}
// answer sends smallwebwaf's own answer, unless the response has already
// started, and records the refusal for the log line.
func (rq *request) answer(r refusal) {
rq.refused.CompareAndSwap(nil, &r)
if rq.out.status != 0 {
return // too late to answer: the connection can only be cut
}
if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. This panic has Go's server close
// the connection without an answer, and log nothing; the log line
// is still written as the handler returns.
panic(http.ErrAbortHandler)
}
// A client found too slow is read no more; any other may go on
// sending until its time is up, so that Go's server can read the
// rest of the body and end the request cleanly.
deadline := rq.clientRequestDeadline()
if r.status == http.StatusRequestTimeout {
deadline = time.Now()
}
rq.stopReadingBody(deadline)
timeout := rq.h.config.ClientResponseTimeout
if timeout > 0 {
_ = rq.rc.SetWriteDeadline(time.Now().Add(timeout))
}
http.Error(rq.out, http.StatusText(r.status), r.status)
}
// refuse records r, unless an earlier refusal was, and ends the request
// to the app, if one was made: smallwebwaf reads the body of a request
// it answers itself too.
func (rq *request) refuse(r refusal) {
rq.refused.CompareAndSwap(nil, &r)
if rq.cancel != nil {
rq.cancel()
}
}
// finish ends the request's timeouts, counts it in the metrics and writes
// its log line.
func (rq *request) finish() { func (rq *request) finish() {
rq.stopTimers()
refused := rq.refused.Load()
if refused == nil {
rq.stopReadingBody(rq.clientRequestDeadline())
}
line := &rq.line line := &rq.line
line.Status = rq.out.status line.Status = rq.out.status
line.ResponseBytes = rq.out.bytes line.ResponseBytes = rq.out.bytes
header := rq.out.Header() line.ResponseContentType = rq.out.Header().Get("Content-Type")
line.ResponseContentType = header.Get("Content-Type") line.RequestBytes = rq.requestBytes
line.CacheControl = header.Get("Cache-Control")
line.Location = header.Get("Location")
line.RequestBytes = rq.requestBytes()
// limit is the setting whose size or time limit the request passed.
var limit string
switch { switch {
case refused != nil: case rq.refused != nil:
line.Action = refused.action line.Action = rq.refused.action
limit = refused.limit case rq.out.err != nil || rq.in.Context().Err() != nil:
case errors.Is(rq.out.err, os.ErrDeadlineExceeded):
// The client took longer than SWWAF_CLIENT_RESPONSE_TIMEOUT to
// take the response.
line.Action = requestlog.ActionTimedOut
limit = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
case !rq.complete && (rq.out.err != nil || rq.in.Context().Err() != nil):
line.Aborted = true line.Aborted = true
} }
now := time.Now() duration := time.Since(rq.start)
duration := now.Sub(rq.start)
line.DurationTotal = requestlog.Milliseconds(duration) line.DurationTotal = requestlog.Milliseconds(duration)
line.DurationChecks = timing(rq.start, rq.checked) line.DurationChecks = timing(rq.start, rq.checked)
var upstreamDuration time.Duration
if !rq.upstreamStart.IsZero() {
upstreamDuration = now.Sub(rq.upstreamStart)
line.DurationUpstreamTotal = new(requestlog.Milliseconds(upstreamDuration))
rq.mu.Lock()
line.DurationUpstreamConnect = timing(rq.upstreamStart, rq.connected)
line.DurationUpstreamFirstByte = timing(rq.upstreamStart, rq.answerStarted)
rq.mu.Unlock()
}
// Counted before the log line is written, so that the metrics count // Counted before the log line is written, so that the metrics count
// every request whose line is out. An allowed request is the exception: // every request whose line is out. An allowed request is the exception:
// its report gives the status, bytes and times the metrics count. // its report gives the status, bytes and times the metrics count.
if rq.allowed { if rq.allowed {
rq.h.metrics.RequestAllowed() rq.h.metrics.RequestAllowed()
} else { } else {
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration) rq.h.metrics.RequestEnded(line, duration)
} }
rq.writeLine() rq.writeLine()
@@ -657,18 +355,16 @@ func timing(start, end time.Time) *float64 {
// before either was there, and one from GeoJS that comes later is added // before either was there, and one from GeoJS that comes later is added
// when it comes. // when it comes.
func (rq *request) addToHistory() { func (rq *request) addToHistory() {
forwarded := !rq.upstreamStart.IsZero()
request := ratelimit.Request{ request := ratelimit.Request{
Forwarded: forwarded, Forwarded: rq.allowed,
Refused: !forwarded && rq.refused.Load() != nil, Refused: rq.refused != nil,
Status: rq.out.status, Status: rq.out.status,
RequestBytes: rq.requestBytes(), RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes, ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit, BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack, Attack: rq.attack,
RuleBlocked: rq.ruleBlocked, RuleBlocked: rq.ruleBlocked,
WAFBlocked: rq.wafBlocked, WAFBlocked: rq.wafBlocked,
TokenRefused: rq.tokenRefused,
} }
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request) rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
@@ -729,228 +425,38 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client)) return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
} }
// requestBytes is how many bytes of the request's body have been read. // responseWriter is servewaf's answer to a request. It notes the status and
func (rq *request) requestBytes() int64 { // size for the log line, and the first error writing the answer.
if rq.body == nil { type responseWriter struct {
return 0 http.ResponseWriter
}
return rq.body.bytes.Load() // status is the status sent, or zero before one is.
status int
bytes int64
err error
} }
// clientRequestDeadline is when the client must have sent its whole // WriteHeader sends the status and headers.
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off. func (w *responseWriter) WriteHeader(status int) {
func (rq *request) clientRequestDeadline() time.Time { if w.status == 0 {
timeout := rq.h.config.ClientRequestTimeout w.status = status
if timeout == 0 {
return time.Time{}
} }
return rq.start.Add(timeout) w.ResponseWriter.WriteHeader(status)
} }
// stopReadingBody ends, at deadline, the reading of a client body that has // Write sends part of the body.
// not arrived whole: Go's server then reads no more of it, and closes the func (w *responseWriter) Write(p []byte) (int, error) {
// connection after the answer. if w.status == 0 {
func (rq *request) stopReadingBody(deadline time.Time) { w.status = http.StatusOK
if rq.body == nil || rq.body.received.Load() {
return
} }
_ = rq.rc.SetReadDeadline(deadline) n, err := w.ResponseWriter.Write(p)
} w.bytes += int64(n)
// startRequestTimers starts the timeouts that run while the request goes if w.err == nil {
// to the app: SWWAF_CLIENT_REQUEST_TIMEOUT until the client has sent its w.err = err
// whole body, and SWWAF_UPSTREAM_REQUEST_TIMEOUT until the app has been
// sent the whole request.
func (rq *request) startRequestTimers() {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.body != nil && rq.h.config.ClientRequestTimeout > 0 {
rq.clientRequestTimer = time.AfterFunc(
time.Until(rq.clientRequestDeadline()), func() {
rq.requestTimedOut("SWWAF_CLIENT_REQUEST_TIMEOUT")
})
}
timeout := rq.h.config.UpstreamRequestTimeout
if timeout > 0 {
rq.upstreamRequestTimer = time.AfterFunc(timeout, func() {
rq.requestTimedOut("SWWAF_UPSTREAM_REQUEST_TIMEOUT")
})
}
}
// requestTimedOut is called when limit, SWWAF_CLIENT_REQUEST_TIMEOUT or
// SWWAF_UPSTREAM_REQUEST_TIMEOUT, runs out while the request is still on
// its way to the app. The answer names the side smallwebwaf was waiting
// on at that moment: 408 when it was waiting for the client to send more
// of its body, 504 when it was waiting for the app to be reached or to
// take what it had.
func (rq *request) requestTimedOut(limit string) {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
if rq.body == nil || !rq.body.waiting.Load() {
// Once the app has answered, the transport, ReverseProxy and Go's
// server each close the client's body, and closing it reads what
// is left of a body under 256 KiB without requestBody.Read, so
// waiting is false while that read waits for the client. That
// read ends now, before refuse: should smallwebwaf still answer
// itself, answer then sets the deadline it gives its own answers.
if !rq.answerStarted.IsZero() {
rq.stopReadingBody(time.Now())
}
rq.refuse(refusal{
status: http.StatusGatewayTimeout,
action: requestlog.ActionTimedOut,
limit: limit,
})
return
} }
rq.refuse(refusal{ return n, err
status: http.StatusRequestTimeout,
action: requestlog.ActionTimedOut,
limit: limit,
})
// The transport gives up on the app only once its Read of the
// client's body returns, so that Read is ended now. The lock keeps
// this from reaching the connection after the request is handled.
_ = rq.rc.SetReadDeadline(time.Now())
}
// bodyReceived is called once the client has sent its whole body.
func (rq *request) bodyReceived() {
rq.mu.Lock()
defer rq.mu.Unlock()
stopTimer(rq.clientRequestTimer)
}
// gotConn is called once there is a connection to the app, a new one or
// one kept open from an earlier request.
func (rq *request) gotConn(httptrace.GotConnInfo) {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.connected = time.Now()
}
// gotFirstResponseByte is called once the first byte of the app's answer
// has arrived.
func (rq *request) gotFirstResponseByte() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.answerStarted = time.Now()
}
// wroteRequest is called once the app has been sent the whole request:
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return // the transport gives up, or tries again
}
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
stopTimer(rq.clientRequestTimer)
stopTimer(rq.upstreamRequestTimer)
rq.requestSent = time.Now()
timeout := rq.h.config.UpstreamResponseTimeout
if timeout > 0 {
rq.upstreamResponseTimer = time.AfterFunc(timeout, rq.responseTimedOut)
}
}
// responseTimedOut is called when SWWAF_UPSTREAM_RESPONSE_TIMEOUT runs out
// before the app has sent its whole answer.
func (rq *request) responseTimedOut() {
rq.mu.Lock()
defer rq.mu.Unlock()
if !rq.timersStopped {
rq.refuse(refusal{
status: http.StatusGatewayTimeout,
action: requestlog.ActionTimedOut,
limit: "SWWAF_UPSTREAM_RESPONSE_TIMEOUT",
})
}
}
// responseReceived is called once the app has sent its whole answer. An
// app may answer before the client has sent its whole body, as with a 404
// it sends without reading it. The timeouts then run on: until the
// transport's read of the body returns, Go's server cannot send the
// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a
// client that stops sending.
func (rq *request) responseReceived() {
rq.complete = true
if rq.body != nil && !rq.body.received.Load() {
return
}
rq.stopTimers()
}
// startClientResponseTimeout sets SWWAF_CLIENT_RESPONSE_TIMEOUT on the
// connection to the client: the response must reach the client within it
// of the end of the request, or of now if the app answers before it has
// the whole request.
func (rq *request) startClientResponseTimeout() {
timeout := rq.h.config.ClientResponseTimeout
if timeout == 0 {
return
}
from := rq.sentAt()
if from.IsZero() {
from = time.Now()
}
_ = rq.rc.SetWriteDeadline(from.Add(timeout))
}
// sentAt is when the app had been sent the whole request, or zero.
func (rq *request) sentAt() time.Time {
rq.mu.Lock()
defer rq.mu.Unlock()
return rq.requestSent
}
// stopTimers stops the request's timeouts and keeps any from starting
// later: the app's answer is complete, the connection upgraded, or the
// request handled.
func (rq *request) stopTimers() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.timersStopped = true
stopTimer(rq.clientRequestTimer)
stopTimer(rq.upstreamRequestTimer)
stopTimer(rq.upstreamResponseTimer)
}
// stopTimer stops t, which is nil when its timeout is off.
func stopTimer(t *time.Timer) {
if t != nil {
t.Stop()
}
} }
-55
View File
@@ -1,55 +0,0 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/smallwebwaf/internal/config"
)
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
rq := &request{
h: &handler{config: &config.Config{}},
out: &responseWriter{ResponseWriter: w},
}
app := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: http.NoBody,
}
err := rq.modifyResponse(app)
if err != nil {
t.Errorf("modifyResponse: %v", err)
}
// The headers go out with this, the first part of the body,
// which Go's server would guess a Content-Type from.
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
}))
t.Cleanup(srv.Close)
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
res, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
_ = res.Body.Close()
if res.Header.Values("Content-Type") != nil {
t.Errorf("client got Content-Type %q, want none",
res.Header.Get("Content-Type"))
}
}
+24 -117
View File
@@ -2,7 +2,6 @@ package proxy_test
import ( import (
"io" "io"
"maps"
"math" "math"
"net/http" "net/http"
"reflect" "reflect"
@@ -42,9 +41,6 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
return return
} }
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Location", "/elsewhere")
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
_, _ = io.WriteString(w, "moved") _, _ = io.WriteString(w, "moved")
}) })
@@ -70,7 +66,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
// A request's log line can come after its answer: each is waited for // A request's log line can come after its answer: each is waited for
// before the next request, so that the lines are in order. // before the next request, so that the lines are in order.
full := out.requestLines(t, 1)[0] full := out.endedLines(t, 1)[0]
// This one comes from 127.0.0.1, which the rate limits do not count, // This one comes from 127.0.0.1, which the rate limits do not count,
// with a body of 4 bytes whose length it does not announce, so that its // with a body of 4 bytes whose length it does not announce, so that its
@@ -81,7 +77,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n") "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
wantStatus(t, readResponse(t, conn), http.StatusNoContent) wantStatus(t, readResponse(t, conn), http.StatusNoContent)
bare := out.requestLines(t, 2)[1] bare := out.endedLines(t, 2)[1]
wantFullLine(t, full) wantFullLine(t, full)
wantBareLine(t, bare) wantBareLine(t, bare)
@@ -108,24 +104,23 @@ func wantFullLine(t *testing.T, line logLine) {
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"} headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, Time: line.Time, Instance: instance, Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
Time: line.Time, Instance: instance,
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure, ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
Host: appHost, Path: "/full", Protocol: protocol, Host: appHost, Path: "/full", Protocol: protocol,
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5, Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
RequestID: "from-traefik", PeerIP: localhost, RequestID: line.RequestID, PeerIP: localhost,
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group, ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
ContentType: "application/x-www-form-urlencoded", ContentLength: 3, ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
RequestHeaders: headers, HasAuthorization: true, HasCookie: true, RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
CacheControl: "no-store", Location: "/elsewhere",
Action: requestlog.ActionForward, Action: requestlog.ActionForward,
// Its 3 bytes in and 5 out, each way counted by default. // Its 3 bytes in and 5 out, each way counted by default.
Counts: ratelimit.Counts{ Counts: ratelimit.Counts{
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8, Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
}, },
}) })
if !reflect.DeepEqual(line.Line, want) { if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" {
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want) t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want)
} }
} }
@@ -135,12 +130,13 @@ func wantBareLine(t *testing.T, line logLine) {
t.Helper() t.Helper()
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, Time: line.Time, Instance: instance, Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
Time: line.Time, Instance: instance,
ClientIP: localhost, Method: http.MethodPost, Scheme: plain, ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
Host: appHost, Path: "/bare", Protocol: protocol, Host: appHost, Path: "/bare", Protocol: protocol,
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID, Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
PeerIP: localhost, ClientGroup: localhost + "/32", PeerIP: localhost, ClientGroup: localhost + "/32",
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward, Action: requestlog.ActionForward,
}) })
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" { if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want) t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
@@ -148,8 +144,7 @@ func wantBareLine(t *testing.T, line logLine) {
for _, name := range []string{ for _, name := range []string{
"forwarded_for", "content_type", "content_length", "request_headers", "forwarded_for", "content_type", "content_length", "request_headers",
"has_authorization", "has_cookie", "websocket", "response_content_type", "has_authorization", "has_cookie", "response_content_type", "counts",
"cache_control", "location", "counts",
} { } {
_, present := line.fields[name] _, present := line.fields[name]
if present { if present {
@@ -162,8 +157,6 @@ func wantBareLine(t *testing.T, line logLine) {
func withTimings(line logLine, want requestlog.Line) requestlog.Line { func withTimings(line logLine, want requestlog.Line) requestlog.Line {
want.DurationTotal = line.DurationTotal want.DurationTotal = line.DurationTotal
want.DurationChecks = line.DurationChecks want.DurationChecks = line.DurationChecks
want.DurationUpstreamConnect = line.DurationUpstreamConnect
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
want.DurationUpstreamTotal = line.DurationUpstreamTotal want.DurationUpstreamTotal = line.DurationUpstreamTotal
return want return want
@@ -188,7 +181,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
req.Header.Set(tc.header, "secret") req.Header.Set(tc.header, "secret")
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
line := out.requestLine(t) line := out.endedLine(t)
_, otherPresent := line.fields[tc.other] _, otherPresent := line.fields[tc.other]
if line.fields[tc.field] != true || otherPresent { if line.fields[tc.field] != true || otherPresent {
@@ -199,81 +192,20 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
} }
} }
func TestRequestIDAndSchemeComeOnlyFromATrustedProxy(t *testing.T) {
t.Parallel()
const sentID = "from-traefik"
sent := http.Header{requestIDHeader: {sentID}, forwardedProto: {secure}}
trusted := map[string]string{trustedProxies: trustLocalhost}
for _, tc := range []struct {
name string
env map[string]string
header http.Header
// wantID is the request id logged, "" for a new one.
wantID, wantScheme string
}{
{"a trusted proxy's are kept", trusted, sent, sentID, secure},
{"without them, the id is new and the scheme http", trusted, nil, "", plain},
{"another peer's are replaced", nil, sent, "", plain},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
received := make(chan string, 2)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
received <- r.Header.Get(requestIDHeader)
})
addr, out := startProxy(t, app.URL, tc.env)
// Two requests, so that two new ids can be told apart.
ids := make([]string, 0, 2)
for i := range 2 {
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
maps.Copy(req.Header, tc.header)
wantStatus(t, do(t, req), http.StatusOK)
line := out.requestLines(t, i+1)[i]
ids = append(ids, line.RequestID)
got := <-received
if line.RequestID != got || line.Scheme != tc.wantScheme {
t.Errorf("log line has request_id %q and scheme %q, and the "+
"app received id %q; want the same id and scheme %q",
line.RequestID, line.Scheme, got, tc.wantScheme)
}
}
switch {
case tc.wantID != "" && (ids[0] != tc.wantID || ids[1] != tc.wantID):
t.Errorf("request ids %q, want %q", ids, tc.wantID)
case tc.wantID == "" && (slices.Contains(ids, sentID) ||
slices.Contains(ids, "") || ids[0] == ids[1]):
t.Errorf("request ids %q, want two new ones", ids)
}
})
}
}
func TestTimingsAreInOrder(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWWAF_DENY_NETS
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
// The pauses set the times apart; a hold-up of the test only // The pause sets the times apart; a hold-up of the test only lengthens
// lengthens them. // it.
time.Sleep(time.Millisecond)
w.WriteHeader(http.StatusOK)
_ = http.NewResponseController(w).Flush()
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
_, _ = io.WriteString(w, "done") _, _ = io.WriteString(w, "done")
}) })
addr, out := startProxy(t, app.URL, map[string]string{
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
denyNets: denied, denyNets: denied,
}) })
@@ -281,20 +213,19 @@ func TestTimingsAreInOrder(t *testing.T) {
// Each log line is waited for before the next request, so that the // Each log line is waited for before the next request, so that the
// lines are in order. // lines are in order.
wantStatus(t, get(t, addr, "/"), http.StatusOK) wantStatus(t, get(t, addr, "/"), http.StatusOK)
forwarded := out.requestLines(t, 1)[0] forwarded := out.endedLines(t, 1)[0]
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, denied) req.Header.Set(forwardedFor, denied)
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
refused := out.requestLines(t, 2)[1] refused := out.endedLines(t, 2)[1]
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK) wantStatus(t, get(t, server.Addr, proxy.HealthPath), http.StatusOK)
health := out.requestLines(t, 3)[2] health := out.endedLines(t, 3)[2]
// A request passed to the app has every timing; one refused, none of // A request passed to the app has every timing; one refused, none of
// the app's; the health check, which runs no check, only the total. // the app's; the health check, which runs no check, only the total.
wantTimings(t, forwarded, "duration_total", "duration_checks", wantTimings(t, forwarded, "duration_total", "duration_checks",
"duration_upstream_connect", "duration_upstream_first_byte",
"duration_upstream_total") "duration_upstream_total")
wantTimings(t, refused, "duration_total", "duration_checks") wantTimings(t, refused, "duration_total", "duration_checks")
wantTimings(t, health, "duration_total") wantTimings(t, health, "duration_total")
@@ -307,17 +238,12 @@ func TestTimingsAreInOrder(t *testing.T) {
// exact. // exact.
total := microseconds(forwarded.DurationTotal) total := microseconds(forwarded.DurationTotal)
checks := microseconds(*forwarded.DurationChecks) checks := microseconds(*forwarded.DurationChecks)
connect := microseconds(*forwarded.DurationUpstreamConnect)
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
upstream := microseconds(*forwarded.DurationUpstreamTotal) upstream := microseconds(*forwarded.DurationUpstreamTotal)
// The checks end before the request is handed to the app, and the // The checks end before the request is handed to the app.
// connection comes before the answer, which the app ends after a if checks+upstream > total {
// pause. t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d",
if checks+upstream > total || connect >= firstByte || firstByte >= upstream { total, checks, upstream)
t.Errorf("timings in microseconds: total %d, checks %d, connect %d, "+
"first byte %d, upstream total %d", total, checks, connect, firstByte,
upstream)
} }
if *refused.DurationChecks > refused.DurationTotal { if *refused.DurationChecks > refused.DurationTotal {
@@ -350,22 +276,3 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
func microseconds(milliseconds float64) int64 { func microseconds(milliseconds float64) int64 {
return int64(math.Round(milliseconds * 1000)) return int64(math.Round(milliseconds * 1000))
} }
func TestLogsAnUpgradedConnection(t *testing.T) {
t.Parallel()
app := startApp(t, echoAfterUpgrade)
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
_ = conn.Close()
line := out.requestLine(t)
if line.fields["websocket"] != true {
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
}
}
+1 -3
View File
@@ -185,8 +185,6 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) { func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
metricsToken: token, metricsToken: token,
@@ -196,7 +194,7 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionBanned)
metrics := s.scrape(scraper) metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1) `smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
+37 -55
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/netip" "net/netip"
"strconv" "strconv"
@@ -13,8 +14,6 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -105,45 +104,38 @@ type Report struct {
} }
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers // NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
// rproxy's questions with the checks New runs, takes rproxy's reports and // rproxy's questions, takes rproxy's reports and answers its own endpoints. It
// answers its own endpoints. It passes nothing to an app. rproxy applies // passes nothing to an app. rproxy applies the size and time limits to the
// the size and time limits to the visitor, so their settings do not apply // visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
// here. Go's default limit on a request's line and headers, 1 MiB, stays: // is far above rproxy's, and a question refused for its size would have rproxy
// it is far above rproxy's, and a question refused for its size would have // let the request through.
// rproxy let the request through.
func NewServeWAF(params Params) *Server { func NewServeWAF(params Params) *Server {
cfg := *params.Config
cfg.RequestMaxBytes = 0
cfg.ClientRequestTimeout = serveWAFTimeout
// The server's WriteTimeout does this. It sets a new deadline for each
// request, where a deadline a handler sets stays on a kept-open
// connection, and would run out under a later request's answer.
cfg.ClientResponseTimeout = 0
// A question's body of QuestionBodyMaxBytes may be cut short, and the
// Core Rule Set does not read a JSON or XML body over its limit.
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
params.Config = &cfg
h := newHandler(params) h := newHandler(params)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport) return &Server{
if err != nil { Server: &http.Server{
panic(err) // New fails only for a size below one Addr: params.Config.ListenAddr,
} Handler: h,
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
h.awaitingReport = awaitingReport
return h.server(&http.Server{
Handler: http.HandlerFunc(h.serveWAF),
ReadTimeout: serveWAFTimeout, ReadTimeout: serveWAFTimeout,
WriteTimeout: serveWAFTimeout, WriteTimeout: serveWAFTimeout,
IdleTimeout: serveWAFIdleTimeout, IdleTimeout: serveWAFIdleTimeout,
}) },
Ledger: h.ledger,
Limiter: h.limiter,
GeoJS: h.geojs,
Anomalies: h.anomalies,
LookupFile: h.lookupFile,
Lists: h.lists,
DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
Metrics: h.metrics,
}
} }
// serveWAF handles one request to servewaf: a request carrying // ServeHTTP handles one request to servewaf: a request carrying ClientHeader is
// ClientHeader is rproxy's question, and any other is for servewaf itself. // rproxy's question, and any other is for servewaf itself.
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 { if len(r.Header.Values(ClientHeader)) > 0 {
h.answerQuestion(w, r) h.answerQuestion(w, r)
@@ -153,12 +145,6 @@ func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
h.answerOwnEndpoint(w, r) h.answerOwnEndpoint(w, r)
} }
// answersQuestions reports whether h is servewaf, which alone holds requests
// for their reports.
func (h *handler) answersQuestions() bool {
return h.awaitingReport != nil
}
// 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
@@ -188,10 +174,6 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
// characters, of which a header value can hold only tabs, so a part of Unicode // characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it. // spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool { func (rq *request) overHeaderLimits() bool {
if !rq.h.answersQuestions() {
return false
}
fields, cookies := 0, 0 fields, cookies := 0, 0
for _, values := range rq.in.Header { for _, values := range rq.in.Header {
@@ -244,7 +226,8 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return return
} }
// The last deferred runs first, as in ServeHTTP. // The last deferred runs first: countRefusal before addToHistory, so that a
// broken error burst is in the client's history.
defer rq.addToHistory() defer rq.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.countRefusal() defer rq.countRefusal()
@@ -374,10 +357,11 @@ func (rq *request) holdForReport() {
// deny answers a question with what the visitor is to get for r. // deny answers a question with what the visitor is to get for r.
func (rq *request) deny(r refusal) { func (rq *request) deny(r refusal) {
rq.refused = &r
if r.status == 0 { if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's // SWWAF_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.refused.CompareAndSwap(nil, &r)
rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK) rq.out.ResponseWriter.WriteHeader(http.StatusOK)
@@ -385,7 +369,7 @@ func (rq *request) deny(r refusal) {
} }
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
rq.answer(r) http.Error(rq.out, http.StatusText(r.status), r.status)
} }
// answerOwnEndpoint answers a request to servewaf's own address. No check // answerOwnEndpoint answers a request to servewaf's own address. No check
@@ -425,7 +409,8 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
} }
held, found := h.awaitingReport.Peek(report.RequestID) held, found := h.awaitingReport.Peek(report.RequestID)
// Remove reports true to one report only, of several that name the id. // Remove reports true to one report only, of several that name the id. A
// question still being handled is held as nil.
if found && held != nil && h.awaitingReport.Remove(report.RequestID) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(held, report) h.finishWith(held, report)
} }
@@ -490,18 +475,17 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the // finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's. // request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first takes the steps ServeHTTP takes once a // For an allowed request it first counts the request's bytes, and adds it to its
// request passed to the app has ended, with the status and bytes of the report // client's history, the anomaly counters and the metrics, with the status and
// in place of those it would have seen. A denied request's counting was // bytes the report gives. A denied request's counting was finished at its
// finished at its decision. // decision.
func (h *handler) finishWith(held *decision, report Report) { func (h *handler) finishWith(held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes}, out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
body: &requestBody{}, requestBytes: report.RequestBytes,
} }
rq.body.bytes.Store(report.RequestBytes)
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport rq.line.ServeWAFLine = requestlog.ServeWAFLineReport
rq.line.Status = report.Status rq.line.Status = report.Status
@@ -511,8 +495,6 @@ func (h *handler) finishWith(held *decision, report Report) {
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
// addToHistory goes by this for whether the request reached the app.
rq.upstreamStart = time.Now()
rq.countBytes() rq.countBytes()
rq.countAnomalies() rq.countAnomalies()
+19 -52
View File
@@ -84,26 +84,12 @@ func startServeWAFWith(
} }
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, queue := newServerWith(t, proxy.NewServeWAF, server, out, queue := newServeWAF(t, geojsURL, clk.Now, settings)
"http://127.0.0.1:8081", geojsURL, clk.Now, settings)
change(server) change(server)
serve(t, server)
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
})
return &serveWAF{ return &serveWAF{
t: t, addr: listener.Addr().String(), out: out, server: server, t: t, addr: server.Addr, out: out, server: server, clock: clk, queue: queue,
clock: clk, queue: queue,
} }
} }
@@ -626,13 +612,10 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
wantProcessMessages(t, s, "a question's body stopped arriving", 1) wantProcessMessages(t, s, "a question's body stopped arriving", 1)
wantNoRequestLines(t, s) wantNoRequestLines(t, s)
// The id was not taken, and the metrics count nothing for it. // The id was not taken.
again := s.question(siteA, client, "/") again := s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, "an-id") again.Header.Set(requestIDHeader, "an-id")
wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow)
wantNoSeries(t, s.metrics(),
`smallwebwaf_size_and_time_limit_hits_total{instance="`+alertInstance+
`",limit="SWWAF_CLIENT_REQUEST_TIMEOUT"}`)
}) })
} }
} }
@@ -1466,16 +1449,10 @@ func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) {
} }
} }
func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) { func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{ s := startServeWAF(t, "", nil)
requestMaxBytes: "1",
clientHeaderMaxBytes: "5K",
clientRequestTimeout: off,
clientResponseTimeout: off,
trustedProxies: localhost,
})
req := s.questionWith(http.MethodPost, siteA, client, "/", req := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader("a body")) strings.NewReader("a body"))
@@ -1493,33 +1470,23 @@ func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
} }
} }
func TestShortClientResponseTimeoutIsIgnored(t *testing.T) { func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
t.Parallel() t.Parallel()
// The setting would leave no time to send any answer. s := startServeWAF(t, "", map[string]string{"SWWAF_LOG_LEVEL": "error"})
s := startServeWAF(t, "", map[string]string{
clientResponseTimeout: time.Nanosecond.String(), denyNets: client,
})
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny) // A refused question would write a process warning, held back at error.
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK) req := s.question(siteA, client, "/")
} req.Header.Del(requestIDHeader)
wantStatus(t, do(t, req), http.StatusBadRequest)
func TestShortClientRequestTimeoutIsIgnored(t *testing.T) { s.allowed(client)
t.Parallel() wantLine(t, s.out.requestLine(t), http.StatusOK, requestlog.ActionForward)
// The setting would have run out while the Core Rule Set read the body. Go's for _, line := range s.out.lines(t) {
// server would then take the connection for gone, and the line say aborted. if line["type"] == "process" {
s := startServeWAF(t, "", map[string]string{ t.Errorf("process line %v, want none at error", line)
clientRequestTimeout: time.Nanosecond.String(), wafMode: block, wafBodyLimit: "1M", }
})
req := s.questionWith(http.MethodPost, siteA, client, "/", strings.NewReader("a=1"))
req.Header.Set("Content-Type", formData)
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
if line := s.out.requestLine(t); line.Aborted {
t.Errorf("log line says aborted: %+v", line.Line)
} }
} }
@@ -1573,7 +1540,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{ s := startServeWAF(t, "", map[string]string{
wafMode: block, wafBodyLimit: "1M", wafMode: block, wafBodyLimit: "64K",
}) })
ask := func(contentType, body string) answer { ask := func(contentType, body string) answer {
+4 -17
View File
@@ -17,7 +17,7 @@ const (
unlistedAddr = "2001:db8::2" unlistedAddr = "2001:db8::2"
) )
func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) { func TestAllowNetsSkipEveryCheck(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 var calls atomic.Int32
@@ -34,7 +34,6 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
denyNets: fromKP, denyNets: fromKP,
deniedCountries: "kp", deniedCountries: "kp",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
requestMaxBytes: "1K",
}) })
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one // Neither SWWAF_DENY_NETS, the country lists nor the limit of one
@@ -49,14 +48,6 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
t.Errorf("GeoJS was asked about %v, want nothing", asked()) t.Errorf("GeoJS was asked about %v, want nothing", asked())
} }
// The size limit still applies.
body := strings.NewReader(strings.Repeat("a", 2<<10))
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set(forwardedFor, fromKP)
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
wantLine(t, out.requestLines(t, 3)[2],
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
if calls.Load() != 2 { if calls.Load() != 2 {
t.Errorf("the app was called %d times, want 2", calls.Load()) t.Errorf("the app was called %d times, want 2", calls.Load())
} }
@@ -82,7 +73,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
}) })
} }
func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) { func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 var calls atomic.Int32
@@ -101,13 +92,9 @@ func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
line := out.requestLine(t) line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied) wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
if line.RequestBytes != 0 {
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
}
if len(asked()) != 0 { if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked()) t.Errorf("GeoJS was asked about %v, want nothing", asked())
} }
@@ -180,6 +167,6 @@ func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, sent.client) req.Header.Set(forwardedFor, sent.client)
wantStatus(t, do(t, req), sent.status) wantStatus(t, do(t, req), sent.status)
wantLine(t, out.requestLines(t, i+1)[i], sent.status, sent.action) wantLine(t, out.endedLines(t, i+1)[i], sent.status, sent.action)
} }
} }
-453
View File
@@ -1,453 +0,0 @@
package proxy_test
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// largeBodySize is more than the connections between the client,
// smallwebwaf and the app can hold while nobody reads, so that a sender
// soon waits.
const largeBodySize = 64 << 20
// writeSize is how much a test sender writes at a time.
const writeSize = 32 << 10
func TestRequestTimeouts(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// limit is the setting set to shortTimeout, which runs out; long
// is one set to longTimeoutSetting, which does not, or "".
limit, long string
// appTakesNothing has the app never read, while the client sends
// as fast as it can; otherwise the app reads, and the client
// stops sending halfway.
appTakesNothing bool
want int
}{
{
name: "client request timeout, waiting on the client",
limit: clientRequestTimeout,
want: http.StatusRequestTimeout,
},
{
name: "upstream request timeout, waiting on the client",
limit: upstreamRequestTimeout,
long: clientRequestTimeout,
want: http.StatusRequestTimeout,
},
{
name: "upstream request timeout, waiting on the app",
limit: upstreamRequestTimeout,
appTakesNothing: true,
want: http.StatusGatewayTimeout,
},
{
name: "client request timeout, waiting on the app",
limit: clientRequestTimeout,
long: upstreamRequestTimeout,
appTakesNothing: true,
want: http.StatusGatewayTimeout,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
app *httptest.Server
appURL string
sendRequest func(*testing.T, string) net.Conn
appGotBody atomic.Bool
)
if tc.appTakesNothing {
appURL, sendRequest = startAppThatTakesNothing(t), sendLargeBody
} else {
app = startApp(t, func(_ http.ResponseWriter, r *http.Request) {
n, _ := io.Copy(io.Discard, r.Body)
appGotBody.Store(n > 0)
})
appURL, sendRequest = app.URL, sendPartOfBody
}
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
if tc.long != "" {
env[tc.long] = longTimeoutSetting
}
addr, out := startProxy(t, appURL, env)
start := time.Now()
got := readResponse(t, sendRequest(t, addr))
wantTimedOut(t, start)
want := tc.want
if app != nil {
// Close returns once the app has finished with the request.
app.Close()
// Until some of the body has reached the app, smallwebwaf
// waits on the app, and SPEC.md asks for 504; the timeout
// runs out then only if the test process is held up.
if !appGotBody.Load() {
want = http.StatusGatewayTimeout
}
}
wantStatus(t, got, want)
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
wantLimitHits(t, addr, tc.limit, 1)
})
}
}
// startAppThatTakesNothing starts an app that accepts connections and
// never reads from them, and returns its URL.
func startAppThatTakesNothing(t *testing.T) string {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var (
mu sync.Mutex
held []net.Conn
)
hold := func(conn net.Conn) {
mu.Lock()
defer mu.Unlock()
held = append(held, conn)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
hold(conn)
}
}()
t.Cleanup(func() {
_ = listener.Close()
mu.Lock()
defer mu.Unlock()
for _, conn := range held {
_ = conn.Close()
}
})
return "http://" + listener.Addr().String()
}
// sendPartOfBody sends a request that announces a large body, and only
// the first bytes of it.
func sendPartOfBody(t *testing.T, addr string) net.Conn {
t.Helper()
conn := dial(t, addr)
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
strconv.Itoa(largeBodySize)+"\r\n\r\nthe first bytes")
return conn
}
// sendLargeBody sends a request with a large body, as fast as smallwebwaf
// takes it, from a goroutine of its own.
func sendLargeBody(t *testing.T, addr string) net.Conn {
t.Helper()
conn := dial(t, addr)
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
strconv.Itoa(largeBodySize)+"\r\n\r\n")
go func() {
chunk := make([]byte, writeSize)
for range largeBodySize / writeSize {
_, err := conn.Write(chunk)
if err != nil {
return
}
}
}()
return conn
}
func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
t.Parallel()
// answeredAt is when the app answered, or nil if the bytes the client
// sent never reached it.
var answeredAt atomic.Pointer[time.Time]
// The app answers without reading the whole body, as an app does for
// a path it does not serve, and only once smallwebwaf waits for the
// rest of it: the app takes the bytes the client sent, and then waits
// half the timeout, far longer than smallwebwaf takes to go on to
// wait.
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadFull(r.Body, make([]byte, len("the first bytes")))
if err != nil {
return
}
time.Sleep(shortTimeout / 2)
answeredAt.Store(new(time.Now()))
http.NotFound(w, r)
})
addr, out := startProxy(t, app.URL, map[string]string{
clientRequestTimeout: shortTimeoutSetting,
metricsToken: token,
})
start := time.Now()
got := readResponse(t, sendPartOfBody(t, addr))
wantTimedOut(t, start)
// Close returns once the app has finished with the request.
app.Close()
// The app's answer came before the time ran out, so the client is
// sent that answer, not smallwebwaf's 408. Only a hold-up of the test
// process can make it come later, and SPEC.md then asks for 408, as
// smallwebwaf was waiting for the client, or for 504 if the bytes the
// client sent had not reached the app.
at := answeredAt.Load()
want := http.StatusNotFound
switch {
case at == nil:
want = http.StatusGatewayTimeout
case at.Sub(start) >= shortTimeout:
want = http.StatusRequestTimeout
}
wantStatus(t, got, want)
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
wantLimitHits(t, addr, clientRequestTimeout, 1)
}
func TestClientStopsSendingWithLittleOfItsBodyLeft(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// limit is the setting set to shortTimeout, which runs out, and
// clientTimeout what SWWAF_CLIENT_REQUEST_TIMEOUT is set to when it
// is not limit.
limit, clientTimeout string
}{
{
name: "client request timeout",
limit: clientRequestTimeout,
},
{
name: "upstream request timeout, client request timeout off",
limit: upstreamRequestTimeout,
clientTimeout: off,
},
{
name: "upstream request timeout, client request timeout longer",
limit: upstreamRequestTimeout,
clientTimeout: longTimeoutSetting,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// appClosed is closed once smallwebwaf has closed its
// connection to the app, which it does once it has the app's
// answer.
appClosed := make(chan struct{})
// The app answers without reading the rest of the body, and
// says it closes the connection, only once smallwebwaf waits
// for the client to send more: it takes the bytes the client
// sent, and then waits half the timeout, far longer than
// smallwebwaf takes to go on to wait.
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buf, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
_, _ = io.ReadFull(buf.Reader, make([]byte, len("the first bytes")))
time.Sleep(shortTimeout / 2)
_, _ = buf.WriteString("HTTP/1.1 404 Not Found\r\n" +
"Content-Length: 0\r\nConnection: close\r\n\r\n")
_ = buf.Flush()
_, _ = io.Copy(io.Discard, buf.Reader)
_ = conn.Close()
close(appClosed)
})
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
if tc.clientTimeout != "" {
env[clientRequestTimeout] = tc.clientTimeout
}
addr, out := startProxy(t, app.URL, env)
// Less than 256 KiB of the body is left after the first bytes,
// little enough for Go's server in smallwebwaf to read the rest
// itself before it sends the app's answer.
start := time.Now()
conn := dial(t, addr)
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: 200000\r\n\r\n"+
"the first bytes")
select {
case <-appClosed:
case <-time.After(waitLimit):
t.Fatal("smallwebwaf did not close its connection to the app")
}
// More of the body, which can no longer go to the app, and then
// nothing.
send(t, conn, strings.Repeat("x", 8<<10))
got := readResponse(t, conn)
wantTimedOut(t, start)
wantStatus(t, got, http.StatusNotFound)
wantLine(t, out.requestLine(t), http.StatusNotFound, requestlog.ActionTimedOut)
wantLimitHits(t, addr, tc.limit, 1)
})
}
}
func TestAppTooSlowToAnswer(t *testing.T) {
t.Parallel()
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
addr, out := startProxy(t, app.URL, map[string]string{
upstreamResponseTimeout: shortTimeoutSetting,
metricsToken: token,
})
start := time.Now()
wantStatus(t, get(t, addr, "/slow"), http.StatusGatewayTimeout)
wantTimedOut(t, start)
line := out.requestLine(t)
wantLine(t, line, http.StatusGatewayTimeout, requestlog.ActionTimedOut)
_, answered := line.fields["upstream_status"]
if answered {
t.Errorf("log line has upstream_status %v for an app that never answered",
line.fields["upstream_status"])
}
wantLimitHits(t, addr, upstreamResponseTimeout, 1)
}
func TestAppTooSlowToFinishItsAnswer(t *testing.T) {
t.Parallel()
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "the first part")
_ = http.NewResponseController(w).Flush()
<-r.Context().Done()
})
addr, out := startProxy(t, app.URL, map[string]string{
upstreamResponseTimeout: shortTimeoutSetting,
})
start := time.Now()
got := get(t, addr, "/slow")
wantStatus(t, got, http.StatusOK)
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
}
wantTimedOut(t, start)
line := out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
if line.UpstreamStatus != http.StatusOK {
t.Errorf("log line has upstream_status %d, want %d",
line.UpstreamStatus, http.StatusOK)
}
}
func TestClientTooSlowToTakeTheAnswer(t *testing.T) {
t.Parallel()
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
chunk := make([]byte, writeSize)
for range largeBodySize / writeSize {
_, err := w.Write(chunk)
if err != nil {
return
}
}
})
addr, out := startProxy(t, app.URL, map[string]string{
clientResponseTimeout: shortTimeoutSetting,
metricsToken: token,
})
start := time.Now()
// The client asks, and never reads the answer.
conn := dial(t, addr)
send(t, conn, "GET /large HTTP/1.1\r\nHost: app\r\n\r\n")
line := out.requestLine(t)
wantTimedOut(t, start)
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
wantLimitHits(t, addr, clientResponseTimeout, 1)
}
func TestClosesAnIdleConnection(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _ := startProxy(t, app.URL, map[string]string{
clientIdleTimeout: shortTimeoutSetting,
})
// The idle time starts once the answer is sent, so after start.
start := time.Now()
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: app\r\n\r\n")
wantStatus(t, readResponse(t, conn), http.StatusOK)
// The read deadline readResponse set still bounds this read.
_, err := conn.Read(make([]byte, 1))
if !errors.Is(err, io.EOF) {
t.Fatalf("read on the idle connection: %v, want it closed", err)
}
wantTimedOut(t, start)
}
+2 -2
View File
@@ -23,8 +23,8 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
{Forwarded: true, Status: 502, ResponseBytes: 12}, {Forwarded: true, Status: 502, ResponseBytes: 12},
// Closed without an answer: refused, and no response. // Closed without an answer: refused, and no response.
{Refused: true, Status: 0}, {Refused: true, Status: 0},
// Answered 404 at smallwebwaf's own endpoints: neither forwarded // Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
// nor refused. // refused.
{Status: 404}, {Status: 404},
} { } {
limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r) limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
+11 -19
View File
@@ -92,10 +92,9 @@ type History struct {
ASName string `json:"as_name,omitempty"` ASName string `json:"as_name,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
LookedUp time.Time `json:"looked_up,omitzero"` LookedUp time.Time `json:"looked_up,omitzero"`
// Requests are all the client's requests: Forwarded those passed to // Requests are all the client's requests: Forwarded those servewaf allowed,
// the app, Refused those refused before anything reached it, a 401 at // Refused those it refused, and neither those for a path under
// smallwebwaf's own endpoints included, and neither the others // /_smallwebwaf/.
// smallwebwaf answered there.
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Forwarded int64 `json:"forwarded"` Forwarded int64 `json:"forwarded"`
Refused int64 `json:"refused"` Refused int64 `json:"refused"`
@@ -124,21 +123,20 @@ type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the // Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match // error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block // of a ban rule or a request for a trap path, RuleBlocked those a block
// rule refused, WAFBlocked those the Core Rule Set refused, and // rule refused, and WAFBlocked those the Core Rule Set refused.
// TokenRefused those refused for a missing or wrong token.
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
Attack int64 `json:"attack"` Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"` RuleBlocked int64 `json:"rule_blocked"`
WAFBlocked int64 `json:"waf_blocked"` WAFBlocked int64 `json:"waf_blocked"`
TokenRefused int64 `json:"token_refused"` // TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"`
} }
// Request is what a client's history keeps of one of its requests. // Request is what a client's history keeps of one of its requests.
type Request struct { type Request struct {
// Forwarded is true for a request passed to the app, Refused for one // Forwarded is true for a request servewaf allowed, and Refused for one it
// refused before anything reached it, a 401 at smallwebwaf's own // refused. Both are false for a request for a path under /_smallwebwaf/.
// endpoints included. Both are false for any other request smallwebwaf
// answered there.
Forwarded bool Forwarded bool
Refused bool Refused bool
// Status is what the client was sent, 0 if nothing was. // Status is what the client was sent, 0 if nothing was.
@@ -149,14 +147,12 @@ type Request struct {
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte // BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or // limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, // asked for a trap path, RuleBlocked for one a block rule refused, and
// WAFBlocked for one the Core Rule Set refused, and TokenRefused for // WAFBlocked for one the Core Rule Set refused.
// one refused for a missing or wrong token.
BrokeLimit bool BrokeLimit bool
Attack bool Attack bool
RuleBlocked bool RuleBlocked bool
WAFBlocked bool WAFBlocked bool
TokenRefused bool
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
@@ -334,10 +330,6 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
if r.WAFBlocked { if r.WAFBlocked {
h.Offences.WAFBlocked++ h.Offences.WAFBlocked++
} }
if r.TokenRefused {
h.Offences.TokenRefused++
}
} }
// AddLookup gives client's history its AS number, AS name and country, as // AddLookup gives client's history its AS number, AS name and country, as
+15 -32
View File
@@ -14,19 +14,13 @@ import (
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
) )
// The action a request line names: what smallwebwaf did with the // The action a request line names: what servewaf did with the request.
// request.
const ( const (
// ActionForward is a request passed to the app. // ActionForward is a request servewaf allowed, which rproxy passes to the
// app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request or response over its size limit, or, in // ActionTooLarge is a request over the header limits.
// servewaf, a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionTimedOut is a request or response that ran out of time.
ActionTimedOut = "timed_out"
// ActionUpstreamError is a request the app could not be reached
// for, or whose answer could not be passed on.
ActionUpstreamError = "upstream_error"
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client. // over a rate limit, which bans the client.
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
@@ -48,8 +42,8 @@ const (
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"
// ActionAdmin is a request smallwebwaf answered at one of its own // ActionAdmin is a request servewaf answered at one of its own endpoints,
// endpoints, under /_smallwebwaf/. // or a question about a path under /_smallwebwaf/.
ActionAdmin = "admin" ActionAdmin = "admin"
) )
@@ -86,8 +80,8 @@ type Line struct {
// two lines about a question. // two lines about a question.
ServeWAFLine string `json:"servewaf_line,omitempty"` ServeWAFLine string `json:"servewaf_line,omitempty"`
// The standard web log fields. Scheme is how the client reached // The standard web log fields. Scheme is how the client reached rproxy, or
// smallwebwaf, or the trusted proxy in front of it. // the trusted proxy in front of it.
Time string `json:"time"` Time string `json:"time"`
Instance string `json:"instance"` Instance string `json:"instance"`
ClientIP string `json:"client_ip"` ClientIP string `json:"client_ip"`
@@ -103,8 +97,8 @@ type Line struct {
Referer string `json:"referer"` Referer string `json:"referer"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
// Request detail. RequestID is the X-Request-ID a trusted proxy sent, // Request detail. RequestID is the id rproxy made for the request, or for a
// or a new one, and is sent on to the app. ForwardedFor is the // request to servewaf's own endpoints a new one. ForwardedFor is the
// X-Forwarded-For header as received. ClientGroup is the netblock the // X-Forwarded-For header as received. ClientGroup is the netblock the
// client is counted as. ASN, ASName and Country are the client's AS // client is counted as. ASN, ASName and Country are the client's AS
// number, AS name and country, as looked up. // number, AS name and country, as looked up.
@@ -123,17 +117,10 @@ type Line struct {
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"`
HasCookie bool `json:"has_cookie,omitempty"` HasCookie bool `json:"has_cookie,omitempty"`
// Websocket is true when the connection was upgraded, as for a
// WebSocket.
Websocket bool `json:"websocket,omitempty"`
// Response detail, from the headers of the answer: the app's, as // Response detail, of servewaf's own answer. Aborted is true when rproxy, or
// passed on, or those of smallwebwaf's own. Aborted is true when the // the admin's tool, went away before the answer.
// client went away early.
ResponseContentType string `json:"response_content_type,omitempty"` ResponseContentType string `json:"response_content_type,omitempty"`
UpstreamStatus int `json:"upstream_status,omitempty"`
CacheControl string `json:"cache_control,omitempty"`
Location string `json:"location,omitempty"`
Aborted bool `json:"aborted,omitempty"` Aborted bool `json:"aborted,omitempty"`
// The decision. // The decision.
@@ -183,16 +170,12 @@ type Line struct {
// The timings, in milliseconds. DurationChecks is the time until the // The timings, in milliseconds. DurationChecks is the time until the
// checks were done, and DurationWAF the part of it the Core Rule Set // checks were done, and DurationWAF the part of it the Core Rule Set
// took. DurationUpstreamConnect, DurationUpstreamFirstByte and // took. DurationUpstreamTotal is what the report of an allowed request gives,
// DurationUpstreamTotal run from when the request was handed to the // from the hand-over to the app until the end of its answer. Each but
// app: until there was a connection to it, until the first byte of its // DurationTotal is nil for a request that did not get that far.
// answer arrived, and until the end. Each but DurationTotal is nil for
// a request that did not get that far.
DurationTotal float64 `json:"duration_total"` DurationTotal float64 `json:"duration_total"`
DurationChecks *float64 `json:"duration_checks,omitempty"` DurationChecks *float64 `json:"duration_checks,omitempty"`
DurationWAF *float64 `json:"duration_waf,omitempty"` DurationWAF *float64 `json:"duration_waf,omitempty"`
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"` DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
} }
+27 -7
View File
@@ -1,7 +1,9 @@
package rproxy_test package rproxy_test
import ( import (
"bufio"
"bytes" "bytes"
"errors"
"io" "io"
"log/slog" "log/slog"
"net" "net"
@@ -602,10 +604,12 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) { func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Parallel() t.Parallel()
const start = "the start"
release := make(chan struct{}) release := make(chan struct{})
servewaf := startServeWAF(t, allow) servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "the start") _, _ = io.WriteString(w, start)
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
<-release <-release
@@ -613,17 +617,29 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Cleanup(func() { close(release) }) t.Cleanup(func() { close(release) })
// The time limit is long enough for a loaded host to pass on the start of the
// answer before it runs out.
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: "300", maxResponseTime: "3000",
}) })
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
// dial's deadline ends this read with an error if the connection stays open. res, err := http.ReadResponse(bufio.NewReader(conn), nil)
_, err := io.ReadAll(conn)
if err != nil { if err != nil {
t.Errorf("read: %v, want the connection closed", err) t.Fatalf("read the answer: %v", err)
}
// The body breaks off when rproxy closes the connection. If the connection
// stays open, dial's deadline ends this read with a timeout instead.
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if res.StatusCode != http.StatusOK || string(body) != start ||
!errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
res.StatusCode, body, err, start)
} }
if report := servewaf.report(t); report.Status != http.StatusOK { if report := servewaf.report(t); report.Status != http.StatusOK {
@@ -651,8 +667,10 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
} }
} }
}) })
// The time limit is long enough for a loaded host to start passing on the
// answer before it runs out.
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: "500", maxResponseTime: "3000",
}) })
// The visitor asks, and never reads the answer. // The visitor asks, and never reads the answer.
@@ -793,8 +811,10 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
servewaf := startServeWAF(t, deny) servewaf := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
// The time limit is long enough for a loaded host to read the first part of
// the body before it runs out.
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "500", maxRequestTime: "3000",
}) })
// Go's server waits for a little of the body left before it sends an answer, // Go's server waits for a little of the body left before it sends an answer,
+46
View File
@@ -128,6 +128,52 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
} }
} }
func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
header []string
client string
}{
{
"the first address outside the trusted proxies, from the right",
[]string{"198.51.100.7, 203.0.113.9, 10.0.0.2"}, "203.0.113.9",
},
{
"the leftmost, when every address is a trusted proxy",
[]string{"10.0.0.5, 10.0.0.2"}, "10.0.0.5",
},
{"the peer, with no header", nil, localhost},
{
"the last address read, before an entry that is not an address",
[]string{"203.0.113.9, unknown, 10.0.0.2"}, "10.0.0.2",
},
{
"read as one list, with several header lines",
[]string{"2001:db8::7", "10.0.0.2"}, "2001:db8::7",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8",
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header[forwardedForHeader] = tc.header
wantGreeting(t, visit(t, req))
if got := servewaf.question(t).header.Get(proxy.ClientHeader); got != tc.client {
t.Errorf("the question names %s, want %s", got, tc.client)
}
})
}
}
// randomBytes returns n random bytes. // randomBytes returns n random bytes.
func randomBytes(n int) []byte { func randomBytes(n int) []byte {
body := make([]byte, n) body := make([]byte, n)
+3 -1
View File
@@ -1353,8 +1353,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
}) })
app := startApp(t, greet) app := startApp(t, greet)
// The time limit is long enough for a loaded host to answer the question before
// it runs out.
addr, out := startRProxy(t, backend, app.URL, map[string]string{ addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "1000", timeoutSetting: "3000",
}) })
t.Cleanup(func() { close(release) }) t.Cleanup(func() { close(release) })
+62 -21
View File
@@ -17,13 +17,16 @@ import (
// healthCheckTimeout bounds the whole health check. // healthCheckTimeout bounds the whole health check.
const healthCheckTimeout = 5 * time.Second const healthCheckTimeout = 5 * time.Second
var errHealthEndpoint = errors.New("smallwebwaf's health endpoint answered") var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. It returns 0 while // HealthCheck is the container's health check. In servewaf's container, where
// smallwebwaf answers its health endpoint on 127.0.0.1, at the port in // SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health
// SWWAF_LISTEN_ADDR, and the app accepts connections at the address in // endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container,
// SWWAF_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. // where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// It reads no other setting, nor a file that another names, so neither // answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
// any other value of SMALLWEBWAF_COMMAND included, it writes why to stderr and
// 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.
@@ -38,7 +41,20 @@ func HealthCheck(
return 1 return 1
} }
err := healthCheck(ctx, lookupEnv) ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
command, err := config.Command(lookupEnv)
switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "servewaf":
err = checkServeWAF(ctx, lookupEnv)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
}
if err != nil { if err != nil {
_, _ = fmt.Fprintln(stderr, "unhealthy:", err) _, _ = fmt.Fprintln(stderr, "unhealthy:", err)
@@ -48,16 +64,48 @@ func HealthCheck(
return 0 return 0
} }
func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) error { // checkServeWAF asks servewaf's health endpoint.
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout) func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error {
defer cancel() listenAddr, err := config.ServeWAFListenAddr(lookupEnv)
listenAddr, upstreamURL, err := config.ListenAddrAndUpstreamURL(lookupEnv)
if err != nil { if err != nil {
return fmt.Errorf("invalid setting: %w", err) return fmt.Errorf("invalid setting: %w", err)
} }
// The settings have checked that the address has a port. err = askHealthEndpoint(ctx, listenAddr)
if err != nil {
return fmt.Errorf("ask servewaf: %w", err)
}
return nil
}
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
func checkRProxyAndApp(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
err = askHealthEndpoint(ctx, listenAddr)
if err != nil {
return fmt.Errorf("ask rproxy: %w", err)
}
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
if err != nil {
return fmt.Errorf("connect to the app: %w", err)
}
_ = conn.Close()
return nil
}
// askHealthEndpoint asks the health endpoint on 127.0.0.1 at the port of
// listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr) _, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath
@@ -68,7 +116,7 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
res, err := http.DefaultClient.Do(req) res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("ask smallwebwaf: %w", err) return fmt.Errorf("send the request: %w", err)
} }
_ = res.Body.Close() _ = res.Body.Close()
@@ -77,13 +125,6 @@ func healthCheck(ctx context.Context, lookupEnv func(string) (string, bool)) err
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status) return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
} }
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
if err != nil {
return fmt.Errorf("connect to the app: %w", err)
}
_ = conn.Close()
return nil return nil
} }
+80 -23
View File
@@ -15,11 +15,16 @@ import (
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
func TestHealthCheck(t *testing.T) { const (
t.Parallel() // commandSetting says which command a container runs, and which the health
// check checks.
commandSetting = "SMALLWEBWAF_COMMAND"
// serveWAFCommand is the command that runs servewaf.
serveWAFCommand = "servewaf"
)
app := httptest.NewServer(http.NotFoundHandler()) func TestHealthCheckInServeWAFsContainer(t *testing.T) {
defer app.Close() t.Parallel()
ctx, stop := context.WithCancel(t.Context()) ctx, stop := context.WithCancel(t.Context())
defer stop() defer stop()
@@ -28,7 +33,6 @@ func TestHealthCheck(t *testing.T) {
exited := make(chan int, 1) exited := make(chan int, 1)
settings := map[string]string{ settings := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: app.URL,
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
} }
@@ -39,40 +43,36 @@ func TestHealthCheck(t *testing.T) {
addr, _ := out.line(t, "msg", "starting")["address"].(string) addr, _ := out.line(t, "msg", "starting")["address"].(string)
_, port, _ := net.SplitHostPort(addr) _, port, _ := net.SplitHostPort(addr)
// The container's settings: an address to listen on with an empty // The container's settings: an address to listen on with an empty
// host part, which the health check asks at 127.0.0.1. // host part, which the health check asks at 127.0.0.1.
env := map[string]string{listenAddr: ":" + port, upstreamURL: app.URL} wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, listenAddr: ":" + port,
}, 0, "")
wantHealthCheck(t, env, 0, "") // The health check reads that setting alone, here given as a file: a removed
// or invalid token file, or an invalid value of another setting, does not
// The health check reads those two settings alone, here given as // fail it.
// files: a removed or invalid token file, or an invalid value of
// another setting, does not fail it.
for _, other := range []struct{ name, value string }{ for _, other := range []struct{ name, value string }{
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")}, {"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")}, {"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWWAF_MODE", "neither"}, {"SWWAF_MODE", "neither"},
} { } {
wantHealthCheck(t, map[string]string{ wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand,
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"), listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
upstreamURL + "_FILE": writeFile(t, app.URL+"\n"),
other.name: other.value, other.name: other.value,
}, 0, "") }, 0, "")
} }
// Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect.
wantHealthCheck(t, map[string]string{
listenAddr: ":" + port,
upstreamURL: "http://224.0.0.1",
}, 1, "unhealthy: connect to the app: ")
// Listening on port 0 takes any free port, so nothing listens on port 0 // Listening on port 0 takes any free port, so nothing listens on port 0
// itself. // itself.
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1, wantHealthCheck(t, map[string]string{
"unhealthy: ask smallwebwaf: ") commandSetting: serveWAFCommand, listenAddr: ":0",
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1, }, 1, "unhealthy: ask servewaf: ")
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ") wantHealthCheck(t, map[string]string{
commandSetting: serveWAFCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
stop() stop()
@@ -83,6 +83,63 @@ func TestHealthCheck(t *testing.T) {
} }
} }
func TestHealthCheckInRProxysContainer(t *testing.T) {
t.Parallel()
app := httptest.NewServer(http.NotFoundHandler())
defer app.Close()
// Nothing listens at the backend. The health check asks rproxy and the app,
// whether or not servewaf answers.
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
settings := func(more ...string) map[string]string {
env := map[string]string{rproxyListenAddr: ":" + port, rproxyUpstreamURL: app.URL}
for i := 0; i < len(more); i += 2 {
env[more[i]] = more[i+1]
}
return env
}
wantHealthCheck(t, settings(), 0, "")
wantHealthCheck(t, settings(commandSetting, ""), 0, "")
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
// SMALLWEBWAF_COMMAND cannot be given as a file.
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")),
0, "")
// Another setting does not fail it, even an invalid one, and neither does
// SWRP_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
// Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect.
wantHealthCheck(t, settings(rproxyUpstreamURL, "http://224.0.0.1"), 1,
"unhealthy: connect to the app: ")
// Listening on port 0 takes any free port, so nothing listens on port 0
// itself.
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ")
// No setting of rproxy's has a form given as a file.
wantHealthCheck(t, map[string]string{
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
rproxyUpstreamURL: app.URL,
}, 1, "unhealthy: ask rproxy: ")
}
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
t.Parallel()
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
"unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+
"\"healthcheck\" is not rproxy or servewaf\n")
}
func TestHealthCheckRefusesAnArgument(t *testing.T) { func TestHealthCheckRefusesAnArgument(t *testing.T) {
t.Parallel() t.Parallel()
+3 -1
View File
@@ -28,6 +28,8 @@ const (
wafBackend = "SWRP_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 = "SWRP_LISTEN_ADDR" rproxyListenAddr = "SWRP_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for servewaf. // rproxyTimeout is the setting that says how long rproxy waits for servewaf.
rproxyTimeout = "SWRP_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.
@@ -100,7 +102,7 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()
exited := make(chan int, 1) exited := make(chan int, 1)
go func() { go func() {
exited <- runAs(ctx, true, env, out) exited <- run(ctx, env, out)
}() }()
addr, _ := out.line(t, "msg", "starting")["address"].(string) addr, _ := out.line(t, "msg", "starting")["address"].(string)
+44 -50
View File
@@ -1,6 +1,7 @@
package smallwebwaf_test package smallwebwaf_test
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"io" "io"
@@ -33,16 +34,7 @@ func TestMain(m *testing.M) {
os.Exit(smallwebwaf.Main(testVersion)) os.Exit(smallwebwaf.Main(testVersion))
} }
func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) { func TestMainRunsServeWAF(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
command, disposition string
}{
{"servewaf", proxy.DispositionAllow},
{"", ""},
} {
t.Run("command "+tc.command, func(t *testing.T) {
t.Parallel() t.Parallel()
binary, err := os.Executable() binary, err := os.Executable()
@@ -58,9 +50,8 @@ func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) {
cmd.Stdout = out cmd.Stdout = out
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
commandForMain+"="+tc.command, commandForMain+"="+serveWAFCommand,
listenAddr+"="+localhost+":0", listenAddr+"="+localhost+":0",
upstreamURL+"="+startApp(t),
stateDir+"="+t.TempDir(), stateDir+"="+t.TempDir(),
rulesDir+"="+t.TempDir(), rulesDir+"="+t.TempDir(),
lookupSource+"=off") lookupSource+"=off")
@@ -76,7 +67,7 @@ func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) {
}) })
addr, _ := out.line(t, "msg", "starting")["address"].(string) addr, _ := out.line(t, "msg", "starting")["address"].(string)
ask(t, addr, "/", http.StatusOK, tc.disposition) ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
err = cmd.Process.Signal(syscall.SIGTERM) err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil { if err != nil {
@@ -87,6 +78,45 @@ func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("exit: %v; output:\n%s", err, out.text()) t.Errorf("exit: %v; output:\n%s", err, out.text())
} }
}
func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
t.Parallel()
binary, err := os.Executable()
if err != nil {
t.Fatalf("find the test binary: %v", err)
}
for _, command := range []string{"", "serve"} {
t.Run("command "+command, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), waitLimit)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, binary)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
cmd.Env = append(os.Environ(), commandForMain+"="+command)
err := cmd.Run()
if code := cmd.ProcessState.ExitCode(); code != 2 {
t.Errorf("exit status %d (%v), want 2", code, err)
}
for _, name := range []string{"rproxy", serveWAFCommand, "healthcheck"} {
if !strings.Contains(stderr.String(), name) {
t.Errorf("stderr %q does not name %s", stderr.String(), name)
}
}
if stdout.Len() != 0 {
t.Errorf("stdout %q, want nothing", stdout.String())
}
}) })
} }
} }
@@ -134,7 +164,7 @@ func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
exited := make(chan int, 1) exited := make(chan int, 1)
go func() { go func() {
exited <- runAs(ctx, true, env, out) exited <- run(ctx, env, out)
}() }()
addr, _ := out.line(t, "msg", "starting")["address"].(string) addr, _ := out.line(t, "msg", "starting")["address"].(string)
@@ -172,39 +202,3 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny) ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
}) })
} }
func TestWithoutACommandTheClientHeaderIsNoQuestion(t *testing.T) {
t.Parallel()
runUntilStopped(t, map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
}, func(url string) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set(proxy.ClientHeader, placed)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || string(body) != greeting ||
res.Header.Get(proxy.DispositionHeader) != "" {
t.Errorf("answered %q (%v) with disposition %q, want the app's greeting",
body, err, res.Header.Get(proxy.DispositionHeader))
}
})
}
+61 -53
View File
@@ -1,15 +1,16 @@
// Package smallwebwaf runs the smallwebwaf process: it reads the settings, // Package smallwebwaf runs the smallwebwaf process, as the command it is given.
// the rule files, the lookup database and the state files, serves requests // `smallwebwaf servewaf` reads the settings, the rule files, the lookup database
// until it is told to stop, and then stops in an orderly way, writing the // and the state files, answers rproxy's questions until it is told to stop, and
// state files. It serves them in front of an app, or, as `smallwebwaf // then stops in an orderly way, writing the state files. `smallwebwaf rproxy`
// servewaf`, by answering rproxy's questions about them. As `smallwebwaf // reads rproxy's settings alone, and runs the reverse proxy in front of one app
// rproxy`, it instead reads rproxy's settings alone, and runs the reverse proxy // that asks servewaf about each request. `smallwebwaf healthcheck` is the
// in front of one app that asks servewaf about each request. // container's health check.
package smallwebwaf package smallwebwaf
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net" "net"
@@ -35,12 +36,25 @@ import (
// runit and docker wait a little longer before they kill the process. // runit and docker wait a little longer before they kill the process.
const shutdownTimeout = 5 * time.Second const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as smallwebwaf stops, the log lines // remoteLogStopTimeout is how long, as servewaf stops, the log lines still
// still waiting are sent to SWWAF_LOG_REMOTE_URL before they are given // waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has
// up. stdout has carried them. // carried them.
const remoteLogStopTimeout = 2 * time.Second const remoteLogStopTimeout = 2 * time.Second
// Params are what Run needs from the process. // usageStatus is the exit status with no command, or one smallwebwaf does not
// know.
const usageStatus = 2
// usage is what smallwebwaf prints then.
const usage = `usage: smallwebwaf <command>
commands:
rproxy the reverse proxy in front of one app
servewaf decides for rproxy, and keeps bans, counters and history
healthcheck the container's health check
`
// Params are what RunServeWAF and RunRProxy need from the process.
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
@@ -48,44 +62,47 @@ type Params struct {
LookupEnv func(string) (string, bool) LookupEnv func(string) (string, bool)
// 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
// ServeWAF makes it `smallwebwaf servewaf`, which answers rproxy's
// questions, rather than the proxy in front of an app.
ServeWAF bool
} }
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the // Main runs the command os.Args names, and returns the process's exit status.
// process's exit status. Run as `smallwebwaf servewaf`, it answers // `smallwebwaf rproxy` and `smallwebwaf servewaf` run until SIGTERM or SIGINT.
// rproxy's questions rather than pass requests to an app. Run as // With no command, or one it does not know, it prints the commands and returns
// `smallwebwaf rproxy`, it is the reverse proxy that asks those questions. // usageStatus.
// Run as `smallwebwaf healthcheck`, it is the container's health check
// instead.
func Main(version string) int { func Main(version string) int {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" { args := os.Args[1:]
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr) if len(args) == 0 {
return printUsage(os.Stderr)
} }
ctx, stop := signal.NotifyContext(context.Background(), ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGTERM, os.Interrupt) syscall.SIGTERM, os.Interrupt)
defer stop() defer stop()
params := Params{ params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout}
Version: version,
LookupEnv: os.LookupEnv,
Stdout: os.Stdout,
ServeWAF: len(os.Args) > 1 && os.Args[1] == "servewaf",
}
if len(os.Args) > 1 && os.Args[1] == "rproxy" { switch args[0] {
case "rproxy":
return RunRProxy(ctx, params) return RunRProxy(ctx, params)
case "servewaf":
return RunServeWAF(ctx, params)
case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
default:
return printUsage(os.Stderr)
} }
return Run(ctx, params)
} }
// Run reads the settings, the rule files, the lookup database and the // printUsage writes the commands to w, and returns usageStatus.
// state files, then serves requests until ctx is done. It returns the func printUsage(w io.Writer) int {
// process's exit status, 1 when smallwebwaf cannot start. _, _ = fmt.Fprint(w, usage)
func Run(ctx context.Context, params Params) int {
return usageStatus
}
// RunServeWAF is `smallwebwaf servewaf`: it reads the settings, the rule files,
// the lookup database and the state files, then serves requests until ctx is
// done. It returns the process's exit status, 1 when servewaf cannot start.
func RunServeWAF(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's // Until the settings are read, the one message is an invalid setting's
// error, which every SWWAF_LOG_LEVEL lets through. // error, which every SWWAF_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout, processLog := requestlog.NewProcessLogger(params.Stdout,
@@ -133,12 +150,7 @@ func Run(ctx context.Context, params Params) int {
return 1 return 1
} }
newProxy := proxy.New server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
if params.ServeWAF {
newProxy = proxy.NewServeWAF
}
server, err := newServer(newProxy, cfg, stdout, processLog, now, ruleFiles, alertQueue)
if err != nil { if err != nil {
processLog.Error("cannot use the lookup database", "error", err.Error()) processLog.Error("cannot use the lookup database", "error", err.Error())
@@ -172,12 +184,10 @@ func Run(ctx context.Context, params Params) int {
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog) return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
} }
// newServer returns the server smallwebwaf runs, which newProxy makes, with // newServer returns the server servewaf runs, with the metrics of the alerts,
// the metrics of the alerts, after reading the lookup database while // after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup
// SWWAF_LOOKUP_SOURCE is file. A lookup database that cannot be read is an // database that cannot be read is an error.
// error.
func newServer( func newServer(
newProxy func(proxy.Params) *proxy.Server,
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
) (*proxy.Server, error) { ) (*proxy.Server, error) {
@@ -197,7 +207,7 @@ func newServer(
} }
} }
server := newProxy(proxy.Params{ server := proxy.NewServeWAF(proxy.Params{
Config: cfg, Config: cfg,
RequestLog: stdout, RequestLog: stdout,
ProcessLog: processLog, ProcessLog: processLog,
@@ -361,12 +371,10 @@ func serve(
// Run and Watch have ended, so nothing else reads or writes the // Run and Watch have ended, so nothing else reads or writes the
// files, and no alert is being sent, so that alerts.json keeps every // files, and no alert is being sent, so that alerts.json keeps every
// alert not yet sent. Every request has ended too, but for two kinds // alert not yet sent. Every request has ended too, but for one cut off
// that Go's server does not wait for: one cut off because Shutdown // because Shutdown timed out, which adds to its client's history only as
// timed out, and one whose connection switched protocols, such as a // it ends. That can be after this write, and then that request is missing
// WebSocket. Such a request adds to its client's history only as it // from clients.json.
// ends, which can be after this write, and then that request is
// missing from clients.json.
<-written <-written
<-watched <-watched
<-rulesWatched <-rulesWatched
+31 -104
View File
@@ -3,6 +3,7 @@ package smallwebwaf_test
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
@@ -19,6 +20,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
@@ -32,8 +34,6 @@ const (
// localhost is where the tests listen. // localhost is where the tests listen.
localhost = "127.0.0.1" localhost = "127.0.0.1"
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWWAF_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL"
trustedProxies = "SWWAF_TRUSTED_PROXIES"
stateDir = "SWWAF_STATE_DIR" stateDir = "SWWAF_STATE_DIR"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
@@ -122,18 +122,11 @@ func (o *output) text() string {
return o.buf.String() return o.buf.String()
} }
// run runs smallwebwaf with the settings in env until ctx is done, and // run runs `smallwebwaf servewaf` with the settings in env until ctx is done,
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it, // and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses. // so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int { func run(ctx context.Context, env map[string]string, out *output) int {
return runAs(ctx, false, env, out) return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{
}
// runAs is run as `smallwebwaf servewaf` while serveWAF is true.
func runAs(
ctx context.Context, serveWAF bool, env map[string]string, out *output,
) int {
return smallwebwaf.Run(ctx, smallwebwaf.Params{
Version: testVersion, Version: testVersion,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
value, ok := env[name] value, ok := env[name]
@@ -144,7 +137,6 @@ func runAs(
return value, ok return value, ok
}, },
Stdout: out, Stdout: out,
ServeWAF: serveWAF,
}) })
} }
@@ -154,7 +146,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{} out := &output{}
status := run(t.Context(), map[string]string{ status := run(t.Context(), map[string]string{
"SWWAF_REQUEST_MAX_BYTES": "lots", "SWWAF_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance, instanceName: instance,
}, out) }, out)
if status != 1 { if status != 1 {
@@ -166,7 +158,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
if line["type"] != "process" || line["instance"] != instance || if line["type"] != "process" || line["instance"] != instance ||
line["level"] != "ERROR" || line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") { !strings.HasPrefix(message, "SWWAF_BYTES_LIMIT_PER_MINUTE: ") {
t.Errorf("start refused with %v", line) t.Errorf("start refused with %v", line)
} }
} }
@@ -228,7 +220,6 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
func TestServesUntilToldToStop(t *testing.T) { func TestServesUntilToldToStop(t *testing.T) {
t.Parallel() t.Parallel()
appURL := startApp(t)
dir := t.TempDir() dir := t.TempDir()
ctx, stop := context.WithCancel(t.Context()) ctx, stop := context.WithCancel(t.Context())
@@ -238,7 +229,6 @@ func TestServesUntilToldToStop(t *testing.T) {
go func() { go func() {
exited <- run(ctx, map[string]string{ exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: appURL,
stateDir: dir, stateDir: dir,
rulesDir: filepath.Join("..", "..", "share", "rules.d"), rulesDir: filepath.Join("..", "..", "share", "rules.d"),
}, out) }, out)
@@ -250,10 +240,10 @@ func TestServesUntilToldToStop(t *testing.T) {
} }
starting := out.line(t, "msg", "starting") starting := out.line(t, "msg", "starting")
wantStartingLine(t, starting, appURL, dir) wantStartingLine(t, starting, dir)
addr, _ := starting["address"].(string) addr, _ := starting["address"].(string)
wantGreeting(t, "http://"+addr+"/") wantAllowed(t, "http://"+addr+"/")
out.line(t, "type", "request") out.line(t, "type", "request")
stop() stop()
@@ -325,7 +315,6 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
metricsToken: token, metricsToken: token,
@@ -363,7 +352,6 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
rateLimitPerDay: "2", rateLimitPerDay: "2",
@@ -375,8 +363,8 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
// The two requests a day allows, and a stop. // The two requests a day allows, and a stop.
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
wantGreeting(t, url) wantAllowed(t, url)
wantGreeting(t, url) wantAllowed(t, url)
}) })
// After a restart the client has no fresh allowance: its third // After a restart the client has no fresh allowance: its third
@@ -400,10 +388,8 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
rateLimitPerDay: "1", rateLimitPerDay: "1",
scope: "24", scope: "24",
} }
@@ -448,10 +434,8 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir, stateDir: dir,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
// No write comes due in the test, so only the watch on the // No write comes due in the test, so only the watch on the
// directory can take the edits in. // directory can take the edits in.
stateWriteDelay: "1h", stateWriteDelay: "1h",
@@ -473,10 +457,8 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir, stateDir: dir,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
adminToken: adminSecret, adminToken: adminSecret,
// Neither comes due in the test: the files are written as // Neither comes due in the test: the files are written as
// smallwebwaf stops. // smallwebwaf stops.
@@ -518,7 +500,6 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
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
@@ -527,7 +508,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url) wantAllowed(t, url)
// Written once: each change would start the rule files' wait // Written once: each change would start the rule files' wait
// again. A file written before smallwebwaf watches the directory is // again. A file written before smallwebwaf watches the directory is
@@ -580,10 +561,8 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
lookupSource: "file", lookupSource: "file",
@@ -679,10 +658,8 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir, stateDir: dir,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
metricsToken: token, metricsToken: token,
instanceName: instance, instanceName: instance,
"SWWAF_BLOCKLIST_URLS": torURL, "SWWAF_BLOCKLIST_URLS": torURL,
@@ -763,10 +740,8 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir, stateDir: dir,
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
"SWWAF_CROWDSEC_LAPI_URL": engine.URL, "SWWAF_CROWDSEC_LAPI_URL": engine.URL,
"SWWAF_CROWDSEC_LAPI_KEY": bouncerKey, "SWWAF_CROWDSEC_LAPI_KEY": bouncerKey,
} }
@@ -864,14 +839,13 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(), "SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url) wantAllowed(t, url)
}) })
out.line(t, "type", "request") out.line(t, "type", "request")
@@ -920,7 +894,6 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), "SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
@@ -930,7 +903,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url) wantAllowed(t, url)
// More than one line has been written, and the buffer holds the // More than one line has been written, and the buffer holds the
// last. // last.
@@ -980,7 +953,6 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir, stateDir: dir,
rulesDir: rules, rulesDir: rules,
"SWWAF_ALERT_WEBHOOK_URL": webhook.url, "SWWAF_ALERT_WEBHOOK_URL": webhook.url,
@@ -1065,10 +1037,8 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
slack, ntfy := startDestination(t), startDestination(t) slack, ntfy := startDestination(t), startDestination(t)
env := map[string]string{ env := map[string]string{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(), stateDir: t.TempDir(),
rulesDir: t.TempDir(), rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
rateLimitPerDay: "1", rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts. // The metrics are read from 127.0.0.1, which no limit counts.
"SWWAF_ALLOW_NETS": localhost + "/32", "SWWAF_ALLOW_NETS": localhost + "/32",
@@ -1208,8 +1178,8 @@ func startApp(t *testing.T) string {
return app.URL return app.URL
} }
// runUntilStopped runs smallwebwaf with the settings in env, has use send // runUntilStopped runs `smallwebwaf servewaf` with the settings in env, has use
// it requests at url, then stops it as SIGTERM does, checks that it // send it requests at url, then stops it as SIGTERM does, checks that it
// stopped in order, and returns its output. // stopped in order, and returns its output.
func runUntilStopped( func runUntilStopped(
t *testing.T, env map[string]string, use func(url string), t *testing.T, env map[string]string, use func(url string),
@@ -1242,26 +1212,16 @@ func runUntilStopped(
// wantStartingLine checks that the line at start gives the version and // wantStartingLine checks that the line at start gives the version and
// every setting's value. // every setting's value.
func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) { func wantStartingLine(t *testing.T, line map[string]any, dir string) {
t.Helper() t.Helper()
settings, _ := line["settings"].(map[string]any) settings, _ := line["settings"].(map[string]any)
want := map[string]any{ want := map[string]any{
listenAddr: localhost + ":0", listenAddr: localhost + ":0",
upstreamURL: appURL,
stateDir: dir, stateDir: dir,
"SWWAF_MODE": "enforce", "SWWAF_MODE": "enforce",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES": "32K",
"SWWAF_CLIENT_IDLE_TIMEOUT": "120s",
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "1G",
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
@@ -1292,30 +1252,11 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
} }
} }
// wantGreeting checks that a request to url gets the app's answer. // wantAllowed checks that servewaf at url allows a request from localhost.
func wantGreeting(t *testing.T, url string) { func wantAllowed(t *testing.T, url string) {
t.Helper() t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, wantStatus(t, url, localhost, http.StatusOK)
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || string(body) != greeting {
t.Errorf("got %q (%v), want the app's answer", body, err)
}
} }
// messages returns the message of each record in received, octet-counted // messages returns the message of each record in received, octet-counted
@@ -1496,34 +1437,16 @@ func onlyBan(t *testing.T, dir string) map[string]any {
return file.Bans[0] return file.Bans[0]
} }
// wantRefused checks that a request to url is refused with 403, the // wantRefused checks that servewaf at url refuses a request from localhost with
// default SWWAF_BAN_RESPONSE. // 403, the default SWWAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) { func wantRefused(t *testing.T, url string) {
t.Helper() t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, wantStatus(t, url, localhost, http.StatusForbidden)
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want %d", res.StatusCode, http.StatusForbidden)
}
} }
// wantStatus checks that a request to url from the client at from, as // wantStatus checks that a request to url from the client at from, as statusFrom
// X-Forwarded-For names it, is answered with status. // sends it, is answered with status.
func wantStatus(t *testing.T, url, from string, status int) { func wantStatus(t *testing.T, url, from string, status int) {
t.Helper() t.Helper()
@@ -1690,8 +1613,10 @@ func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost
} }
} }
// statusFrom returns the status a request to url from the client at // statusFrom returns the status a request to url from the client at from is
// from, as X-Forwarded-For names it, is answered with. // answered with. The request serves rproxy and servewaf alike. rproxy reads the
// client from X-Forwarded-For, and replaces the other two headers. To servewaf,
// they make it a question about that client.
func statusFrom(t *testing.T, url, from string) int { func statusFrom(t *testing.T, url, from string) int {
t.Helper() t.Helper()
@@ -1702,6 +1627,8 @@ func statusFrom(t *testing.T, url, from string) int {
} }
req.Header.Set("X-Forwarded-For", from) req.Header.Set("X-Forwarded-For", from)
req.Header.Set(proxy.ClientHeader, from)
req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()
+21
View File
@@ -648,6 +648,27 @@ func TestClientWithRefusalsInTheMinuteWithoutTheirStartStopsTheStart(t *testing.
`: entry 1 has no "minute_refusals.start"`) `: entry 1 has no "minute_refusals.start"`)
} }
func TestClientsJSONFromAnEarlierVersionWithTokenRefusalsLoads(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, clientsJSON),
[]byte(`{"version": 1, "clients": [{"client": "203.0.113.9/32", `+
`"history": {"offences": {"token_refused": 2}}}]}`), 0o600)
if err != nil {
t.Fatalf("write %s: %v", clientsJSON, err)
}
params := newParams(dir)
load(t, params)
clients := params.Limiter.Snapshot()
if len(clients) != 1 || clients[0].History.Offences.TokenRefused != 2 {
t.Errorf("clients %+v, want one with its two refusals kept", clients)
}
}
func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) { func TestReputationJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
+1 -2
View File
@@ -212,8 +212,7 @@ type Result struct {
// method, its URL with the query, and its headers, the Cookie header // method, its URL with the query, and its headers, the Cookie header
// without the cookies in cookiesNotRead, and on body, r's body as the // without the cookies in cookiesNotRead, and on body, r's body as the
// caller has it, as readBody reads it. It returns what it found, what it // caller has it, as readBody reads it. It returns what it found, what it
// read of body, which the app is still to be sent, and the error that // read of body, and the error that ended the reading early, if one did.
// 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) {
+140 -74
View File
@@ -1,19 +1,28 @@
#!/bin/sh #!/bin/sh
# script/example-app: build the image, and on it the example app in # script/example-app: build the image, and on it the example app in
# deploy/example-app, then run the app's container with a volume for the # deploy/example-app, then run the app's container, with rproxy in it, and
# state files and check that the health check passes, that a request is # servewaf in a container of its own with a volume for the state files. It
# served through smallwebwaf, that a second one in a minute bans the # checks:
# client, that a probe for /.env bans another client, which its next # - that the health checks pass;
# request bans for good, that `sv stop` stops smallwebwaf in order, that # - that a request is served through rproxy, and a second one in a minute
# `docker stop` stops the container without having to kill it, that a # bans the client;
# new container on the same volume still refuses the banned client, that # - that a probe for /.env bans another client, which its next request bans
# a container with its state files on a new volume mounted at the # for good;
# directory SWWAF_STATE_DIR_FILE names becomes healthy, and that one whose # - that only servewaf's container changes the state directory's owner;
# SWWAF_STATE_DIR does not exist logs smallwebwaf's message about it. The # - that the app still answers through rproxy with servewaf stopped, and
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent # that once servewaf has started again, bans apply again;
# to GeoJS. The containers, the volumes and both images are removed # - that `sv stop` stops rproxy in order, and `docker stop` stops each
# however the script ends. Building the app needs network access, for # container without having to kill it;
# nixpkgs' binary cache. script/check does not run this. # - that a new servewaf container on the same volume still refuses the banned
# client;
# - that a servewaf container with its state files on a new volume mounted at
# the directory SWWAF_STATE_DIR_FILE names becomes healthy;
# - that one whose SWWAF_STATE_DIR does not exist logs servewaf's message
# about it.
# servewaf runs with SWWAF_LOOKUP_SOURCE=off, so that no address is sent to
# GeoJS. The containers, the network, the volumes and both images are removed
# however the script ends. Building the app needs network access, for nixpkgs'
# binary cache. script/check does not run this.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -24,7 +33,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
NAME="$("$SCRIPT_DIR/projectname")-example-$$" NAME="$("$SCRIPT_DIR/projectname")-example-$$"
IMAGE="$NAME-base" IMAGE="$NAME-base"
APP_IMAGE="$NAME-app" APP_IMAGE="$NAME-app"
CONTAINER="$NAME" NETWORK="$NAME"
APP="$NAME-app-container"
SERVEWAF="$NAME-servewaf"
VOLUME="$NAME-state" VOLUME="$NAME-state"
# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE # The volumes of the container whose state directory SWWAF_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.
@@ -32,15 +43,18 @@ MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings" SETTINGS_VOLUME="$NAME-settings"
cleanup() { cleanup() {
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true docker rm --force "$APP" "$SERVEWAF" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \ docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
} }
fail() { fail() {
echo "example-app: $*; the container's output:" >&2 echo "example-app: $*; the app container's output:" >&2
docker logs "$CONTAINER" >&2 || true docker logs "$APP" >&2 || true
echo "example-app: servewaf's output:" >&2
docker logs "$SERVEWAF" >&2 || true
exit 1 exit 1
} }
@@ -57,52 +71,73 @@ wait_for() {
done done
} }
# healthy <container>: docker says the container's health check passes.
healthy() { healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER")" status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
[ "$status" = healthy ] [ "$status" = healthy ]
} }
# logged <text>...: a line of the container's output holds every text, # logged <container> <text>...: a line of the container's output holds every
# in any order. # text, in any order.
logged() { logged() {
lines="$(docker logs "$CONTAINER" 2>&1)" lines="$(docker logs "$1" 2>&1)"
shift
for text in "$@"; do for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1 lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
done done
} }
# start_container: run the app's container, with the state files on the # start_servewaf: run servewaf from the image, with the state files on the
# volume, a rate limit of one request a minute and no client looked up, # volume, a rate limit of one request a minute and no client looked up, and wait
# and wait until it is healthy. # until it is healthy.
start_container() { start_servewaf() {
docker run --detach --name "$CONTAINER" --publish 127.0.0.1::8080 \ docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
--volume "$VOLUME:/var/lib/smallwebwaf" \ --volume "$VOLUME:/var/lib/smallwebwaf" \
--env SMALLWEBWAF_COMMAND=servewaf \
--env SWWAF_RATE_LIMIT_PER_MINUTE=1 \ --env SWWAF_RATE_LIMIT_PER_MINUTE=1 \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "the health check did not pass" healthy wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
address="$(docker port "$CONTAINER" 8080/tcp)"
} }
# start_moved_container: run the app's container with the state files on # start_app: run the app's container, with rproxy in it, and wait until it is
# a new volume at /srv/state, which SWWAF_STATE_DIR_FILE names in a file # healthy.
# only the smallwebwaf user can read, as under "Settings given as files" start_app() {
# in docs/settings.md, and wait until it is healthy. smallwebwaf can write the docker run --detach --name "$APP" --network "$NETWORK" --publish 127.0.0.1::8080 \
# new volume only once the run script has given it to that user. --env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \
start_moved_container() { "$APP_IMAGE" >/dev/null
wait_for "the app's health check did not pass" healthy "$APP"
address="$(docker port "$APP" 8080/tcp)"
}
# start_moved_servewaf: run servewaf with the state files on a new volume at
# /srv/state, which SWWAF_STATE_DIR_FILE names in a file only the smallwebwaf
# user can read, as under "Settings given as files" in docs/settings.md, and
# wait until it is healthy. servewaf can write the new volume only once the run
# script has given it to that user.
start_moved_servewaf() {
docker volume create "$MOVED_VOLUME" >/dev/null docker volume create "$MOVED_VOLUME" >/dev/null
docker volume create "$SETTINGS_VOLUME" >/dev/null docker volume create "$SETTINGS_VOLUME" >/dev/null
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \ docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir && --entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir &&
chown smallwebwaf:smallwebwaf /settings/state-dir && chown smallwebwaf:smallwebwaf /settings/state-dir &&
chmod 0400 /settings/state-dir' chmod 0400 /settings/state-dir'
docker run --detach --name "$CONTAINER" \ docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
--volume "$MOVED_VOLUME:/srv/state" \ --volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \ --volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \
--env SMALLWEBWAF_COMMAND=servewaf \
--env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \ --env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "the health check did not pass" healthy wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
}
# status_from <client> <path>: print the status a request for path from client,
# as X-Forwarded-For names it, gets. rproxy believes the header from docker's
# gateway, a private address.
status_from() {
curl --silent --output /dev/null --write-out '%{http_code}' \
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2" || true
} }
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's # refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
@@ -113,13 +148,14 @@ refused() {
[ "$code" = 403 ] [ "$code" = 403 ]
} }
# refused_from <client> <path>: a request for path from client, as # refused_from <client> <path>: a request for path from client gets 403.
# X-Forwarded-For names it, gets 403. smallwebwaf believes the header
# from docker's gateway, a private address.
refused_from() { refused_from() {
code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ [ "$(status_from "$1" "$2")" = 403 ]
--max-time 10 --header "X-Forwarded-For: $1" "http://$address$2")" || true }
[ "$code" = 403 ]
# owner_of_state_dir <container>: print who owns /var/lib/smallwebwaf there.
owner_of_state_dir() {
docker exec "$1" stat --format '%U' /var/lib/smallwebwaf
} }
main() { main() {
@@ -131,58 +167,88 @@ main() {
docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \ docker build --no-cache --build-arg SMALLWEBWAF_IMAGE="$IMAGE" \
-t "$APP_IMAGE" deploy/example-app -t "$APP_IMAGE" deploy/example-app
docker network create "$NETWORK" >/dev/null
docker volume create "$VOLUME" >/dev/null docker volume create "$VOLUME" >/dev/null
start_container start_servewaf
echo "example-app: the health check passes" start_app
echo "example-app: the health checks pass"
[ "$(owner_of_state_dir "$SERVEWAF")" = smallwebwaf ] ||
fail "servewaf's container did not give the state directory to smallwebwaf"
[ "$(owner_of_state_dir "$APP")" = root ] ||
fail "the app's container changed the state directory's owner"
echo "example-app: only servewaf's container changes the state directory's owner"
page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" || page="$(curl --fail --silent --show-error --max-time 10 "http://$address/")" ||
fail "no answer on port 8080" fail "no answer on port 8080"
[ "$page" = "hello from the example app" ] || fail "port 8080 answered $page" [ "$page" = "hello from the example app" ] || fail "port 8080 answered $page"
wait_for "smallwebwaf logged no request it forwarded" logged '"action":"forward"' wait_for "servewaf logged no request it allowed" \
echo "example-app: smallwebwaf passes a request to the app and its answer back" logged "$SERVEWAF" '"action":"forward"' '"servewaf_line":"report"'
echo "example-app: rproxy passes a request to the app and its answer back"
refused || fail "a second request in a minute was not refused" refused || fail "a second request in a minute was not refused"
wait_for "smallwebwaf logged no ban" logged '"action":"rate_limited"' wait_for "servewaf logged no ban" logged "$SERVEWAF" '"action":"rate_limited"'
echo "example-app: a second request in a minute bans the client" echo "example-app: a second request in a minute bans the client"
refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused" refused_from 203.0.113.9 /.env || fail "a probe for /.env was not refused"
wait_for "smallwebwaf logged no ban for the probe" \ wait_for "servewaf logged no ban for the probe" \
logged '"action":"banned"' '"rule_ids":["env-file"]' logged "$SERVEWAF" '"action":"banned"' '"rule_ids":["env-file"]'
refused_from 203.0.113.9 / || fail "the client of the probe was let through" refused_from 203.0.113.9 / || fail "the client of the probe was let through"
wait_for "the client's next request did not make its ban permanent" \ wait_for "the client's next request did not make its ban permanent" \
logged '"ban_expires":"permanent"' logged "$SERVEWAF" '"ban_expires":"permanent"'
echo "example-app: a probe for /.env bans the client, its next request for good" echo "example-app: a probe for /.env bans the client, its next request for good"
docker exec "$CONTAINER" sv stop smallwebwaf >/dev/null || docker exec "$SERVEWAF" sv stop smallwebwaf >/dev/null ||
fail "sv stop smallwebwaf failed" fail "sv stop smallwebwaf failed in servewaf's container"
wait_for "smallwebwaf did not stop in order" logged '"msg":"stopped"' wait_for "servewaf did not stop in order" logged "$SERVEWAF" '"msg":"stopped"'
echo "example-app: sv stop stops smallwebwaf in order" [ "$(status_from 203.0.113.9 /)" = 200 ] ||
fail "with servewaf stopped, the app did not answer through rproxy"
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged "$APP" '"default_disposition":true'
docker exec "$APP" /usr/local/bin/smallwebwaf healthcheck ||
fail "with servewaf stopped, the app's health check failed"
echo "example-app: with servewaf stopped, the app answers through rproxy"
docker stop "$CONTAINER" >/dev/null docker exec "$SERVEWAF" sv start smallwebwaf >/dev/null ||
status="$(docker inspect --format '{{.State.ExitCode}}' "$CONTAINER")" fail "sv start smallwebwaf failed in servewaf's container"
wait_for "once servewaf had started again, the ban did not apply" \
refused_from 203.0.113.9 /
echo "example-app: once servewaf has started again, bans apply again"
docker exec "$APP" sv stop smallwebwaf >/dev/null ||
fail "sv stop smallwebwaf failed in the app's container"
wait_for "rproxy did not stop in order" logged "$APP" '"msg":"stopped"'
echo "example-app: sv stop stops rproxy in order"
for container in "$APP" "$SERVEWAF"; do
docker stop "$container" >/dev/null
status="$(docker inspect --format '{{.State.ExitCode}}' "$container")"
[ "$status" = 0 ] || fail "docker stop left exit status $status" [ "$status" = 0 ] || fail "docker stop left exit status $status"
echo "example-app: docker stop stops the container in order" done
echo "example-app: docker stop stops each container in order"
docker rm "$CONTAINER" >/dev/null docker rm "$APP" "$SERVEWAF" >/dev/null
start_container start_servewaf
refused || fail "the new container let the banned client through" start_app
wait_for "smallwebwaf logged no request refused under the ban" \ refused || fail "the new servewaf container let the banned client through"
logged '"action":"banned"' wait_for "servewaf logged no request refused under the ban" \
echo "example-app: a new container on the same volume keeps the ban" logged "$SERVEWAF" '"action":"banned"'
echo "example-app: a new servewaf container on the same volume keeps the ban"
docker rm --force "$CONTAINER" >/dev/null docker rm --force "$SERVEWAF" >/dev/null
start_moved_container start_moved_servewaf
echo "example-app: the run script gives the state files' directory named" \ echo "example-app: the run script gives the state files' directory named" \
"by SWWAF_STATE_DIR_FILE to the smallwebwaf user" "by SWWAF_STATE_DIR_FILE to the smallwebwaf user"
docker rm --force "$CONTAINER" >/dev/null docker rm --force "$SERVEWAF" >/dev/null
docker run --detach --name "$CONTAINER" \ docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
--env SMALLWEBWAF_COMMAND=servewaf \
--env SWWAF_STATE_DIR=/srv/missing \ --env SWWAF_STATE_DIR=/srv/missing \
--env SWWAF_LOOKUP_SOURCE=off \ --env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null "$IMAGE" >/dev/null
wait_for "smallwebwaf did not report the missing state directory" \ wait_for "servewaf did not report the missing state directory" \
logged "SWWAF_STATE_DIR cannot be written" logged "$SERVEWAF" "SWWAF_STATE_DIR cannot be written"
echo "example-app: smallwebwaf reports a state directory that does not exist" echo "example-app: servewaf reports a state directory that does not exist"
} }
main "$@" main "$@"
+62 -48
View File
@@ -1,13 +1,14 @@
#!/bin/sh #!/bin/sh
# script/gitea-requests: build the image and run gitea 1.27.3 behind it, # script/gitea-requests: build the image and run gitea 1.27.3 behind rproxy
# with every setting of smallwebwaf at its default but SWWAF_UPSTREAM_URL, # and servewaf, three containers on one network. Every setting is at its
# which names gitea's container. Then send through it the gitea requests # default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
# that reading gitea's source found the Core Rule Set refuses, which # names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
# docs/gitea.md records, and a short sample of gitea's ordinary use: # request. Then send through rproxy the gitea requests that reading gitea's
# signing in, pages of files and commits, a diff, the API, a push, clone # source found the Core Rule Set refuses, which docs/gitea.md records, and a
# and fetch over HTTP, and two container image pushes. Last, print each # short sample of gitea's ordinary use: signing in, pages of files and commits,
# request smallwebwaf logged, with what it did with it and the ids of the # a diff, the API, a push, clone and fetch over HTTP, and two container image
# Core Rule Set's rules that matched it. # pushes. Last, print each request servewaf logged, with what it did with it and
# the ids of the Core Rule Set's rules that matched it.
# Every request comes from one client, docker's gateway, so more than # Every request comes from one client, docker's gateway, so more than
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and # SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
# the requests after would show as banned. The containers, the network, # the requests after would show as banned. The containers, the network,
@@ -29,7 +30,8 @@ GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$" NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
IMAGE="$NAME-image" IMAGE="$NAME-image"
NETWORK="$NAME" NETWORK="$NAME"
SMALLWEBWAF="$NAME-smallwebwaf" RPROXY="$NAME-rproxy"
SERVEWAF="$NAME-servewaf"
GITEA="$NAME-gitea" GITEA="$NAME-gitea"
# gitea's one user, who owns the repository and the container images. # gitea's one user, who owns the repository and the container images.
@@ -37,16 +39,23 @@ PASSWORD="owner-password"
BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0" BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
# The files the script makes, and smallwebwaf's address once it runs, # The header that labels a request in the report. rproxy makes every request's
# localhost and the port docker publishes it on: a host name, since the # id itself, so a label cannot be the X-Request-ID.
# Core Rule Set takes a Host that is an IP address for a warning (920350). LABEL_HEADER="X-Case"
# The default of SWWAF_LOG_REQUEST_HEADERS, and the header above.
LOGGED_HEADERS="accept,accept-language,accept-encoding,content-type,origin,range,x-case"
# The files the script makes, and rproxy's address once it runs, localhost and
# the port docker publishes it on: a host name, since the Core Rule Set takes a
# Host that is an IP address for a warning (920350).
WORK="" WORK=""
address="" address=""
cleanup() { cleanup() {
# So that a signal cannot stop it partway through. # So that a signal cannot stop it partway through.
trap '' HUP INT TERM trap '' HUP INT TERM
docker rm --force --volumes "$SMALLWEBWAF" "$GITEA" >/dev/null 2>&1 || true docker rm --force --volumes "$RPROXY" "$SERVEWAF" "$GITEA" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
if [ -n "$address" ]; then if [ -n "$address" ]; then
@@ -75,30 +84,30 @@ wait_for() {
done done
} }
# healthy <container>: docker says the container's health check passes.
healthy() { healthy() {
status="$(docker inspect --format '{{.State.Health.Status}}' "$SMALLWEBWAF")" status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
[ "$status" = healthy ] [ "$status" = healthy ]
} }
# logged <text>...: a line of smallwebwaf's output holds every text, in # logged <text>...: a line of servewaf's output holds every text, in any order.
# any order.
logged() { logged() {
lines="$(docker logs "$SMALLWEBWAF" 2>&1)" lines="$(docker logs "$SERVEWAF" 2>&1)"
for text in "$@"; do for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1 lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
done done
} }
# send <label> <path> <curl option>...: send a request for path to # send <label> <path> <curl option>...: send a request for path to rproxy, with
# smallwebwaf, with label as its X-Request-ID, which the report gives, # label in LABEL_HEADER, which the report gives, and keep the body of the answer
# and keep the body of the answer in $WORK/answer. The Core Rule Set # in $WORK/answer. The Core Rule Set reads that header too, so a label names no
# reads that header too, so a label names no file the cases name. # file the cases name.
send() { send() {
label="$1" label="$1"
path="$2" path="$2"
shift 2 shift 2
curl --silent --show-error --max-time 30 --output "$WORK/answer" \ curl --silent --show-error --max-time 30 --output "$WORK/answer" \
--header "X-Request-ID: $label" "$@" "http://$address$path" --header "$LABEL_HEADER: $label" "$@" "http://$address$path"
} }
# page <label> <path> <curl option>...: send the request as a browser # page <label> <path> <curl option>...: send the request as a browser
@@ -173,7 +182,7 @@ make_repository() {
echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java" echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
repo_git -C "$dir" commit --quiet --all --message "Add a line" repo_git -C "$dir" commit --quiet --all --message "Add a line"
repo_git -C "$dir" -c "http.extraHeader=X-Request-ID: git-push" push --quiet \ repo_git -C "$dir" -c "http.extraHeader=$LABEL_HEADER: git-push" push --quiet \
"http://owner:$PASSWORD@$address/owner/repo.git" main || "http://owner:$PASSWORD@$address/owner/repo.git" main ||
fail "the push of the repository failed" fail "the push of the repository failed"
} }
@@ -236,9 +245,8 @@ call_api() {
# push_images: push a container image to owner/python3-app, then the same # push_images: push a container image to owner/python3-app, then the same
# image to owner/app. For the second, the case, docker asks gitea to take # image to owner/app. For the second, the case, docker asks gitea to take
# its layer from owner/python3-app, naming it in the query parameter # its layer from owner/python3-app, naming it in the query parameter
# from. docker sends no X-Request-ID, so its requests have the ids # from. docker sends no label, so the report gives its requests none. The image
# smallwebwaf gives them. The image is one small file, imported rather # is one small file, imported rather than built.
# than built.
push_images() { push_images() {
echo "$NAME" >"$WORK/layer.txt" echo "$NAME" >"$WORK/layer.txt"
tar -cf - -C "$WORK" layer.txt | tar -cf - -C "$WORK" layer.txt |
@@ -258,10 +266,10 @@ push_images() {
# clone_and_fetch: the clone and fetch over HTTP of the sample. # clone_and_fetch: the clone and fetch over HTTP of the sample.
clone_and_fetch() { clone_and_fetch() {
repo_git -c "http.extraHeader=X-Request-ID: git-clone" clone --quiet \ repo_git -c "http.extraHeader=$LABEL_HEADER: git-clone" clone --quiet \
"http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" || "http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
fail "the clone failed" fail "the clone failed"
repo_git -C "$WORK/clone" -c "http.extraHeader=X-Request-ID: git-fetch" fetch --quiet || repo_git -C "$WORK/clone" -c "http.extraHeader=$LABEL_HEADER: git-fetch" fetch --quiet ||
fail "the fetch failed" fail "the fetch failed"
} }
@@ -271,19 +279,19 @@ field() {
printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
} }
# report: print a line for each request smallwebwaf logged, but its # report: print a line for each request rproxy reported to servewaf: what
# health checks: what it did with the request, the ids of the Core Rule # servewaf did with the request, the ids of the Core Rule Set's rules that
# Set's rules that matched it, its X-Request-ID, and the request. # matched it, its label, and the request. waf_blocked is a refusal by the Core
# waf_blocked is a refusal by the Core Rule Set, and forward a request # Rule Set, and forward a request passed on to gitea, whatever gitea answered.
# passed on to gitea, whatever gitea answered.
report() { report() {
docker logs "$SMALLWEBWAF" 2>/dev/null | grep '"type":"request"' | docker logs "$SERVEWAF" 2>/dev/null | grep '"servewaf_line":"report"' |
grep -v '"action":"admin"' | sed 's/\\u0026/\&/g' >"$WORK/requests" sed 's/\\u0026/\&/g' >"$WORK/requests"
while IFS= read -r line; do while IFS= read -r line; do
ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')" ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
query="$(field query "$line")" query="$(field query "$line")"
label="$(field x-case "$line")"
printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \ printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
"$(field request_id "$line")" "$(field method "$line")" \ "${label:--}" "$(field method "$line")" \
"$(field path "$line")" "${query:+?$query}" "$(field path "$line")" "${query:+?$query}"
done <"$WORK/requests" done <"$WORK/requests"
echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \ echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
@@ -298,24 +306,30 @@ main() {
docker build --no-cache -t "$IMAGE" . docker build --no-cache -t "$IMAGE" .
# smallwebwaf first, so that gitea can be given the address it is # rproxy first, so that gitea can be given the address it is reached at,
# reached at, and gitea's image pulled before, so that gitea starts # and gitea's image pulled before, so that gitea starts within the minute
# within the minute smallwebwaf's health check waits for it. # rproxy's health check waits for it.
docker pull --quiet "$GITEA_IMAGE" >/dev/null docker pull --quiet "$GITEA_IMAGE" >/dev/null
docker network create "$NETWORK" >/dev/null docker network create "$NETWORK" >/dev/null
docker run --detach --name "$SMALLWEBWAF" --network "$NETWORK" \ docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
--publish 127.0.0.1::8080 \ --env SMALLWEBWAF_COMMAND=servewaf \
--env SWWAF_UPSTREAM_URL="http://$GITEA:3000" \ --env SWWAF_LOG_REQUEST_HEADERS="$LOGGED_HEADERS" \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
published="$(docker port "$SMALLWEBWAF" 8080/tcp)" wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
docker run --detach --name "$RPROXY" --network "$NETWORK" \
--publish 127.0.0.1::8080 \
--env SWRP_UPSTREAM_URL="http://$GITEA:3000" \
--env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \
"$IMAGE" >/dev/null
published="$(docker port "$RPROXY" 8080/tcp)"
address="localhost:${published##*:}" address="localhost:${published##*:}"
docker run --detach --name "$GITEA" --network "$NETWORK" \ docker run --detach --name "$GITEA" --network "$NETWORK" \
--env GITEA__security__INSTALL_LOCK=true \ --env GITEA__security__INSTALL_LOCK=true \
--env GITEA__server__ROOT_URL="http://$address/" \ --env GITEA__server__ROOT_URL="http://$address/" \
"$GITEA_IMAGE" >/dev/null "$GITEA_IMAGE" >/dev/null
# smallwebwaf's health check passes once gitea takes connections. # rproxy's health check passes once gitea takes connections.
wait_for "the health check did not pass" healthy wait_for "rproxy's health check did not pass" healthy "$RPROXY"
docker exec --user git "$GITEA" gitea admin user create --admin \ docker exec --user git "$GITEA" gitea admin user create --admin \
--username owner --password "$PASSWORD" --email owner@example.invalid \ --username owner --password "$PASSWORD" --email owner@example.invalid \
--must-change-password=false >/dev/null --must-change-password=false >/dev/null
@@ -328,8 +342,8 @@ main() {
# The fetch's last request is the last of all; once it is logged, so # The fetch's last request is the last of all; once it is logged, so
# is every other. # is every other.
wait_for "smallwebwaf logged no line for the fetch" \ wait_for "servewaf logged no report of the fetch" \
logged '"request_id":"git-fetch"' '"method":"POST"' logged '"x-case":"git-fetch"' '"method":"POST"' '"servewaf_line":"report"'
report report
} }
+31 -5
View File
@@ -1,14 +1,29 @@
#!/bin/sh #!/bin/sh
# script/run: build bin/smallwebwaf with script/build and run it, with # script/run: build bin/smallwebwaf with script/build and run servewaf and
# the settings in the environment. Unless SWWAF_STATE_DIR is set, the # rproxy on the host, with the settings in the environment, until rproxy
# state files go in bin/state, beside the binary, and unless # exits or the script is told to stop. rproxy listens on port 8080 unless
# SWWAF_RULES_DIR is set, the rule files are those of share/rules.d, # SWRP_LISTEN_ADDR says otherwise, in front of the app at SWRP_UPSTREAM_URL.
# servewaf listens on port 8090 unless SWWAF_LISTEN_ADDR says otherwise, and
# rproxy asks it there unless SWRP_WAF_BACKEND says otherwise. Unless
# SWWAF_STATE_DIR is set, the state files go in bin/state, beside the binary,
# and unless SWWAF_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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
SERVEWAF=""
RPROXY=""
# stop_both: stop what runs, and wait for it, so that servewaf writes its state
# files.
stop_both() {
trap '' HUP INT TERM
kill $SERVEWAF $RPROXY 2>/dev/null || true
wait || true
}
main() { main() {
"$SCRIPT_DIR/build" "$SCRIPT_DIR/build"
if [ -z "${SWWAF_STATE_DIR+set}" ]; then if [ -z "${SWWAF_STATE_DIR+set}" ]; then
@@ -20,7 +35,18 @@ main() {
SWWAF_RULES_DIR="$ROOT/share/rules.d" SWWAF_RULES_DIR="$ROOT/share/rules.d"
export SWWAF_RULES_DIR export SWWAF_RULES_DIR
fi fi
exec "$ROOT/bin/smallwebwaf" SWWAF_LISTEN_ADDR="${SWWAF_LISTEN_ADDR:-:8090}"
SWRP_WAF_BACKEND="${SWRP_WAF_BACKEND:-http://127.0.0.1:${SWWAF_LISTEN_ADDR##*:}}"
export SWWAF_LISTEN_ADDR SWRP_WAF_BACKEND
trap stop_both EXIT
trap 'exit 1' HUP INT TERM
"$ROOT/bin/smallwebwaf" servewaf &
SERVEWAF=$!
"$ROOT/bin/smallwebwaf" rproxy &
RPROXY=$!
wait "$RPROXY"
} }
main "$@" main "$@"
+25 -9
View File
@@ -2,22 +2,38 @@
set -euo pipefail set -euo pipefail
# runit's run script for smallwebwaf, run again whenever smallwebwaf # runit's run script for smallwebwaf, run again whenever smallwebwaf
# exits; the wait spaces out the restarts. The state directory and every # exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the
# file in it are given to the smallwebwaf user, so that a volume mounted # command to run: rproxy when it is unset or empty, or servewaf. With any
# there needs no change of owner; chown -R changes a symbolic link itself, # other value the script starts nothing and stops with a message naming it.
# never what it points to. When SWWAF_STATE_DIR, in either form, cannot be
# read or is not the absolute path of a directory, no owner is changed,
# and smallwebwaf starts and 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: smallwebwaf reports a directory it cannot write.
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself. # exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
main() { main() {
sleep 1 sleep 1
local command="${SMALLWEBWAF_COMMAND:-rproxy}"
case "$command" in
rproxy) ;;
servewaf) give_state_dir_to_smallwebwaf ;;
*)
printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \
"$command" >&2
exit 1
;;
esac
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command"
}
# give_state_dir_to_smallwebwaf gives the state directory and every file in
# it to the smallwebwaf user, so that a volume mounted there needs no change
# of owner; chown -R changes a symbolic link itself, never what it points
# to. When SWWAF_STATE_DIR, in either form, cannot be read or is not the
# absolute path of a directory, no owner is changed, and servewaf starts and
# 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: servewaf
# reports a directory it cannot write.
give_state_dir_to_smallwebwaf() {
local dir local dir
if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then
chown -R smallwebwaf:smallwebwaf "$dir" || true chown -R smallwebwaf:smallwebwaf "$dir" || true
fi fi
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
} }
# state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset, # state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset,