rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 23m49s

`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.

Model: opus-5-5
This commit is contained in:
2026-11-05 10:33:24 +00:00
committed by sneak
parent 17c5dd2438
commit ac1a69e863
71 changed files with 2653 additions and 5493 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
+215 -137
View File
@@ -1,19 +1,29 @@
# 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.
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 with two
accepts requests on the app's behalf, passes them to the app, and passes the 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. It accepts
on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and requests on the app's behalf and asks `servewaf` about each one. It passes the
why. The reference documents under [`docs/`](docs/) are listed under "Documents" allowed ones to the app, and the app's answers back. It keeps nothing, and
below. never touches disk.
- `smallwebwaf servewaf` decides. It runs the checks, and keeps the bans,
counters and history. One `servewaf` serves many sites.
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy`
waits at most 2 seconds for `servewaf`'s answer, and without one passes the
request to the app.
`rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf`
is. In a container of its own, it also needs `SWRP_UPSTREAM_URL`, which says
where the app is. Every other setting has a default chosen for a service on the
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
reference documents under [`docs/`](docs/) are listed under "Documents" below.
## What it protects against ## What it protects against
@@ -54,7 +64,7 @@ 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 (see
[`docs/request-log.md`](docs/request-log.md)); [`docs/request-log.md`](docs/request-log.md));
- sends alerts to a webhook, Slack or ntfy (see - sends alerts to a webhook, Slack or ntfy (see
[`docs/alerts.md`](docs/alerts.md)); [`docs/alerts.md`](docs/alerts.md));
@@ -65,8 +75,8 @@ For the operator, it:
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.
- Defence against floods large enough to fill the host's network link. - Defence against floods large enough to fill the host's network link.
@@ -84,14 +94,68 @@ 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.
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 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;
- `servewaf`, with a volume for its state files.
```sh
docker network create site
docker run --detach --name servewaf --network site \
--env SMALLWEBWAF_COMMAND=servewaf \
--volume servewaf-state:/var/lib/smallwebwaf \
<registry>/smallwebwaf@sha256:<digest>
docker run --detach --name app --network site \
<registry>/app@sha256:<digest>
docker run --detach --name rproxy --network site \
--env SWRP_WAF_BACKEND=http://servewaf:8080 \
--env SWRP_UPSTREAM_URL=http://app:8081 \
<registry>/smallwebwaf@sha256:<digest>
```
Here the app listens on port 8081, at its container's address on that network.
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.
With no setting, `servewaf` sends the address of every new visitor to GeoJS, a
web 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)).
### Many sites, one `servewaf`
Run one `rproxy` for each app, all with `SWRP_WAF_BACKEND` naming the same
`servewaf`. It keeps one table of clients and bans for all of them:
- A client banned through one site is refused on all of them.
- A client's requests on all of them count toward one set of limits.
- Log lines, alerts and ban notes name the site by its host.
### `rproxy` in the app's container
An app's image can also be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app. `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,38 +190,43 @@ 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 the app's image as you deploy any app. Point traefik's labels at port
8080 (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Mount a
volume at `/var/lib/smallwebwaf`, so that bans and history survive a deploy. For
example, with no setting:
```sh
docker run --detach --name app \
--volume app-smallwebwaf:/var/lib/smallwebwaf \
<registry>/app@sha256:<digest>
```
With no setting, the address of every new visitor is sent to GeoJS, a web
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 `deploy/example-app` is such an app. `make example-app` builds it and checks it
(see "Checking that it works" below). To run `smallwebwaf` outside the image (see "Checking that it works" below). To run `rproxy` and `servewaf` on the host
while working on it, see "Entrypoints" below. while working on them, see `script/run` under "Entrypoints" below.
### When `servewaf` is down, slow or overloaded
`SWRP_DEFAULT_DISPOSITION` decides what `rproxy` does with a request when
`servewaf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWRP_TIMEOUT_MS`, 2 seconds by default.
Its default, `allow`, passes the request to the app without `servewaf`'s answer.
`deny` answers `503` instead. Either way the request gets no report, so its
bytes are not counted and it is not added to its client's history. When
`servewaf` does answer, a `deny` or an error goes to the visitor as `servewaf`
gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part.
## What happens to a request ## What happens to a request
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step A request goes through fifteen steps, in this order. Once `servewaf` refuses a
refuses a request, the steps after it are skipped, apart from step 15, and request, the steps after that are skipped, apart from the report of step 15, and
nothing of the request reaches the app. "What happens to a request" in nothing of the request reaches the app. "What happens to a request" in
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers. [`SPEC.md`](SPEC.md) gives each step in full, under the same numbers.
`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, and `servewaf` is asked about the
request.
`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.
@@ -167,31 +236,36 @@ nothing of the request reaches the app. "What happens to a request" in
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
that prefix.
`rproxy` takes the rest, with `servewaf` counting at the end:
14. The request is passed to the app. 14. The request is passed to the app.
15. After the answer: the byte limits, the error burst and the client's history. 15. After the answer, `rproxy` sends `servewaf` a report. For an allowed
request, `servewaf` counts the byte limits and adds the request to the
client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a "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 Core Rule Set match answers `403`. `rproxy` itself answers `405` to a method it
request over a size or time limit gets the status for that limit, such as `413` does not pass on, and the status for a size or time limit, such as `413` for a
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)). body too large (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
## Settings you are likely to touch ## Settings you are likely to touch
Each setting is an environment variable of the app's container, such as Each setting is an environment variable, read at start. Those of `servewaf`
`docker run -e SWWAF_MODE=observe ...`, read at start. start with `SWWAF_`, and those of `rproxy` with `SWRP_`.
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the [`docs/settings.md`](docs/settings.md) lists every setting, its default and the
values it takes. The ones most deployments touch: values it takes. The ones most deployments touch:
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a - `SWWAF_MODE=observe`: `servewaf` refuses nothing and makes no ban of its own.
request only for a size or time limit or a missing or wrong token. A request A request it would have refused is passed to the app, and its log line says
it would have refused is passed to the app, and its log line says what what `enforce` mode would have done. For trying a configuration before
`enforce` mode would have done. For trying a configuration before enforcing enforcing it. The limits of `rproxy` still apply.
it.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their - `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. clients skip `servewaf`'s checks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the - `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. byte limits, such as a machine that talks to the app all day.
- `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as - `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as
@@ -201,39 +275,49 @@ values it takes. The ones most deployments touch:
refuses your app's ordinary requests. The log line names the rules a request 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 matched. Keep the six default ids in a list you give (see
[`docs/settings.md`](docs/settings.md)). [`docs/settings.md`](docs/settings.md)).
- `SWWAF_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and - `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`: raise them for an app
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`: raise them for an app that takes uploads that takes uploads over 100 MiB or slower than 60 seconds.
over 100 MiB or slower than 60 seconds. - `SWRP_DEFAULT_DISPOSITION`: `allow` (the default) or `deny`, as above.
- `SWRP_TRUSTED_PROXIES`: the netblocks whose `X-Forwarded-For` `rproxy`
believes, by default the private address ranges.
- `SWWAF_LOOKUP_SOURCE`: `geojs` (the default), `file` or `off`, as above. - `SWWAF_LOOKUP_SOURCE`: `geojs` (the default), `file` or `off`, as above.
- `SWWAF_ADMIN_TOKEN` and `SWWAF_METRICS_TOKEN`: switch on the ban endpoints and - `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. the metrics, at `servewaf`'s own address.
- `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or - `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or
`SWWAF_ALERT_NTFY_URL`: where alerts go. `SWWAF_ALERT_NTFY_URL`: where alerts go.
- `SWWAF_INSTANCE_NAME`: the name in every log line, alert and metric, such as - `SWWAF_INSTANCE_NAME`: the name in every line `servewaf` logs, and in every
`fsn1app1/gitea`. alert and metric, such as `fsn1/servewaf`.
## Checking that it works ## Checking that it works
- `docker inspect --format '{{.State.Health.Status}}' <container>` says - `docker inspect --format '{{.State.Health.Status}}' <container>` says
`healthy` once `smallwebwaf` answers and the app accepts connections. A log `healthy` for each container of `smallwebwaf`:
line with `"msg":"starting"` gives the version, the address and every setting - for `rproxy`, once it answers and the app accepts connections, whether or
in effect, with secrets masked. not `servewaf` answers;
- A request to the site through traefik gets the app's answer, and its log line - for `servewaf`, once it answers.
has `"action":"forward"`. `https://<site>/_smallwebwaf/healthz` answers `ok`. - A log line with `"msg":"starting"` gives the version, the address and every
- To see a refusal without banning yourself, run the container with setting in effect, with secrets masked.
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. Its log - A request to the site through traefik gets the app's answer.
line has `"would_action":"banned"` and `"rule_ids":["env-file"]`. In `enforce` `https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
mode the same request bans your address for seven days, and your next request about the request have `"action":"forward"`.
makes the ban permanent. Lift the ban by editing `bans.json` (see "Bans" in - To see a refusal without banning yourself, run `servewaf` with
[`SPEC.md`](SPEC.md)). `SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
- With `SWWAF_METRICS_TOKEN` set, `servewaf`'s log lines have `"would_action":"banned"` and
`curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics` `"rule_ids":["env-file"]`. In `enforce` mode the same request bans your
answers with the metrics. address for seven days, and your next request makes the ban permanent. Lift
- `make example-app` builds the image and the example app on it, runs the app, the ban by editing `bans.json` (see "Bans" in [`SPEC.md`](SPEC.md)).
and checks it. It needs network access. It checks: - With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
- the health check; `servewaf`'s network:
- a request forwarded to the app; `curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
- `make example-app` builds the image and the example app on it, runs the app
with `rproxy` in its container and `servewaf` in another, and checks them. It
needs network access. It checks:
- the health checks;
- a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent; - a ban for a broken limit, and a ban for a probe made permanent;
- that only `servewaf`'s container changes the state directory's owner;
- with `servewaf` stopped, the app still answering through `rproxy`;
- with `servewaf` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`; - orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume; - bans surviving a new container on the same volume;
- a setting given as a file. - a setting given as a file.
@@ -276,21 +360,26 @@ 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 on it, runs them
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it at with a volume, and checks them as "Checking that it works" above describes.
the defaults. It sends through it a sample of gitea's ordinary use and the Then it removes the containers, the network, the volumes and the images. Needs
requests the Core Rule Set is known to refuse, and prints what `smallwebwaf` network access. `make example-app`.
did with each. Needs network access, and a docker daemon that pushes to a - `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
registry on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) `rproxy` and `servewaf`, three containers on one network. Every setting is at
records the results. `make gitea-requests`. its default, but for those that join the containers and the header that labels
each request. It sends through them a sample of gitea's ordinary use and the
requests the 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 `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the
results. `make gitea-requests`.
## Rationale ## Rationale
@@ -303,16 +392,16 @@ Small self-hosted sites receive a great deal of traffic nobody asked for:
Most of it comes from a small number of hosting networks and countries. A 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 single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of one service and left alone. 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 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 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 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 of it want a database and a web console. None of them can say "clients from
say "clients from these networks are banned after half as many requests as these networks are banned after half as many requests as anyone else". That is
anyone else". That is the useful thing to say when nearly all abuse comes from a the useful thing to say when nearly all abuse comes from a known list of AS
known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the numbers. [`EVALUATION.md`](EVALUATION.md) goes through the candidates one by
candidates one by one. one.
`smallwebwaf` fills that gap with: `smallwebwaf` fills that gap with:
@@ -320,8 +409,8 @@ candidates one by one.
- lower limits for the networks and countries abuse comes from; - lower limits for the networks and countries abuse comes from;
- bans that grow with repetition; - bans that grow with repetition;
- a log that is easy to search and ship; - a log that is easy to search and ship;
- one binary in the app's own container, with no database and no required - a site that keeps answering while the part that decides is down;
setting. - one binary, with no database and one required setting.
## Design ## Design
@@ -331,47 +420,36 @@ nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is [`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned. pinned.
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each - runit, a service supervisor, runs `smallwebwaf` and starts it again when it
as its own user, and starts either again when it exits. The app cannot touch exits. In an app's image built on the `smallwebwaf` image, it runs `rproxy`
the state files or the process of `smallwebwaf`. and the app side by side, each as its own user.
- `smallwebwaf` listens on port 8080 and passes requests to the app on - `rproxy` listens on port 8080 and passes requests to the app at
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it `SWRP_UPSTREAM_URL`. It answers the health check itself, and the refusals and
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under errors "`rproxy`" in [`SPEC.md`](SPEC.md) lists.
`/_smallwebwaf/`, on the same port. - `servewaf` listens on port 8080 of its own container. Nothing should route the
internet there. Its own endpoints, under `/_smallwebwaf/`, are for your admin
tools and your metrics collector.
- `rproxy` and `servewaf` use no token between them. Anything on their private
network can ask `servewaf` a question, or send it a report.
- The app sees the headers traefik sent, with traefik's address added to - The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
`127.0.0.1` and `::1` for forwarded headers, besides the private address `rproxy`'s address for forwarded headers. With `rproxy` in the app's
ranges. container, that address is `127.0.0.1` or `::1`, which the private address
- Both processes start with the container's environment variables, so every ranges leave out.
setting of this `smallwebwaf` process starts with `SWWAF_`, and the app can - The state files live in `/var/lib/smallwebwaf` in `servewaf`'s container. A
read a token given as one. A token given as a file that only the `smallwebwaf` volume mounted there keeps them across a deploy.
user can read is out of its reach (see "Settings given as files" in - `docker stop` gives requests in progress five seconds to finish. With `rproxy`
[`docs/settings.md`](docs/settings.md)). in the app's container, the app must keep answering for five seconds after
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps `SIGTERM` for that to help them.
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 "Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health checks, what
happens when a process stops, moving `smallwebwaf` to another port, and the happens when a process stops, moving a port, and the forwarded headers.
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and what each does.
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
+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"`
} }
+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)
+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,