6 Commits
Author SHA1 Message Date
clawbot 21cac70b50 Lower the default request and byte limits (closes #193)
check / check (push) Successful in 3m12s
New defaults, as sneak approved: 120 requests a minute, 1,500 an hour and 6,000 a day; bytes off a minute, 50G an hour and 100G a day. The README, `SPEC.md`, `docs/settings.md` and `docs/gitea.md` give the new figures and reasoning, and point Actions runners and shared addresses at `SWWAF_RATE_LIMIT_EXEMPT_NETS`. The README examples now hold against the new defaults.

An Actions runner that is not exempted makes more requests an hour than the new limit and will be banned.

Model: opus-5-5
2026-11-06 20:14:23 +01:00
clawbot 2ab57eb278 Install the image's Ubuntu packages from the normal mirrors (closes #190)
check / check (push) Successful in 3m6s
The image's `ca-certificates`, `nix-bin` and `runit` now come from Ubuntu's archive instead of a dated snapshot, whose service had failed image builds. apt fetches them over plain HTTP and checks each against the archive's signed package lists, so no CA certificates are borrowed from the Go image. The README, `SPEC.md` and `docs/image.md` say so; the amd64-only limit, which came from the removed hash check, is gone.

Rule cut against: `REPO_POLICIES.md` pins every external reference by hash; sneak's ruling at sneak/prompts#72 (comment) allows Ubuntu's own package index.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 20:14:18 +01:00
clawbot a63b7e4d2d Rename internal/proxy to internal/servewaf (closes #173)
check / check (push) Successful in 3m44s
The package passes nothing on since the one-process mode went: it answers rproxy's questions, takes its reports and answers servewaf's own endpoints. It is now named after the command it serves, with `git mv`, and its constructor is `servewaf.New`. `SPEC.md` and the other documents follow. No change of behaviour.

Model: opus-5-5
2026-11-06 20:02:17 +01:00
clawbot 3cb41f27a8 deploy/gitea: Gitea behind traefik, rproxy and servewaf, with its test (closes #178)
check / check (push) Successful in 3m50s
A compose setup that runs gitea 1.27.3 behind traefik, rproxy and servewaf on one shared network with IPv4 and IPv6, and `script/gitea-setup` (`make gitea-setup`), which brings it up and checks that a page load, a push and a clone pass through rproxy and are checked by servewaf. Containers on the shared network skip servewaf's checks; the gateways do not. gitea gets a secret key made per install and has sign-up off. `docs/gitea.md` explains each setting.

Judgement call: the Core Rule Set runs in `detect` mode here until #198 lands.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 19:46:38 +01:00
clawbot 6deb9d7581 README: sections, compose examples, env var table (closes #188) (#189)
check / check (push) Successful in 4m40s
2026-11-06 00:06:25 +01:00
clawbot 8632da556a rproxy tests give a loaded host time before their limits run out (closes #185) (#186)
check / check (push) Successful in 6m15s
2026-11-05 23:17:16 +01:00
58 changed files with 1268 additions and 924 deletions
+4 -25
View File
@@ -133,32 +133,11 @@ RUN mkdir /etc/sysusers.d \
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \ && echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
> /etc/sysusers.d/runit.conf > /etc/sysusers.d/runit.conf
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at # ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
# the snapshot moment, which is never earlier than the Ubuntu image above. # them over plain HTTP and checks each one against the archive's signed
# apt checks every package against the snapshot's InRelease files, and # package lists, so no CA certificates are needed.
# this step checks those against the hashes named here, which are those RUN apt-get update \
# of the amd64 archive: other architectures use Ubuntu's ports archive.
# apt also fetches the live archive's InRelease files, which change daily
# and which the install does not use. The snapshot service is HTTPS only
# and this image has no CA certificates yet, so this step uses the Go
# image's. The snapshot service answers 503 at times; apt retries a
# failed download, waiting twice as long before each retry, and this
# step gives it five retries, about half a minute in all, where its
# default of three gives up after seven seconds.
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
apt-get update --snapshot 20261001T000000Z \
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
-o Acquire::Retries=5 \
&& printf '%s\n' \
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
'64b3353f0bd4970b4f7271962245bcea9ff24d4cc7bea16b433f8a60e42ca3dd snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-backports_InRelease' \
'1d5041572116a8b23aabf79ac7439ad8af83d57ad3fb0f9aa0d4523ec10c5908 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-security_InRelease' \
| (cd /var/lib/apt/lists && sha256sum --check --strict) \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
--snapshot 20261001T000000Z \
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
-o Acquire::Retries=5 \
ca-certificates nix-bin runit \ ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+601 -271
View File
@@ -4,59 +4,45 @@
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the [@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
requests of scrapers, scanners and other abusers before they reach an app. requests of scrapers, scanners and other abusers before they reach an app.
## What it does
A web application firewall reads each request on its way to a web app and A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is one binary with two refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
commands that do this together: one image, with two commands that do this together:
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. It accepts - `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
requests on the app's behalf and asks `servewaf` about each one. It passes the routes to it. It asks `servewaf` about each request, passes the allowed ones
allowed ones to the app, and the app's answers back. It keeps nothing, and to the app, and passes the app's answers back. It keeps nothing and never
never touches disk. touches disk.
- `smallwebwaf servewaf` decides. It runs the checks, and keeps the bans, - `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
counters and history. One `servewaf` serves many sites. counters and each client's history. One `servewaf` serves many sites: a client
banned through one site is refused on all of them, and its requests on all of
them count toward one set of limits.
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy` The site keeps answering while `servewaf` is down, slow or restarting: without
waits at most 2 seconds for `servewaf`'s answer, and without one passes the an answer in time, `rproxy` passes the request to the app.
request to the app.
`rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf` It protects against:
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 - Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on bytes per minute, per hour and per day. The defaults leave
- Scrapers that fetch every page of a site, all day. Rate limiting gives each room for a person reading a site for hours. They catch a scraper that keeps up
client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves one request every 2 seconds. A client over a limit is refused and banned: for
room for programmatic use of an API, such as an automation fleet on another a while at first, for three times as long at each repeat, and permanently when
host. A fleet of about 20 agents makes about 120 requests a minute at its it keeps at it.
busiest. A gitea page opened with an empty cache loads about 40 files, so a
few such first visits fit in the minute limit too. The minute limit catches,
within seconds, a client firing requests as fast as it can. The hour limit
catches a client that keeps up more than about 5.5 a second for an hour. The
day limit catches a client that keeps up 4 a second around the clock. Counting
cannot catch a client that stays under all three limits, such as one that
keeps up 3 a second; only the protections below can. Byte limits give each
client 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over a limit
is refused and banned. The first ban lasts an hour. Each repeat within a day
makes the next ban three times as long. A ban that would last more than seven
days is permanent.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client for seven days at its first probe. Its default rule file bans such a client at its first probe, and its next request
next request during the ban makes the ban permanent. The default rule file during the ban makes the ban permanent. Your own rules, or a list of trap
does not cover probes for other apps, such as a WordPress login. A `ban` rule paths, ban probes for other apps, such as a WordPress login, the same way.
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
- Attacks carried in a request's URL or headers, such as SQL injection and path - Attacks carried in a request's URL or headers, such as SQL injection and path
traversal. The OWASP Core Rule Set is a published set of rules that recognise traversal. The OWASP Core Rule Set, a published set of rules that recognise
common web attacks. Coraza, a Go engine, runs those rules (see "Attack common web attacks, runs on every request through Coraza, a Go engine. A
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is request the rules take for an attack is refused.
refused.
- Abuse from networks and countries you do not want. You can: - Abuse from networks and countries you do not want. You can:
- refuse whole netblocks and countries; - refuse whole netblocks and countries;
- give the clients of chosen networks and countries a fraction of every - give the clients of chosen networks and countries a fraction of every
limit, so that they are banned sooner; limit, so that they are banned sooner;
- refuse or limit clients on a blocklist or a DNS blocklist, or with a high - refuse or limit clients on a blocklist, a DNS blocklist, or with a high
AbuseIPDB score; AbuseIPDB score;
- ban clients named by your own CrowdSec engine. - ban clients named by your own CrowdSec engine.
- Slow and oversized requests. Every request and every answer has a time limit - Slow and oversized requests. Every request and every answer has a time limit
@@ -64,29 +50,26 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
For the operator, it: For the operator, it:
- logs each request as JSON lines, saying what it did and why (see - logs each request as JSON lines, saying what it did and why;
[`docs/request-log.md`](docs/request-log.md)); - sends alerts to a webhook, Slack or ntfy;
- sends alerts to a webhook, Slack or ntfy (see - serves Prometheus metrics;
[`docs/alerts.md`](docs/alerts.md));
- serves Prometheus metrics (see [`docs/metrics.md`](docs/metrics.md));
- keeps its bans, counters and history in JSON files you can read and edit, so - keeps its bans, counters and history in JSON files you can read and edit, so
that a restart loses nothing and a ban can be lifted with an editor (see that a restart loses nothing and a ban can be lifted with an editor.
[`docs/state-files.md`](docs/state-files.md)).
Not built, and not planned: Not built, and not planned:
- TLS and routing by host name. Both stay with traefik, the reverse proxy in - TLS and routing by host name. Both stay with traefik, the reverse proxy in
front of `rproxy`. front of `rproxy`.
- Browser challenges such as captchas. - Browser challenges such as captchas.
- A web console. - A web console, or a configuration file.
- Defence against floods large enough to fill the host's network link. - Defence against floods large enough to fill the host's network link.
## Getting started ## Getting started
Build the image from a clone. `make docker` runs the tests and the linter, then ### The image
builds the image, tagged `smallwebwaf`. It builds for amd64, on an amd64 host
only: the `Dockerfile` checks Ubuntu's package lists against the hashes of the No image is published. Build it from a clone. `make docker` runs the tests and
amd64 archive. the linter, then builds the image, tagged `smallwebwaf`.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -94,65 +77,38 @@ cd smallwebwaf
make docker make docker
``` ```
Push the image to a registry your hosts pull from. Push the image to a registry your hosts pull from, and name it there by digest.
The image runs `rproxy` by default. To run `servewaf` from the same image, set The image runs `rproxy` by default. To run `servewaf` from the same image, set
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
container, once `servewaf` answers.
### The usual setup: three containers ### The usual setup: three containers
The usual setup is three containers on one private network: The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which traefik routes to; - `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
- `servewaf`, with a volume for its state files. names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
`/var/lib/smallwebwaf` for its state files.
```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` Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port (`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 of the app or of `servewaf`. Anything on that network can reach the app around
`rproxy`, and can ask `servewaf` questions. `rproxy`, and can ask `servewaf` questions, so the network holds only what you
trust. Each further app adds its own container and its own `rproxy`, whose
`deploy/gitea` holds this setup for gitea behind traefik, as a Docker Compose `SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
file, which [`docs/gitea.md`](docs/gitea.md) explains. of gitea" examples below show this shape.
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 ### `rproxy` in the app's container
An app's image can also be built on the `smallwebwaf` image, so that `rproxy` An app's image can instead 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 runs beside the app in one container. `servewaf` still runs in a container of
`Dockerfile` starts `FROM` the `smallwebwaf` image, named by digest, and adds: 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;
@@ -194,39 +150,440 @@ main "$@"
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `rproxy` and the app side by side, each as its own user. Run entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
app, and the "`rproxy` beside the app" example below runs one.
`deploy/example-app` is such an app. `make example-app` builds it and checks it The app sees the headers traefik sent, with traefik's address added to
(see "Checking that it works" below). To run `rproxy` and `servewaf` on the host `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
while working on them, see `script/run` under "Entrypoints" below. `rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
on the private network, which the usual default of a trusted-proxy setting
covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
ranges leave out, so the app's own setting must name them too, as `app.run`
does.
### When `servewaf` is down, slow or overloaded ### Checking that it works
`SWRP_DEFAULT_DISPOSITION` decides what `rproxy` does with a request when - `docker inspect --format '{{.State.Health.Status}}' <container>` says
`servewaf`: `healthy` for each container of `smallwebwaf`.
- A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer.
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
about the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes.
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
`servewaf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints").
- cannot be connected to; ## Configuration
- 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. Every setting is an environment variable, read once at start; changing one means
`deny` answers `503` instead. Either way the request gets no report, so its restarting the container. `rproxy` reads only the `SWRP_` settings, and
bytes are not counted and it is not added to its client's history. When `servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
`servewaf` does answer, a `deny` or an error goes to the visitor as `servewaf` default, chosen for a service on the open internet. A setting that needs an
gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part. account, a key, a token or an alert destination stays off until you give one.
## What happens to a request - Any `SWWAF_` setting can be given as a file instead, for secrets and long
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end. Setting both
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never
touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`.
- A count is a whole number of requests, such as `1000`.
- A time in milliseconds is a whole number above zero, such as `2000`.
- A list is comma separated. A list set to an empty value is an empty list, and
replaces the default.
- A netblock is an address and a prefix length, such as `10.0.0.0/8`; a bare
address stands for itself alone. A country is a two-letter code in either
case, such as `de`. An AS number is `AS` and the number, such as `AS64496`. A
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it.
- An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`.
A request goes through fifteen steps, in this order. Once `servewaf` refuses a Every setting the code reads, with its default:
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 | Variable | Default | What it does |
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers. | ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
## docker-compose examples
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
`make docker` gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Only "In front of gitea"
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
out.
### Smallest setup
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
files, every setting at its default.
```yaml
services:
app:
# Your app, which listens on port 8081.
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
```
### Custom per-address limits
For a site with no API:
- tighter limits for everyone;
- no limits for the office and the automation host;
- no checks for monitoring;
- a refused netblock;
- a smaller share of the limits for two hosting networks and one country;
- trap paths;
- `429` for a refused client.
```yaml
services:
app:
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
# Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site.
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
SWWAF_RATE_LIMIT_PER_HOUR: "600"
SWWAF_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
SWWAF_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m
volumes:
- servewaf-state:/var/lib/smallwebwaf
volumes:
servewaf-state:
```
### In front of gitea
gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time;
- the response size limit raised, for clones over 1 GiB;
- the Actions runners exempt from counting;
- the default request and byte limits kept.
`traefik` is the network traefik is on, made outside this file.
```yaml
services:
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example/
# The host and the port of the SSH clone URLs gitea shows.
GITEA__server__SSH_DOMAIN: git.example
GITEA__server__SSH_PORT: "2222"
# gitea's TCP peer is rproxy, on the private network.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
volumes:
- gitea-data:/data
networks:
- site
ports:
# SSH does not pass through smallwebwaf.
- "2222:22"
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects and image layers over 100 MiB, or slower
# than a minute, are cut off at the defaults.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "600000"
# A clone larger than 1 GiB is cut off at the default.
SWRP_MAX_RESPONSE_SIZE: 4G
networks:
- site
- traefik
labels:
traefik.enable: "true"
# rproxy is on two networks; traefik reaches it on this one.
traefik.docker.network: traefik
traefik.http.routers.git.rule: Host(`git.example`)
traefik.http.routers.git.entrypoints: websecure
traefik.http.routers.git.tls.certresolver: letsencrypt
traefik.http.services.git.loadbalancer.server.port: "8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- site
networks:
site:
traefik:
external: true
volumes:
gitea-data:
servewaf-state:
```
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
defaults do in front of gitea, request by request.
### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `servewaf` is down,
and `servewaf`:
- observes before it enforces;
- looks addresses up in a local database rather than GeoJS;
- sends alerts to ntfy;
- reads its ntfy token from a file.
```yaml
services:
app:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
# Answer 503 rather than pass a request on while servewaf is down.
SWRP_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
SWWAF_LOOKUP_SOURCE: file
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes:
- servewaf-state:/var/lib/smallwebwaf
- /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
volumes:
servewaf-state:
```
The database is mounted by its directory, not by file, so that a replaced
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
with mode `0400`. This one holds an access token the ntfy server issued, which
`ntfy token add <user>` prints there:
```sh
mkdir -p /srv/smallwebwaf/tokens
# <token> is the access token that ntfy printed.
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
chmod 0400 /srv/smallwebwaf/tokens/ntfy
```
## Design
How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request
A request goes through these steps, in this order. Once `servewaf` refuses a
request, the steps after that are skipped, apart from the report, and nothing of
the request reaches the app.
`rproxy` takes the first two: `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 visitor's address is worked out, and `servewaf` is asked about the 2. The visitor's address is worked out from `X-Forwarded-For`, believed only
request. from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
visitor sent it, with the first 64 KiB of its body.
`servewaf` takes the checks: `servewaf` takes the checks:
@@ -235,7 +592,8 @@ nothing of the request reaches the app. "What happens to a request" in
5. The lookup of the client's AS number and country. 5. The lookup of the client's AS number and country.
6. The country lists. 6. The country lists.
7. The reputation sources. 7. The reputation sources.
8. The client's limit percentage. 8. The client's limit percentage, from its AS number, its country or a
reputation hit.
9. The rate limits. 9. The rate limits.
10. The trap paths. 10. The trap paths.
11. The rule files. 11. The rule files.
@@ -245,85 +603,113 @@ nothing of the request reaches the app. "What happens to a request" in
`rproxy` takes the rest, with `servewaf` counting at the end: `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, streaming.
15. After the answer, `rproxy` sends `servewaf` a report. For an allowed 15. After the answer, `rproxy` sends `servewaf` a report with the status and the
request, `servewaf` counts the byte limits and adds the request to the bytes each way. For an allowed request, `servewaf` counts the bytes toward
client's history. 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`. 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` and bans nobody, since the Core Rule Set has
Core Rule Set match answers `403`. `rproxy` itself answers `405` to a method it false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
does not pass on, and the status for a size or time limit, such as `413` for a instead. A request over the header limits of step 12 is answered `431`, and bans
body too large (see "`rproxy`" in [`SPEC.md`](SPEC.md)). nobody either. `rproxy` itself answers `405` to a method it does not pass on,
and the status for a size or time limit, such as `413` for a body too large.
## Settings you are likely to touch ### Clients and bans
Each setting is an environment variable, read at start. Those of `servewaf` - A client is one IPv4 address or one IPv6 netblock of
start with `SWWAF_`, and those of `rproxy` with `SWRP_`. `SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the - Each limit is counted over a sliding window with a few integers per client.
values it takes. The ones most deployments touch: The table of clients and the table of bans each have a bound
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin
lifts it.
- Every ban carries notes: the client's AS number and country, the limit it
broke or the rule it matched, the request that caused it, and its earlier
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
- `SWWAF_MODE=observe`: `servewaf` refuses nothing and makes no ban of its own. ### Attack detection
A request it would have refused is passed to the app, and its log line says
what `enforce` mode would have done. For trying a configuration before
enforcing it. The limits of `rproxy` still apply.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
clients skip `servewaf`'s checks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
byte limits, such as a machine that talks to the app all day.
- `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php` in front of a code forge. A request for one bans
the client.
- `SWWAF_WAF_DISABLED_RULES`: Core Rule Set rule ids to switch off when a rule
refuses your app's ordinary requests. The log line names the rules a request
matched. Keep the six default ids in a list you give (see
[`docs/settings.md`](docs/settings.md)).
- `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`: raise them for an app
that takes uploads 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_ADMIN_TOKEN` and `SWWAF_METRICS_TOKEN`: switch on the ban endpoints and
the metrics, at `servewaf`'s own address.
- `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or
`SWWAF_ALERT_NTFY_URL`: where alerts go.
- `SWWAF_INSTANCE_NAME`: the name in every line `servewaf` logs, and in every
alert and metric, such as `fsn1/servewaf`.
## Checking that it works - The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the
rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
requests to a code forge through, such as git over HTTP and sign-in through
Git Credential Manager. No setting undoes them; "Attack detection" in
[`SPEC.md`](SPEC.md) lists them.
- Rule files are plain text, one rule to a line, edited by hand and read again
while `servewaf` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)).
- `docker inspect --format '{{.State.Health.Status}}' <container>` says ### State files
`healthy` for each container of `smallwebwaf`:
- for `rproxy`, once it answers and the app accepts connections, whether or `servewaf` keeps everything in memory, and the five JSON files in
not `servewaf` answers; `SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
- for `servewaf`, once it answers. `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
- A log line with `"msg":"starting"` gives the version, the address and every renamed over the real one, so a crash leaves a complete file. `servewaf` notices
setting in effect, with secrets masked. a file edited on disk and takes the edit in; an edit that does not parse is set
- A request to the site through traefik gets the app's answer. aside as `<name>.bad`, and never stops the running process (see
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines [`docs/state-files.md`](docs/state-files.md)).
about the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with ### The image and its processes
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and - The image is built on Ubuntu 26.04 LTS, pinned by digest. Its Ubuntu packages
`"rule_ids":["env-file"]`. In `enforce` mode the same request bans your come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
address for seven days, and your next request makes the ban permanent. Lift `Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
the ban by editing `bans.json` (see "Bans" in [`SPEC.md`](SPEC.md)). same package each time it is built (see [`docs/image.md`](docs/image.md)).
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on - runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
`servewaf`'s network: and gid 65532) and starts it again when it exits. The `run` script of
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`. `servewaf` gives the state directory to the `smallwebwaf` user at each start,
- `make example-app` builds the image and the example app on it, runs the app so a volume needs no change of owner.
with `rproxy` in its container and `servewaf` in another, and checks them. It - `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
needs network access. It checks: under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
- the health checks; at its own address on the private network; nothing routes the internet there.
- a request passed to the app; - `docker stop` gives requests in progress five seconds to finish. With `rproxy`
- a ban for a broken limit, and a ban for a probe made permanent; in the app's container, the app must keep answering for five seconds after
- that only `servewaf`'s container changes the state directory's owner; `SIGTERM` for that to help them.
- with `servewaf` stopped, the app still answering through `rproxy`; - Only faults found at start stop a process: an invalid setting, a rule file or
- with `servewaf` started again, bans applying again; state file that does not parse, a lookup database that cannot be read, or an
- orderly stops with `sv stop` and `docker stop`; address it cannot listen on. Once running, a failing lookup service,
- bans surviving a new container on the same volume; reputation source or alert destination never takes `servewaf` down.
- a setting given as a file.
### Packages
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
- `internal/smallwebwaf`: the process, as the command it is given, and the
health check.
- `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app.
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/alerts`: one check or source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the
metrics.
## Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore `robots.txt` and crawl every commit of every repository
on a public git server;
- scanners walking through lists of WordPress and `.env` paths;
- credential-guessing bots.
Most of it comes from a small number of hosting networks and countries. A
single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of its services and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want a database and a web console. None of them can say "clients from
these networks are banned after half as many requests as anyone else".
[`EVALUATION.md`](EVALUATION.md) goes through the candidates one by one.
## Entrypoints ## Entrypoints
@@ -371,88 +757,33 @@ on the host.
- `servewaf` listens on port 8090; - `servewaf` listens on port 8090;
- its state files go in `bin/state`; - its state files go in `bin/state`;
- its rule files are those of `share/rules.d`. - its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app on it, runs them - `script/example-app`: builds the image and the example app
with a volume, and checks them as "Checking that it works" above describes. (`deploy/example-app`) on it, runs the app with `rproxy` in its container and
Then it removes the containers, the network, the volumes and the images. Needs `servewaf` in another, and checks them. Then it removes the containers, the
network access. `make example-app`. network, the volumes and the images. Needs network access. `make example-app`.
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;
- 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`;
- bans surviving a new container on the same volume;
- a setting given as a file.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind - `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
`rproxy` and `servewaf`, three containers on one network. Every setting is at `rproxy` and `servewaf`, three containers on one network. Every setting is at
its default, but for those that join the containers and the header that labels its default, but for those that join the containers and
each request. It sends through them a sample of gitea's ordinary use and the `SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
requests the Core Rule Set is known to refuse, and prints what `servewaf` did It sends through them a sample of gitea's ordinary use and the requests the
with each. Needs network access, and a docker daemon that pushes to a registry Core Rule Set is known to refuse, and prints what `servewaf` did with each.
on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the Needs network access, and a docker daemon that pushes to a registry on
results. `make gitea-requests`. `localhost` over plain HTTP. `make gitea-requests`.
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`, - `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
the containers, the network, the volumes and the image it built. Needs network the containers, the network, the volumes and the image it built. Needs network
access. `make gitea-setup`. access. `make gitea-setup`.
## Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore `robots.txt` and crawl every commit of every repository
on a public git server;
- scanners walking through lists of WordPress and `.env` paths;
- credential-guessing bots.
Most of it comes from a small number of hosting networks and countries. A
single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of its services and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want a database and a web console. None of them can say "clients from
these networks are banned after half as many requests as anyone else". That is
the useful thing to say when nearly all abuse comes from a known list of AS
numbers. [`EVALUATION.md`](EVALUATION.md) goes through the candidates one by
one.
`smallwebwaf` fills that gap with:
- per-client limits that ban;
- lower limits for the networks and countries abuse comes from;
- bans that grow with repetition;
- a log that is easy to search and ship;
- a site that keeps answering while the part that decides is down;
- one binary, with no database and one required setting.
## Design
The `smallwebwaf` image is built on Ubuntu 26.04 LTS, pinned by digest, with
nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
`nix-env -iA nixpkgs.<name>` and gets the same package each time it is built.
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs `smallwebwaf` and starts it again when it
exits. In an app's image built on the `smallwebwaf` image, it runs `rproxy`
and the app side by side, each as its own user.
- `rproxy` listens on port 8080 and passes requests to the app at
`SWRP_UPSTREAM_URL`. It answers the health check itself, and the refusals and
errors "`rproxy`" in [`SPEC.md`](SPEC.md) lists.
- `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
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
`rproxy`'s address for forwarded headers. With `rproxy` in the app's
container, that address is `127.0.0.1` or `::1`, which the private address
ranges leave out.
- The state files live in `/var/lib/smallwebwaf` in `servewaf`'s container. A
volume mounted there keeps them across a deploy.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health checks, what
happens when a process stops, moving a port, and the forwarded headers.
"Architecture" in [`SPEC.md`](SPEC.md) lists the packages and what each does.
## TODO ## TODO
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending - Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending
@@ -464,8 +795,7 @@ happens when a process stops, moving a port, and the forwarded headers.
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers - [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
and misses, and why none was adopted. and misses, and why none was adopted.
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows. - [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
- [`docs/settings.md`](docs/settings.md): every setting, its default and the - [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
values it takes.
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file. - [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
`/_smallwebwaf/`, with examples. `/_smallwebwaf/`, with examples.
+33 -45
View File
@@ -66,10 +66,10 @@ to them.
one it does not know, the binary prints the commands and exits with one it does not know, the binary prints the commands and exits with
status 2. status 2.
- `internal/config`: reads the settings, the one place they are read. - `internal/config`: reads the settings, the one place they are read.
- `internal/proxy`: `servewaf`'s server. It runs the checks in `check`, and - `internal/servewaf`: `servewaf`'s server. It runs the checks in `check`,
holds `servewaf`'s question, answer and report. It answers `servewaf`'s and holds `servewaf`'s question, answer and report. It answers
own endpoints in `answerOwnEndpoint`. It counts bytes, refusals and `servewaf`'s own endpoints in `answerOwnEndpoint`. It counts bytes,
anomalies, and writes the log lines. refusals and anomalies, and writes the log lines.
- `internal/rproxy`: the reverse proxy in front of one app that asks - `internal/rproxy`: the reverse proxy in front of one app that asks
`servewaf` about each request. It passes a request to the app with the `servewaf` about each request. It passes a request to the app with the
standard library's `httputil.ReverseProxy`, within the time and size standard library's `httputil.ReverseProxy`, within the time and size
@@ -397,32 +397,31 @@ Conventions:
### Request rate limits, per client ### Request rate limits, per client
Breaking one bans the client. The defaults suit programmatic use of an API, such Breaking one bans the client. The defaults let people read a site and stop
as an automation fleet on another host, and still stop abusive bots. A fleet of scrapers that send many requests slowly over hours or days. The hour limit is
20 agents, each making a request every 10 seconds at its busiest, makes 120 the main one.
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
a day. It stays under all three limits.
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120 - Per minute: 120, 2 requests a second. It leaves room for a person opening two
a minute and a few quick first visits with an empty cache. A gitea page opened pages at once with an empty cache. A gitea page opened with an empty cache
with an empty cache loads about 40 files. A client firing requests as fast as loads 20 to 60 files, and a handful once cached.
it can passes the limit within seconds. - Per hour: 1,500. A person reading code in gitea for a whole hour, a page every
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that 20 seconds, makes about 900 requests. A scraper making one request every 2
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute, seconds makes 1,800 an hour, more than the limit.
under the minute limit. The fleet's 7,200 an hour is about a third of it. - Per day: 6,000, four hours at the hour limit. A scraper making one request
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that every 10 seconds makes 8,640 a day, more than the limit.
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and
14,400 an hour, it stays under the other two limits.
Counting cannot catch a client that stays under all three limits, such as one Counting cannot catch a client that stays under all three limits, about 4
that keeps up 3 requests a second. Only the other checks can catch an abuser requests a minute around the clock. Only the other checks can catch an abuser
that slow. They include the trap paths, the rule files, the Core Rule Set and that slow. They include the trap paths, the rule files, the Core Rule Set and
the blocklists. An abuser that trips none of them is not caught. A machine that the blocklists. An abuser that trips none of them is not caught.
keeps up more than 500 requests a minute passes the minute limit. One that keeps
up more than about 5.5 a second for an hour passes the hour limit. One that Some legitimate clients pass the limits:
keeps up more than about 3.5 a second all day passes the day limit, as two busy
Actions runners on one address do. Such a machine's address belongs in - An Actions runner asks gitea for work all day and can pass the limits.
`SWWAF_RATE_LIMIT_EXEMPT_NETS`. - Everyone behind one shared address, such as an office, shares one client's
limits.
Their addresses belong in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as `SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
static assets or health checks; their bytes are not counted either. A prefix is static assets or health checks; their bytes are not counted either. A prefix is
@@ -435,12 +434,10 @@ matched the same way.
### Byte limits, per client ### Byte limits, per client
The hour and day limits sit about twice above what 40 people busy with heavy web By default there is no minute limit, so a client can download in a burst at full
apps move together on one address. Each moves about 256 MiB an hour, for ten speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
hours a day. A response's bytes are counted when it ends (see step 15). So the allow several downloads of a few GiB each. A response's bytes are counted when
minute limit holds three of the largest responses, with their requests, ending it ends (see step 15).
in the same minute. No single download then breaks a limit. An operator who
raises `SWRP_MAX_RESPONSE_SIZE` must raise the byte limits too.
### Lookup of AS number and country ### Lookup of AS number and country
@@ -1544,20 +1541,11 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
- The image is Ubuntu's newest long-term support release, pinned by digest, and - The image is Ubuntu's newest long-term support release, pinned by digest, and
moves to the next LTS release when that ships. Its packages come from Ubuntu's moves to the next LTS release when that ships. Its packages come from Ubuntu's
snapshot service, the archive as it was at the moment the Dockerfile names, archive, whose packages change with every update.
rather than the live archive, whose packages change with every update. The
snapshot is never older than the pinned Ubuntu image, since older packages can
need older versions of packages the image holds, and it moves forward with the
image's digest. The Dockerfile names the hash of each of the snapshot's
`InRelease` files, which cover every package list and so every package, and
the build checks them before installing. The live archive's `InRelease` files,
which `apt-get update` also fetches and the install does not use, are left out
of the check. The hashes are the amd64 archive's, so the image is built for
amd64 only.
- The Ubuntu image has no CA certificates. Without them Nix cannot download - The Ubuntu image has no CA certificates. Without them Nix cannot download
packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed
by name. The snapshot itself is fetched over HTTPS with the CA certificates of by name. apt fetches the packages over plain HTTP and checks each one against
the Go image that builds `smallwebwaf`, itself pinned by digest. the archive's signed package lists, so the install needs no CA certificates.
- nixpkgs comes from a release file on `releases.nixos.org`, named with its - nixpkgs comes from a release file on `releases.nixos.org`, named with its
hash, not from a GitHub archive, whose bytes can change. Root uses Nix hash, not from a GitHub archive, whose bytes can change. Root uses Nix
directly; no Nix daemon runs. `nix-bin` creates no build users, so directly; no Nix daemon runs. `nix-bin` creates no build users, so
+24 -9
View File
@@ -3,7 +3,14 @@
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest; # - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
# - replace git.example.org with the site's host name; # - replace git.example.org with the site's host name;
# - replace each 10.99.0 with the first three numbers of a /24 subnet that # - replace each 10.99.0 with the first three numbers of a /24 subnet that
# no other network on the host uses. # no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
# X-Forwarded-For only from those by default. Replace each fd10:99 with
# the first two hextets of an unused /64 subnet whose third and fourth
# hextets are 0, as in fd10:99::/64;
# - set GITEA_SECRET_KEY to the output of
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
# once per install, and keep it.
# Then run `docker compose up --detach` in this directory. # Then run `docker compose up --detach` in this directory.
# script/gitea-setup checks this setup. # script/gitea-setup checks this setup.
@@ -54,9 +61,12 @@ services:
# Gitea starts at its defaults, with SQLite, instead of showing its # Gitea starts at its defaults, with SQLite, instead of showing its
# install page to whoever opens it first. # install page to whoever opens it first.
GITEA__security__INSTALL_LOCK: "true" GITEA__security__INSTALL_LOCK: "true"
# rproxy's address. Gitea then takes each visitor's address from GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
# Accounts are made with `gitea admin user create`.
GITEA__service__DISABLE_REGISTRATION: "true"
# rproxy's addresses. Gitea then takes each visitor's address from
# the X-Real-Ip header traefik sets and rproxy passes on. # the X-Real-Ip header traefik sets and rproxy passes on.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2 GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
volumes: volumes:
- gitea-data:/data - gitea-data:/data
networks: networks:
@@ -82,6 +92,7 @@ services:
networks: networks:
shared: shared:
ipv4_address: 10.99.0.2 ipv4_address: 10.99.0.2
ipv6_address: fd10:99::2
logging: *logging logging: *logging
restart: unless-stopped restart: unless-stopped
@@ -91,8 +102,8 @@ services:
SMALLWEBWAF_COMMAND: servewaf SMALLWEBWAF_COMMAND: servewaf
# The containers on the shared network skip the checks. Add the # The containers on the shared network skip the checks. Add the
# address of an automation fleet on another host here too; without # address of an automation fleet on another host here too; without
# that, the default request limits are sized for such a fleet. # that, it is counted like any client.
SWWAF_ALLOW_NETS: 10.99.0.128/25 SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
# In block mode the Core Rule Set refuses everyday Gitea searches # In block mode the Core Rule Set refuses everyday Gitea searches
# and file names, and 31 refusals in a minute ban the address. # and file names, and 31 refusals in a minute ban the address.
SWWAF_WAF_MODE: detect SWWAF_WAF_MODE: detect
@@ -104,16 +115,20 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
# The network traefik and Gitea share. Its containers get addresses in its # The network traefik and Gitea share. Its containers get addresses in the
# upper half, which servewaf lets skip the checks. The gateway, the host's # upper half of each subnet, which servewaf lets skip the checks. Each
# own address on it, stays out of that half: requests docker passes on from # subnet's gateway, the host's own address on it, stays out of that half:
# a published port, such as those of visitors over IPv6, can come from it. # requests docker passes on from a published port can come from it.
shared: shared:
enable_ipv6: true
ipam: ipam:
config: config:
- subnet: 10.99.0.0/24 - subnet: 10.99.0.0/24
ip_range: 10.99.0.128/25 ip_range: 10.99.0.128/25
gateway: 10.99.0.1 gateway: 10.99.0.1
- subnet: fd10:99::/64
ip_range: fd10:99:0:0:8000::/65
gateway: fd10:99::1
volumes: volumes:
gitea-data: gitea-data:
+42 -38
View File
@@ -20,14 +20,22 @@ To start it:
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's 1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
host name. host name.
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no 2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
other network on the host uses. other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
first two hextets of an unused `/64` subnet whose third and fourth hextets
are 0, as in `fd10:99::/64`.
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest. 3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
4. Run `docker compose up --detach` in `deploy/gitea`. 4. Set `GITEA_SECRET_KEY` to the output of
5. Make gitea's first user with `gitea admin user create`, in gitea's container, `docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
install, and keep the key.
5. Run `docker compose up --detach` in `deploy/gitea`.
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
as the user `git`. as the user `git`.
gitea starts at its defaults, with SQLite, instead of showing its install page Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
to whoever opens it first. traefik answers with a certificate it made itself at its defaults, with SQLite. traefik answers with a certificate it made itself
until you add a certificate resolver, as for your other sites. SSH to gitea does until you add a certificate resolver, as for your other sites. SSH to gitea does
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it. not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
@@ -64,40 +72,42 @@ A clone or a download is a response:
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone - `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
or download that takes longer is cut off. or download that takes longer is cut off.
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone - `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
is larger needs it raised, and the byte limits with it ("Byte limits, per is larger needs it raised.
client" in `SPEC.md`).
The byte limits stay at their defaults. Two uploads of 2 GiB from one address in The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
one minute pass the minute's limit of 4 GiB, and ban the address. GiB a day.
## The checks ## The checks
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule `SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
ids, and refuses nothing. In `block` mode, the default, it refuses everyday ids, and refuses nothing. In `block` mode, the default, it refuses everyday
gitea searches and file names, which "What the Core Rule Set refuses in `block` gitea searches and file names. The section "What the Core Rule Set refuses in
mode" below lists. Each such refusal counts toward the error burst, so 31 in one `block` mode" below lists them. Each such refusal counts toward the error burst,
minute ban the address. so 31 in one minute ban the address.
Every other check stays at its default. A client may make 500 requests a minute, Every other check stays at its default. A client may make 120 requests a minute,
20,000 an hour and 300,000 a day. Archive downloads and blame and history pages 1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
are what scrapers hammer, and these limits do most of the work there. what scrapers hammer, and these limits do most of the work there.
## The shared network ## The shared network
Containers on the shared network skip `servewaf`'s checks: Containers on the shared network skip `servewaf`'s checks:
- The shared network's subnet is `10.99.0.0/24`. Docker gives its containers - The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
addresses in the upper half, `10.99.0.128/25`, which `SWWAF_ALLOW_NETS` names. gives its containers addresses in the upper half of each, `10.99.0.128/25` and
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
- traefik is known on the shared network by the site's host name. So a container - traefik is known on the shared network by the site's host name. So a container
there that asks for the site reaches traefik from its own address. An there that asks for the site reaches traefik from its own address. An
automation fleet in such a container skips the checks. automation fleet in such a container skips the checks.
- The network's gateway, `10.99.0.1`, is the host's own address on it. Requests - Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
docker passes on from a published port can come from it, such as those of on it. Requests docker passes on from a published port can come from the
visitors over IPv6. So the gateway is left out of `SWWAF_ALLOW_NETS`. gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
`servewaf` checks it as itself.
- A container on the shared network that asks for the site at the host's public - A container on the shared network that asks for the site at the host's public
address arrives from the gateway. It is checked like any visitor. address arrives from the gateway. It is checked like any visitor.
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`. - A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
Without that, the default request limits are sized for such a fleet. Without that, it is counted like any client.
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it - traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
so: otherwise a visitor could name an address on the shared network, and skip so: otherwise a visitor could name an address on the shared network, and skip
the checks. the checks.
@@ -106,10 +116,10 @@ Containers on the shared network skip `servewaf`'s checks:
## gitea's settings ## gitea's settings
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed address, `10.99.0.2`. - `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
gitea then takes each visitor's address from the `X-Real-Ip` header, which and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
traefik sets and `rproxy` passes on. Without it, gitea logs every visitor as header, which traefik sets and `rproxy` passes on. Without it, gitea logs
`rproxy`. every visitor as `rproxy`.
- `INSTALL_LOCK` is `true`, as "The setup" above says. - `INSTALL_LOCK` is `true`, as "The setup" above says.
## Methods ## Methods
@@ -134,20 +144,14 @@ in the query string, as `?token=`, is then in the log. Send it in the
A runner on the shared network skips the checks. One on another host is counted A runner on the shared network skips the checks. One on another host is counted
like any client. A runner older than version 0.4 (April 2026) asks for work like any client. A runner older than version 0.4 (April 2026) asks for work
every 2 seconds, 43,200 requests a day. While it runs a job, it reports the every 2 seconds, 1,800 requests an hour. While it runs a job, it reports the
job's log and state every second instead, two requests a second. So one runner job's log and state every second instead, two requests a second.
busy all day sends 120 requests a minute and 172,800 a day, under the request
limits.
Several runners on one address can pass them: So one idle runner makes 1,800 requests an hour, more than the hour limit of
1,500. A busy one, at two requests a second, passes the minute limit of 120
- Two busy all day make 345,600 requests, and pass the day limit of 300,000. within two minutes. Its address is then banned, its running jobs fail, and each
- Three busy at once make 21,600 an hour, and pass the hour limit of 20,000. repeat within a day makes the ban longer. Newer runners ask for work less often.
- Five busy at once make 600 a minute, and pass the minute limit of 500. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
Their address is then banned, their running jobs fail, and each repeat within a
day makes the ban longer. Newer runners ask for work less often. Put the
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set refuses in `block` mode ## What the Core Rule Set refuses in `block` mode
+3 -6
View File
@@ -8,8 +8,7 @@ is pinned that way.
## Contents ## Contents
- Ubuntu 26.04 LTS, pinned by digest. - Ubuntu 26.04 LTS, pinned by digest.
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, from the snapshot of its - `ca-certificates`, `nix-bin` and `runit` from Ubuntu's archive.
archive at `20261001T000000Z`.
- Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as - Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as
`nixpkgs`. No Nix daemon; `build-users-group =` in `/etc/nix/nix.conf`. Root's `nixpkgs`. No Nix daemon; `build-users-group =` in `/etc/nix/nix.conf`. Root's
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`. Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
@@ -26,10 +25,8 @@ is pinned that way.
## Pins ## Pins
- Ubuntu's packages: the snapshot service, `--snapshot 20261001T000000Z` on both - Ubuntu's packages are not pinned: they come from the live archive, whose
`apt-get update` and `apt-get install`. The SHA-256 hash of each of the packages change with every update.
snapshot's `InRelease` files is named in the `Dockerfile` and checked after
`apt-get update`. The hashes are the amd64 archive's.
- nixpkgs: the release file `nixexprs.tar.xz` of - nixpkgs: the release file `nixexprs.tar.xz` of
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its `nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
SHA-256 hash. SHA-256 hash.
+18 -15
View File
@@ -99,19 +99,22 @@ chooses between the two (see "The image's command" below).
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
requests a client may make in a minute. A count or `off`. The default catches, client may make in a minute. A count or `off`. The default leaves room for a
within seconds, a client firing requests as fast as it can. It leaves room for person opening two gitea pages at once with an empty cache, up to 60 files
a fleet of about 20 agents at its busiest, about 120 requests a minute. It each.
also leaves room for a few quick first visits to gitea with an empty cache, - `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
about 40 files each. make in an hour. A count or `off`. It is the main limit against scrapers. A
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `20000`, about 5.5 a second): the most person reading code in gitea for an hour, a page every 20 seconds, makes
requests a client may make in an hour. A count or `off`. The default leaves about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
room for an automation fleet at work. It catches a client that keeps up more more than the limit.
than about 5.5 a second for an hour. - `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
- `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most make in a day. A count or `off`. The default is four hours at the hour limit.
requests a client may make in a day. A count or `off`. The default catches a A scraper making one request every 10 seconds makes 8,640 a day, more than the
scraper that keeps up 4 a second around the clock. limit.
- An Actions runner asks gitea for work all day and can pass the limits. Put the
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
@@ -119,8 +122,8 @@ chooses between the two (see "The image's command" below).
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most (default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
bytes counted for a client in each window. A size or `off`. bytes counted for a client in each window. A size or `off`.
- `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`.
+6 -6
View File
@@ -468,13 +468,13 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
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", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"), BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"), BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
+11 -11
View File
@@ -244,9 +244,9 @@ func TestDefaults(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: ":8080", ListenAddr: ":8080",
Observe: false, Observe: false,
RateLimitPerMinute: 500, RateLimitPerMinute: 120,
RateLimitPerHour: 20000, RateLimitPerHour: 1500,
RateLimitPerDay: 300000, RateLimitPerDay: 6000,
BanResponse: 403, BanResponse: 403,
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
@@ -266,8 +266,8 @@ func TestDefaults(t *testing.T) {
LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond, LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
}) })
wantByteLimitSettings(t, cfg, config.Config{ wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30, BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
BytesLimitPerDay: 200 << 30, BytesCount: "both", BytesLimitPerDay: 100 << 30, BytesCount: "both",
}) })
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
@@ -308,7 +308,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
for _, limit := range []int64{ for _, limit := range []int64{
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
} { } {
if largest > limit { if limit != 0 && largest > limit { // 0 is off
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit) t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
} }
} }
@@ -2271,12 +2271,12 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "45", rateLimitPerMinute: "45",
rateLimitPerHour: "20000", rateLimitPerHour: "1500",
rateLimitPerDay: "300000", rateLimitPerDay: "6000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "4G", bytesLimitPerMinute: "off",
bytesLimitPerHour: "20G", bytesLimitPerHour: "50G",
bytesLimitPerDay: "200G", bytesLimitPerDay: "100G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
+88 -68
View File
@@ -1,7 +1,9 @@
package rproxy_test package rproxy_test
import ( import (
"bufio"
"bytes" "bytes"
"errors"
"io" "io"
"log/slog" "log/slog"
"net" "net"
@@ -14,9 +16,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"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/rproxy"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// post is the start of a POST whose headers end with what follows it. // post is the start of a POST whose headers end with what follows it.
@@ -69,11 +71,11 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: tc.limit, maxRequestSize: tc.limit,
}) })
@@ -83,11 +85,11 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
t.Errorf("answered %+v, want 413", got) t.Errorf("answered %+v, want 413", got)
} }
if asked := servewaf.question(t); len(asked.body) != tc.asked { if asked := standIn.question(t); len(asked.body) != tc.asked {
t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked) t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
} }
report := servewaf.report(t) report := standIn.report(t)
if report.Status != http.StatusRequestEntityTooLarge { if report.Status != http.StatusRequestEntityTooLarge {
t.Errorf("report %+v, want status 413", report) t.Errorf("report %+v, want status 413", report)
} }
@@ -98,9 +100,9 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
func TestADenyOutranksTheBodySizeLimit(t *testing.T) { func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: "1K", maxRequestSize: "1K",
}) })
@@ -130,12 +132,12 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
var asked atomic.Bool var asked atomic.Bool
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
asked.Store(true) asked.Store(true)
greet(w, r) greet(w, r)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: tc.limit, maxRequestSize: tc.limit,
}) })
@@ -163,12 +165,12 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Run("announced, is refused", func(t *testing.T) { t.Run("announced, is refused", func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large))) w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
_, _ = io.WriteString(w, large) _, _ = io.WriteString(w, large)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K", maxResponseSize: "1K",
}) })
@@ -177,7 +179,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Errorf("answered %d, want 502", got.status) t.Errorf("answered %d, want 502", got.status)
} }
if report := servewaf.report(t); report.Status != http.StatusBadGateway { if report := standIn.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report) t.Errorf("report %+v, want status 502", report)
} }
}) })
@@ -185,13 +187,13 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Run("not announced, is cut off", func(t *testing.T) { t.Run("not announced, is cut off", func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, large[:1]) _, _ = io.WriteString(w, large[:1])
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
_, _ = io.WriteString(w, large[1:]) _, _ = io.WriteString(w, large[1:])
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K", maxResponseSize: "1K",
}) })
@@ -203,7 +205,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err) t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
} }
report := servewaf.report(t) report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 { if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
t.Errorf("report %+v, want status 200 and at most 1K of the answer", report) t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
} }
@@ -215,11 +217,11 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
const announced = "2048" const announced = "2048"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, announced) w.Header().Set(contentLengthHeader, announced)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K", maxResponseSize: "1K",
}) })
@@ -238,9 +240,9 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
t.Run(setting, func(t *testing.T) { t.Run(setting, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
setting: "off", setting: "off",
}) })
@@ -249,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader("a body")))) strings.NewReader("a body"))))
if got := get(t, addr, proxy.HealthPath); got.status != http.StatusOK { if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK {
t.Errorf("answered %+v, want rproxy's own 200", got) t.Errorf("answered %+v, want rproxy's own 200", got)
} }
}) })
@@ -268,9 +270,9 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "500", maxRequestTime: "500",
}) })
@@ -286,11 +288,11 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
t.Errorf("line %v, want no disposition and 10 bytes", line) t.Errorf("line %v, want no disposition and 10 bytes", line)
} }
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("servewaf was asked")
} }
servewaf.wantNoReport(t) standIn.wantNoReport(t)
}) })
} }
} }
@@ -298,9 +300,9 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, post+announcedLength(100)+"some of it") send(t, conn, post+announcedLength(100)+"some of it")
@@ -311,7 +313,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Errorf("line %v, want no answer and no disposition", line) t.Errorf("line %v, want no answer and no disposition", line)
} }
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("servewaf was asked")
} }
} }
@@ -332,9 +334,9 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, tc.request) send(t, conn, tc.request)
@@ -362,7 +364,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Errorf("line %v, want no answer and no disposition", line) t.Errorf("line %v, want no answer and no disposition", line)
} }
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("servewaf was asked")
} }
}) })
@@ -402,11 +404,11 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
announced = 100 * questionBodyMaxBytes announced = 100 * questionBodyMaxBytes
) )
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
_, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{ _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10), maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
}) })
@@ -452,9 +454,9 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{ _, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
}) })
@@ -482,9 +484,9 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000", maxRequestTime: "3000",
}) })
@@ -496,11 +498,11 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Errorf("status %d, want 408", status) t.Errorf("status %d, want 408", status)
} }
if asked := servewaf.question(t); len(asked.body) != questionBodyMaxBytes { if asked := standIn.question(t); len(asked.body) != questionBodyMaxBytes {
t.Errorf("the question carries %d bytes", len(asked.body)) t.Errorf("the question carries %d bytes", len(asked.body))
} }
if report := servewaf.report(t); report.Status != http.StatusRequestTimeout { if report := standIn.report(t); report.Status != http.StatusRequestTimeout {
t.Errorf("report %+v, want status 408", report) t.Errorf("report %+v, want status 408", report)
} }
} }
@@ -551,8 +553,8 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
writeSize = 32 << 10 writeSize = 32 << 10
) )
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
addr, _ := startRProxy(t, servewaf.URL, startAppThatTakesNothing(t), addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
map[string]string{maxRequestTime: "3000"}) map[string]string{maxRequestTime: "3000"})
conn := dial(t, addr) conn := dial(t, addr)
@@ -578,14 +580,14 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Parallel() t.Parallel()
release := make(chan struct{}) release := make(chan struct{})
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
<-release <-release
}) })
t.Cleanup(func() { close(release) }) t.Cleanup(func() { close(release) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "300", maxResponseTime: "300",
}) })
@@ -594,7 +596,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Errorf("answered %d, want 504", got.status) t.Errorf("answered %d, want 504", got.status)
} }
if report := servewaf.report(t); report.Status != http.StatusGatewayTimeout { if report := standIn.report(t); report.Status != http.StatusGatewayTimeout {
t.Errorf("report %+v, want status 504", report) t.Errorf("report %+v, want status 504", report)
} }
} }
@@ -602,10 +604,12 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) { func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Parallel() t.Parallel()
const start = "the start"
release := make(chan struct{}) release := make(chan struct{})
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "the start") _, _ = io.WriteString(w, start)
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
<-release <-release
@@ -613,20 +617,32 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Cleanup(func() { close(release) }) t.Cleanup(func() { close(release) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ // The time limit is long enough for a loaded host to pass on the start of the
maxResponseTime: "300", // answer before it runs out.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "3000",
}) })
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
// dial's deadline ends this read with an error if the connection stays open. res, err := http.ReadResponse(bufio.NewReader(conn), nil)
_, err := io.ReadAll(conn)
if err != nil { if err != nil {
t.Errorf("read: %v, want the connection closed", err) t.Fatalf("read the answer: %v", err)
} }
if report := servewaf.report(t); report.Status != http.StatusOK { // The body breaks off when rproxy closes the connection. If the connection
// stays open, dial's deadline ends this read with a timeout instead.
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if res.StatusCode != http.StatusOK || string(body) != start ||
!errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
res.StatusCode, body, err, start)
}
if report := standIn.report(t); report.Status != http.StatusOK {
t.Errorf("report %+v, want the status the visitor got", report) t.Errorf("report %+v, want the status the visitor got", report)
} }
} }
@@ -640,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
writeSize = 32 << 10 writeSize = 32 << 10
) )
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
part := make([]byte, writeSize) part := make([]byte, writeSize)
@@ -651,8 +667,10 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
} }
} }
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ // The time limit is long enough for a loaded host to start passing on the
maxResponseTime: "500", // answer before it runs out.
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "3000",
}) })
// The visitor asks, and never reads the answer. // The visitor asks, and never reads the answer.
@@ -666,7 +684,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
t.Errorf("line %v, want status 200 and the answer cut off", line) t.Errorf("line %v, want status 200 and the answer cut off", line)
} }
report := servewaf.report(t) report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize { if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
t.Errorf("report %+v, want status 200 and the answer cut off", report) t.Errorf("report %+v, want status 200 and the answer cut off", report)
} }
@@ -675,15 +693,15 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusBadGateway { if got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status) t.Errorf("answered %d, want 502", got.status)
} }
if report := servewaf.report(t); report.Status != http.StatusBadGateway { if report := standIn.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report) t.Errorf("report %+v, want status 502", report)
} }
} }
@@ -691,11 +709,11 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) { func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting) _, _ = io.WriteString(w, greeting)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000", maxRequestTime: "3000",
}) })
@@ -714,12 +732,12 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
t.Parallel() t.Parallel()
working := make(chan struct{}) working := make(chan struct{})
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
close(working) close(working)
<-r.Context().Done() <-r.Context().Done()
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
@@ -755,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
const answer = "the end of a long answer" const answer = "the end of a long answer"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
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)
_ = http.NewResponseController(w).Flush() _ = http.NewResponseController(w).Flush()
@@ -764,7 +782,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
_, _ = io.WriteString(w, answer) _, _ = io.WriteString(w, answer)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000", maxRequestTime: "2000",
}) })
@@ -784,17 +802,19 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
{"a deny", post, http.StatusForbidden}, {"a deny", post, http.StatusForbidden},
{ {
"the health check", "the health check",
"GET " + proxy.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n", "GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
http.StatusOK, http.StatusOK,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ // The time limit is long enough for a loaded host to read the first part of
maxRequestTime: "500", // the body before it runs out.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
}) })
// Go's server waits for a little of the body left before it sends an answer, // Go's server waits for a little of the body left before it sends an answer,
+51 -51
View File
@@ -12,11 +12,11 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// questionBodyMaxBytes is the most of a body a question carries. // questionBodyMaxBytes is the most of a body a question carries.
const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes
// chunked is the encoding of a body whose length the visitor did not announce. // chunked is the encoding of a body whose length the visitor did not announce.
const chunked = "chunked" const chunked = "chunked"
@@ -29,9 +29,9 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
@@ -42,16 +42,16 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
req.Header.Set(forwardedForHeader, "198.51.100.1") req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set(forwardedProtoHeader, "gopher") req.Header.Set(forwardedProtoHeader, "gopher")
req.Header.Set(requestIDHeader, "the-visitor's") req.Header.Set(requestIDHeader, "the-visitor's")
req.Header.Set(proxy.ClientHeader, "198.51.100.2") req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
req.Header.Set(proxy.PeerHeader, "198.51.100.3") req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
req.Header.Set(proxy.ContentLengthHeader, "12345") req.Header.Set(servewaf.ContentLengthHeader, "12345")
// Go's client then sends no User-Agent, so that one in the question would be // Go's client then sends no User-Agent, so that one in the question would be
// Go's own. // Go's own.
req.Header["User-Agent"] = nil req.Header["User-Agent"] = nil
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
asked := servewaf.question(t) asked := standIn.question(t)
if asked.method != http.MethodDelete || asked.target != target || if asked.method != http.MethodDelete || asked.target != target ||
asked.host != site { asked.host != site {
t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host) t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host)
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
want.Set(forwardedForHeader, "198.51.100.1") want.Set(forwardedForHeader, "198.51.100.1")
want.Set(forwardedProtoHeader, "http") want.Set(forwardedProtoHeader, "http")
want.Set(requestIDHeader, asked.header.Get(requestIDHeader)) want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
want.Set(proxy.ClientHeader, localhost) want.Set(servewaf.ClientHeader, localhost)
want.Set(proxy.PeerHeader, localhost) want.Set(servewaf.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) { if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf("the question's headers are %v, want %v", asked.header, want) t.Errorf("the question's headers are %v, want %v", asked.header, want)
@@ -93,14 +93,14 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
forwarded := make(chan http.Header, 1) forwarded := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Header.Clone() forwarded <- r.Header.Clone()
greet(w, r) greet(w, r)
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: tc.trusted, trustedProxies: tc.trusted,
}) })
@@ -109,9 +109,9 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
req.Header.Set(forwardedProtoHeader, "https") req.Header.Set(forwardedProtoHeader, "https")
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
asked := servewaf.question(t) asked := standIn.question(t)
if asked.header.Get(proxy.ClientHeader) != tc.client || if asked.header.Get(servewaf.ClientHeader) != tc.client ||
asked.header.Get(proxy.PeerHeader) != localhost || asked.header.Get(servewaf.PeerHeader) != localhost ||
asked.header.Get(forwardedForHeader) != visitor || asked.header.Get(forwardedForHeader) != visitor ||
asked.header.Get(forwardedProtoHeader) != tc.proto { asked.header.Get(forwardedProtoHeader) != tc.proto {
t.Errorf("the question's headers are %v", asked.header) t.Errorf("the question's headers are %v", asked.header)
@@ -157,9 +157,9 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8", trustedProxies: localhost + "/32,10.0.0.0/8",
}) })
@@ -167,7 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
req.Header[forwardedForHeader] = tc.header req.Header[forwardedForHeader] = tc.header
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
if got := servewaf.question(t).header.Get(proxy.ClientHeader); got != tc.client { if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client {
t.Errorf("the question names %s, want %s", got, tc.client) t.Errorf("the question names %s, want %s", got, tc.client)
} }
}) })
@@ -197,7 +197,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel() t.Parallel()
body := randomBytes(size) body := randomBytes(size)
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
received := make(chan []byte, 1) received := make(chan []byte, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
whole, _ := io.ReadAll(r.Body) whole, _ := io.ReadAll(r.Body)
@@ -205,7 +205,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
greet(w, r) greet(w, r)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body)) req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body))
if !announced { if !announced {
@@ -215,7 +215,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
asked := servewaf.question(t) asked := standIn.question(t)
want := body[:min(size, questionBodyMaxBytes)] want := body[:min(size, questionBodyMaxBytes)]
wantQuestionBody(t, asked, want, size, announced) wantQuestionBody(t, asked, want, size, announced)
@@ -223,7 +223,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Error("the app did not get the whole body") t.Error("the app did not get the whole body")
} }
if report := servewaf.report(t); report.RequestBytes != int64(size) { if report := standIn.report(t); report.RequestBytes != int64(size) {
t.Errorf("report %+v, of %d bytes", report, size) t.Errorf("report %+v, of %d bytes", report, size)
} }
}) })
@@ -243,7 +243,7 @@ func wantQuestionBody(
len(want)) len(want))
} }
values := asked.header.Values(proxy.ContentLengthHeader) values := asked.header.Values(servewaf.ContentLengthHeader)
if announced { if announced {
if !slices.Equal(values, []string{strconv.Itoa(size)}) || if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
@@ -264,9 +264,9 @@ func wantQuestionBody(
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
// Far more announced than sent. Go's server does not wait for that much, so // Far more announced than sent. Go's server does not wait for that much, so
// the answer comes at once. // the answer comes at once.
@@ -278,7 +278,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Errorf("status %d, want servewaf's 403", status) t.Errorf("status %d, want servewaf's 403", status)
} }
if report := servewaf.report(t); report.RequestBytes != questionBodyMaxBytes { if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
t.Errorf("report %+v, want the first part's bytes", report) t.Errorf("report %+v, want the first part's bytes", report)
} }
} }
@@ -306,8 +306,8 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
t.Run(method+" "+tc.name, func(t *testing.T) { t.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
@@ -316,7 +316,7 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
t.Errorf("status %d, want servewaf's 403", status) t.Errorf("status %d, want servewaf's 403", status)
} }
asked := servewaf.question(t) asked := standIn.question(t)
if string(asked.body) != tc.body || if string(asked.body) != tc.body ||
!slices.Equal(asked.transferEncoding, tc.encoding) { !slices.Equal(asked.transferEncoding, tc.encoding) {
t.Errorf("the question carries %q, encoded %v, want %q, encoded %v", t.Errorf("the question carries %q, encoded %v, want %q, encoded %v",
@@ -334,11 +334,11 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed || if got.status != http.StatusMethodNotAllowed ||
@@ -352,11 +352,11 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Errorf("line %v, want status 405 and no disposition", line) t.Errorf("line %v, want status 405 and no disposition", line)
} }
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("servewaf was asked")
} }
servewaf.wantNoReport(t) standIn.wantNoReport(t)
}) })
} }
} }
@@ -372,18 +372,18 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
t.Parallel() t.Parallel()
reached := make(chan string, 1) reached := make(chan string, 1)
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
reached <- r.Method reached <- r.Method
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusOK { if got.status != http.StatusOK {
t.Errorf("answered %+v, want the app's 200", got) t.Errorf("answered %+v, want the app's 200", got)
} }
if asked := servewaf.question(t); asked.method != method { if asked := standIn.question(t); asked.method != method {
t.Errorf("servewaf was asked about %s", asked.method) t.Errorf("servewaf was asked about %s", asked.method)
} }
@@ -416,9 +416,9 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, request) send(t, conn, request)
@@ -433,11 +433,11 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
t.Errorf("line %v, want status 400 and no disposition", line) t.Errorf("line %v, want status 400 and no disposition", line)
} }
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked") t.Error("servewaf was asked")
} }
servewaf.wantNoReport(t) standIn.wantNoReport(t)
}) })
} }
} }
@@ -447,9 +447,9 @@ func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
const host = "a%b" const host = "a%b"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n")
@@ -458,7 +458,7 @@ func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
t.Errorf("status %d, want the app's 200", status) t.Errorf("status %d, want the app's 200", status)
} }
if asked := servewaf.question(t); asked.host != host { if asked := standIn.question(t); asked.host != host {
t.Errorf("asked about the host %q, want %q", asked.host, host) t.Errorf("asked about the host %q, want %q", asked.host, host)
} }
} }
@@ -468,15 +468,15 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
const want = "http" const want = "http"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
}) })
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
asked := servewaf.question(t) asked := standIn.question(t)
if got := asked.header.Get(forwardedProtoHeader); got != want { if got := asked.header.Get(forwardedProtoHeader); got != want {
t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want) t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
} }
@@ -497,9 +497,9 @@ func TestAQuestionNamesNoZone(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
_, out, server := startServer(t, servewaf.URL, app.URL, map[string]string{ _, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
}) })
@@ -509,9 +509,9 @@ func TestAQuestionNamesNoZone(t *testing.T) {
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
asked := servewaf.question(t) asked := standIn.question(t)
if asked.header.Get(proxy.ClientHeader) != tc.client || if asked.header.Get(servewaf.ClientHeader) != tc.client ||
asked.header.Get(proxy.PeerHeader) != tc.peer { asked.header.Get(servewaf.PeerHeader) != tc.peer {
t.Errorf("the question's headers are %v", asked.header) t.Errorf("the question's headers are %v", asked.header)
} }
+3 -3
View File
@@ -16,9 +16,9 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as // flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
@@ -129,7 +129,7 @@ func (rq *request) readFirstPartOfBody() bool {
} }
_ = rq.rc.SetReadDeadline(rq.requestDeadline()) _ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll(io.LimitReader(rq.body, proxy.QuestionBodyMaxBytes)) held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes))
// From here on, the request timer ends a read of the rest of the body, so that // From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on. // the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{}) _ = rq.rc.SetReadDeadline(time.Time{})
@@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) {
return return
} }
report := &proxy.Report{ report := &servewaf.Report{
RequestID: rq.id, RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax), Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes, RequestBytes: requestBytes,
+5 -5
View File
@@ -22,7 +22,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// How rproxy keeps connections to the app and to servewaf open between // How rproxy keeps connections to the app and to servewaf open between
@@ -85,7 +85,7 @@ func New(params Params) *Server {
transport: newTransport(), transport: newTransport(),
serveWAFTransport: newServeWAFTransport(), serveWAFTransport: newServeWAFTransport(),
reportURL: (&url.URL{ reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath, Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
}).String(), }).String(),
} }
@@ -166,7 +166,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.finish(r.Context()) defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == proxy.HealthPath { if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath {
rq.answerHealth() rq.answerHealth()
return return
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq.noteAnswer(answer) rq.noteAnswer(answer)
switch rq.disposition { switch rq.disposition {
case proxy.DispositionAllow: case servewaf.DispositionAllow:
rq.passToApp(r.Context()) rq.passToApp(r.Context())
case proxy.DispositionClose: case servewaf.DispositionClose:
// This panic has Go's server close the connection without an answer, // This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs. // and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler) panic(http.ErrAbortHandler)
+83 -81
View File
@@ -19,9 +19,9 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"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/rproxy"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -54,7 +54,7 @@ const (
// The headers the tests set and look at. // The headers the tests set and look at.
const ( const (
dispositionHeader = proxy.DispositionHeader dispositionHeader = servewaf.DispositionHeader
forwardedForHeader = "X-Forwarded-For" forwardedForHeader = "X-Forwarded-For"
forwardedProtoHeader = "X-Forwarded-Proto" forwardedProtoHeader = "X-Forwarded-Proto"
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
@@ -70,9 +70,9 @@ const (
// What the stand-in for servewaf answers. // What the stand-in for servewaf answers.
const ( const (
dispositionAllow = proxy.DispositionAllow dispositionAllow = servewaf.DispositionAllow
dispositionDeny = proxy.DispositionDeny dispositionDeny = servewaf.DispositionDeny
dispositionClose = proxy.DispositionClose dispositionClose = servewaf.DispositionClose
// deniedBody is the body of a deny. // deniedBody is the body of a deny.
deniedBody = "refused by servewaf" deniedBody = "refused by servewaf"
) )
@@ -173,7 +173,7 @@ type serveWAF struct {
*httptest.Server *httptest.Server
questions chan question questions chan question
reports chan proxy.Report reports chan servewaf.Report
} }
// startServeWAF starts a stand-in for servewaf. // startServeWAF starts a stand-in for servewaf.
@@ -182,11 +182,11 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
s := &serveWAF{ s := &serveWAF{
questions: make(chan question, 10), questions: make(chan question, 10),
reports: make(chan proxy.Report, 10), reports: make(chan servewaf.Report, 10),
} }
s.Server = httptest.NewServer(http.HandlerFunc( s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) == "" { if r.Header.Get(servewaf.ClientHeader) == "" {
s.takeReport(t, w, r) s.takeReport(t, w, r)
return return
@@ -213,10 +213,10 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) { func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
t.Helper() t.Helper()
var report proxy.Report var report servewaf.Report
err := json.NewDecoder(r.Body).Decode(&report) err := json.NewDecoder(r.Body).Decode(&report)
if err != nil || r.Method != http.MethodPost || r.URL.Path != proxy.ReportPath { if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err) t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
} }
@@ -237,7 +237,7 @@ func (s *serveWAF) wantNoReport(t *testing.T) {
} }
// report waits for a report. // report waits for a report.
func (s *serveWAF) report(t *testing.T) proxy.Report { func (s *serveWAF) report(t *testing.T) servewaf.Report {
t.Helper() t.Helper()
select { select {
@@ -246,7 +246,7 @@ func (s *serveWAF) report(t *testing.T) proxy.Report {
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("no report") t.Fatal("no report")
return proxy.Report{} return servewaf.Report{}
} }
} }
@@ -273,7 +273,7 @@ func startServeWAFAnsweringReports(
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" { if r.Header.Get(servewaf.ClientHeader) != "" {
answer(w, r) answer(w, r)
return return
@@ -498,7 +498,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) { func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(asnHeader, "AS64496") w.Header().Set(asnHeader, "AS64496")
w.Header().Set(countryHeader, "DE") w.Header().Set(countryHeader, "DE")
allow(w, r) allow(w, r)
@@ -512,7 +512,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
_, _ = io.WriteString(w, greeting) _, _ = io.WriteString(w, greeting)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
@@ -530,7 +530,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
} }
received := <-seen received := <-seen
asked := servewaf.question(t) asked := standIn.question(t)
if received.RequestURI != target || received.Host != site { if received.RequestURI != target || received.Host != site {
t.Errorf("the app saw %s for %s", received.RequestURI, received.Host) t.Errorf("the app saw %s for %s", received.RequestURI, received.Host)
@@ -557,14 +557,14 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
} }
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
seen := make(chan http.Header, 1) seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone() seen <- r.Header.Clone()
greet(w, r) greet(w, r)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: "10.0.0.0/8", trustedProxies: "10.0.0.0/8",
}) })
@@ -602,14 +602,14 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
seen := make(chan http.Header, 1) seen := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) { app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
seen <- r.Header.Clone() seen <- r.Header.Clone()
greet(w, r) greet(w, r)
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
}) })
@@ -641,12 +641,12 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>") _, _ = io.WriteString(w, "<html>")
}) })
_, _, server := startServer(t, servewaf.URL, app.URL, nil) _, _, server := startServer(t, standIn.URL, app.URL, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -704,11 +704,11 @@ func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusForbidden || got.body != deniedBody || if got.status != http.StatusForbidden || got.body != deniedBody ||
@@ -720,7 +720,7 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
t.Errorf("the visitor got %s", dispositionHeader) t.Errorf("the visitor got %s", dispositionHeader)
} }
report := servewaf.report(t) report := standIn.report(t)
if report.Status != http.StatusForbidden || if report.Status != http.StatusForbidden ||
report.ResponseBytes != int64(len(deniedBody)) || report.ResponseBytes != int64(len(deniedBody)) ||
report.DurationUpstreamTotal != 0 { report.DurationUpstreamTotal != 0 {
@@ -736,9 +736,9 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) { func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusForbidden || if got.status != http.StatusForbidden ||
@@ -758,15 +758,15 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
}{ }{
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden}, {"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest}, {"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
{"the health check", "GET " + proxy.HealthPath + host, "ok\n", http.StatusOK}, {"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
log := heldLog{release: make(chan struct{})} log := heldLog{release: make(chan struct{})}
servewaf := startServeWAF(t, deny) standIn := startServeWAF(t, deny)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startServerLogging(t, servewaf.URL, app.URL, nil, log, io.Discard) addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
t.Cleanup(func() { close(log.release) }) t.Cleanup(func() { close(log.release) })
@@ -794,9 +794,9 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
func TestRProxysOwnAnswerIsPlainText(t *testing.T) { func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n\r\n")
@@ -817,14 +817,14 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose) w.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
@@ -835,7 +835,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
text, err) text, err)
} }
report := servewaf.report(t) report := standIn.report(t)
if report.Status != 0 || report.ResponseBytes != 0 { if report.Status != 0 || report.ResponseBytes != 0 {
t.Errorf("report %+v, of a visitor sent nothing", report) t.Errorf("report %+v, of a visitor sent nothing", report)
} }
@@ -935,7 +935,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
t.Run(tc.name+" with the default "+setting, func(t *testing.T) { t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
if tc.disposition != "" { if tc.disposition != "" {
w.Header().Set(dispositionHeader, tc.disposition) w.Header().Set(dispositionHeader, tc.disposition)
} }
@@ -947,7 +947,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
defaultDisposition: setting, defaultDisposition: setting,
}) })
@@ -977,11 +977,11 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) { func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest) http.Error(w, "refused", http.StatusBadRequest)
}) })
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadRequest { if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
t.Errorf("answered %+v, want servewaf's 400", got) t.Errorf("answered %+v, want servewaf's 400", got)
@@ -991,18 +991,18 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
t.Errorf("line %v, want no disposition", line) t.Errorf("line %v, want no disposition", line)
} }
servewaf.wantNoReport(t) standIn.wantNoReport(t)
} }
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) { func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil w.Header()["Content-Type"] = nil
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "<html>broken") _, _ = io.WriteString(w, "<html>broken")
}) })
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/") got := get(t, addr, "/")
if contentType, sent := got.header["Content-Type"]; sent { if contentType, sent := got.header["Content-Type"]; sent {
@@ -1015,11 +1015,11 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
const size = 1 << 20 const size = 1 << 20
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size)) _, _ = w.Write(make([]byte, size))
}) })
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/") got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size { if got.status != http.StatusInternalServerError || len(got.body) != size {
@@ -1135,15 +1135,15 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) { func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, overOneMiB(dispositionDeny)) standIn := startServeWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, servewaf.URL, nowhere, nil) addr, out := startRProxy(t, standIn.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway { if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got) t.Errorf("answered %+v, want 502", got)
} }
out.requestLine(t) out.requestLine(t)
servewaf.wantNoReport(t) standIn.wantNoReport(t)
} }
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
@@ -1199,20 +1199,20 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
t.Run("with the default "+disposition, func(t *testing.T) { t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}) })
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked") t.Error("the app was asked")
}) })
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "60000", timeoutSetting: "60000",
defaultDisposition: disposition, defaultDisposition: disposition,
}) })
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
servewaf.question(t) standIn.question(t)
_ = conn.Close() _ = conn.Close()
@@ -1226,7 +1226,7 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
t.Error("rproxy warned that servewaf gave no answer") t.Error("rproxy warned that servewaf gave no answer")
} }
servewaf.wantNoReport(t) standIn.wantNoReport(t)
}) })
} }
} }
@@ -1244,17 +1244,17 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
func TestSendsNoReportWithoutAnAnswer(t *testing.T) { func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) { standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
}) })
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
timeoutSetting: "200", timeoutSetting: "200",
}) })
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
out.requestLine(t) out.requestLine(t)
servewaf.wantNoReport(t) standIn.wantNoReport(t)
} }
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) { func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
@@ -1274,25 +1274,25 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) { func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil) addr, out := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, proxy.HealthPath) got := get(t, addr, servewaf.HealthPath)
if got.status != http.StatusOK || got.body != "ok\n" { if got.status != http.StatusOK || got.body != "ok\n" {
t.Errorf("answered %+v, want ok", got) t.Errorf("answered %+v, want ok", got)
} }
line := out.requestLine(t) line := out.requestLine(t)
if line["path"] != proxy.HealthPath || line["disposition"] != nil { if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
t.Errorf("line %v, want one with no disposition", line) t.Errorf("line %v, want one with no disposition", line)
} }
// With servewaf up too, it is not asked. // With servewaf up too, it is not asked.
addr, _ = startRProxy(t, servewaf.URL, app.URL, nil) addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
get(t, addr, proxy.HealthPath) get(t, addr, servewaf.HealthPath)
if len(servewaf.questions) != 0 { if len(standIn.questions) != 0 {
t.Error("servewaf was asked about the health check") t.Error("servewaf was asked about the health check")
} }
} }
@@ -1353,8 +1353,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
}) })
app := startApp(t, greet) app := startApp(t, greet)
// The time limit is long enough for a loaded host to answer the question before
// it runs out.
addr, out := startRProxy(t, backend, app.URL, map[string]string{ addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "1000", timeoutSetting: "3000",
}) })
t.Cleanup(func() { close(release) }) t.Cleanup(func() { close(release) })
@@ -1394,15 +1396,15 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) { func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out, server := startServer(t, servewaf.URL, app.URL, nil) addr, out, server := startServer(t, standIn.URL, app.URL, nil)
server.WaitForReports() server.WaitForReports()
wantGreeting(t, get(t, addr, "/")) wantGreeting(t, get(t, addr, "/"))
out.requestLine(t) out.requestLine(t)
servewaf.wantNoReport(t) standIn.wantNoReport(t)
} }
func TestReportGivesWhatHappened(t *testing.T) { func TestReportGivesWhatHappened(t *testing.T) {
@@ -1410,14 +1412,14 @@ func TestReportGivesWhatHappened(t *testing.T) {
const body = "the visitor's body" const body = "the visitor's body"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader(body)))) strings.NewReader(body))))
report := servewaf.report(t) report := standIn.report(t)
if report.RequestID == "" || report.Status != http.StatusOK || if report.RequestID == "" || report.Status != http.StatusOK ||
report.RequestBytes != int64(len(body)) || report.RequestBytes != int64(len(body)) ||
@@ -1440,7 +1442,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
release := make(chan struct{}) release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" { if r.Header.Get(servewaf.ClientHeader) != "" {
allow(w, r) allow(w, r)
return return
@@ -1495,9 +1497,9 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
t.Parallel() t.Parallel()
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, echoAfterUpgrade) app := startApp(t, echoAfterUpgrade)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000", maxRequestTime: "2000",
maxResponseTime: "2000", maxResponseTime: "2000",
}) })
@@ -1533,7 +1535,7 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
_ = conn.Close() _ = conn.Close()
report := servewaf.report(t) report := standIn.report(t)
if report.Status != http.StatusSwitchingProtocols || if report.Status != http.StatusSwitchingProtocols ||
report.RequestBytes != int64(len(message)) || report.RequestBytes != int64(len(message)) ||
report.ResponseBytes != int64(len(message)) { report.ResponseBytes != int64(len(message)) {
@@ -1550,7 +1552,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
const goodbye = "sent after the visitor stopped sending\n" const goodbye = "sent after the visitor stopped sending\n"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack() conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
@@ -1570,7 +1572,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
_, _ = buffered.WriteString(goodbye) _, _ = buffered.WriteString(goodbye)
_ = buffered.Flush() _ = buffered.Flush()
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+ send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
@@ -1608,7 +1610,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
//nolint:paralleltest // see above //nolint:paralleltest // see above
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) { func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
closed := make(chan error, 1) closed := make(chan error, 1)
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buffered, err := http.NewResponseController(w).Hijack() conn, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
@@ -1631,7 +1633,7 @@ func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
_, err = buffered.ReadByte() _, err = buffered.ReadByte()
closed <- err closed <- err
}) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr) conn := dial(t, addr)
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+ send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
@@ -1679,9 +1681,9 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
const fromTraefik = "an-id-from-traefik" const fromTraefik = "an-id-from-traefik"
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32", trustedProxies: localhost + "/32",
}) })
@@ -1692,7 +1694,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
req.Header.Set(requestIDHeader, fromTraefik) req.Header.Set(requestIDHeader, fromTraefik)
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
ids = append(ids, servewaf.question(t).header.Get(requestIDHeader)) ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
} }
if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] { if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
@@ -1709,16 +1711,16 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
body = "the visitor's body" body = "the visitor's body"
) )
servewaf := startServeWAF(t, allow) standIn := startServeWAF(t, allow)
app := startApp(t, greet) app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil) addr, out := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body)) req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
req.Host = site req.Host = site
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
line := out.requestLine(t) line := out.requestLine(t)
asked := servewaf.question(t) asked := standIn.question(t)
for name, want := range map[string]any{ for name, want := range map[string]any{
"type": "request", "type": "request",
+11 -11
View File
@@ -11,7 +11,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read. // answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
@@ -38,10 +38,10 @@ type servewafAnswer struct {
// notes nothing, and no report is due for it: servewaf holds no decision to report // notes nothing, and no report is due for it: servewaf holds no decision to report
// on. // on.
func (rq *request) noteAnswer(answer *servewafAnswer) { func (rq *request) noteAnswer(answer *servewafAnswer) {
disposition := answer.header.Get(proxy.DispositionHeader) disposition := answer.header.Get(servewaf.DispositionHeader)
switch disposition { switch disposition {
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose: case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
rq.disposition = disposition rq.disposition = disposition
rq.answered = true rq.answered = true
rq.asn = answer.header.Get(asnHeader) rq.asn = answer.header.Get(asnHeader)
@@ -74,7 +74,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
rq.disposition = rq.h.config.DefaultDisposition rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true rq.byDefault = true
if rq.disposition == proxy.DispositionAllow { if rq.disposition == servewaf.DispositionAllow {
rq.passToApp(ctx) rq.passToApp(ctx)
} else { } else {
rq.answer(http.StatusServiceUnavailable) rq.answer(http.StatusServiceUnavailable)
@@ -169,13 +169,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
question.TransferEncoding = in.TransferEncoding question.TransferEncoding = in.TransferEncoding
question.Header = in.Header.Clone() question.Header = in.Header.Clone()
question.Header.Set(proxy.ClientHeader, rq.client.String()) question.Header.Set(servewaf.ClientHeader, rq.client.String())
question.Header.Set(proxy.PeerHeader, rq.peer.String()) question.Header.Set(servewaf.PeerHeader, rq.peer.String())
question.Header.Del(proxy.ContentLengthHeader) question.Header.Del(servewaf.ContentLengthHeader)
announced := in.Header.Get("Content-Length") announced := in.Header.Get("Content-Length")
if announced != "" { if announced != "" {
question.Header.Set(proxy.ContentLengthHeader, announced) question.Header.Set(servewaf.ContentLengthHeader, announced)
} }
question.Header.Set(requestIDHeader, rq.id) question.Header.Set(requestIDHeader, rq.id)
@@ -196,7 +196,7 @@ func (rq *request) answerWith(answer *servewafAnswer) {
header := rq.out.Header() header := rq.out.Header()
maps.Copy(header, answer.header) maps.Copy(header, answer.header)
header.Del(proxy.DispositionHeader) header.Del(servewaf.DispositionHeader)
// Go's server adds a Content-Type it guesses from the body to a response whose // 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. // headers have no Content-Type key. The key with no value sends none.
@@ -210,7 +210,7 @@ func (rq *request) answerWith(answer *servewafAnswer) {
// sendReport sends servewaf report on a goroutine of its own, so that the // sendReport sends servewaf report on a goroutine of its own, so that the
// visitor does not wait for it. The report goes on after ctx, the request's own // visitor does not wait for it. The report goes on after ctx, the request's own
// context, has ended. // context, has ended.
func (h *handler) sendReport(ctx context.Context, report *proxy.Report) { func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
h.reportsMu.Lock() h.reportsMu.Lock()
defer h.reportsMu.Unlock() defer h.reportsMu.Unlock()
@@ -228,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *proxy.Report) {
// postReport posts report to servewaf, and waits for its answer for up to // postReport posts report to servewaf, and waits for its answer for up to
// SWRP_TIMEOUT_MS. // SWRP_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *proxy.Report) error { func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error {
body, err := json.Marshal(report) body, err := json.Marshal(report)
if err != nil { if err != nil {
return fmt.Errorf("encode the report: %w", err) return fmt.Errorf("encode the report: %w", err)
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"bytes" "bytes"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"encoding/json" "encoding/json"
@@ -10,9 +10,9 @@ import (
"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/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
) )
@@ -88,11 +88,11 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
// Any other request under /_smallwebwaf/ is not found. // Any other request under /_smallwebwaf/ is not found.
for _, e := range []adminEndpoint{ for _, e := range []adminEndpoint{
{http.MethodPut, proxy.BansPath, banOtherClient}, {http.MethodPut, servewaf.BansPath, banOtherClient},
{http.MethodDelete, proxy.BansPath, ""}, {http.MethodDelete, servewaf.BansPath, ""},
{http.MethodGet, proxy.BansPath + "/" + otherClient, ""}, {http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
{http.MethodPost, proxy.ClientsPath + otherClient, ""}, {http.MethodPost, servewaf.ClientsPath + otherClient, ""},
{http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""}, {http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
} { } {
s.admin(e.method, e.path, e.body, http.StatusNotFound) s.admin(e.method, e.path, e.body, http.StatusNotFound)
} }
@@ -118,7 +118,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
Reason: "probes for logins", Reason: "probes for logins",
} }
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK), wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
want) want)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused. // Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1 want.Notes.Requests, want.Notes.Refused = 1, 1
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want) wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
// Ten minutes on, lifting the bans on client lifts that one, which is // Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted. // kept, marked lifted.
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
lifted := clk.Now() lifted := clk.Now()
want.Lifted = &lifted want.Lifted = &lifted
wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
http.StatusOK), want) http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want) wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
// No ban on it is active any more. // No ban on it is active any more.
s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound) s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
} }
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -181,7 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires want.Expires = &expires
} }
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, body, http.StatusOK), want) wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
} }
} }
@@ -250,7 +250,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
"request body too large", "request body too large",
}, },
} { } {
got := s.admin(http.MethodPost, proxy.BansPath, tc.body, http.StatusBadRequest) got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
if !strings.Contains(string(got.body), tc.want) { if !strings.Contains(string(got.body), tc.want) {
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want) t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
} }
@@ -283,7 +283,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
Bans []state.BanEntry `json:"bans"` Bans []state.BanEntry `json:"bans"`
} }
decode(t, s.admin(http.MethodGet, proxy.ClientsPath+addr, "", http.StatusOK), &got) decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
if got.Client == nil { if got.Client == nil {
t.Fatalf("%s: no client", addr) t.Fatalf("%s: no client", addr)
@@ -305,13 +305,13 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// Of an address no request came from and no ban covers, nothing is // Of an address no request came from and no ban covers, nothing is
// known. // known.
got := s.admin(http.MethodGet, proxy.ClientsPath+"198.51.100.99", "", http.StatusOK) got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" { if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body) t.Errorf("an unknown client is answered\n%s", got.body)
} }
s.admin(http.MethodGet, proxy.ClientsPath+"203.0.113", "", http.StatusBadRequest) s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
s.admin(http.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "", s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
http.StatusBadRequest) http.StatusBadRequest)
} }
@@ -342,10 +342,10 @@ type adminEndpoint struct {
// bans on otherClient, and asking about otherClient. // bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint { func adminEndpoints() []adminEndpoint {
return []adminEndpoint{ return []adminEndpoint{
{http.MethodGet, proxy.BansPath, ""}, {http.MethodGet, servewaf.BansPath, ""},
{http.MethodPost, proxy.BansPath, banOtherClient}, {http.MethodPost, servewaf.BansPath, banOtherClient},
{http.MethodDelete, proxy.BansPath + "/" + otherClient, ""}, {http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
{http.MethodGet, proxy.ClientsPath + otherClient, ""}, {http.MethodGet, servewaf.ClientsPath + otherClient, ""},
} }
} }
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"maps" "maps"
@@ -10,8 +10,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"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"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// never sent them, and returns the queue they wait in as well. // never sent them, and returns the queue they wait in as well.
func startWithAlerts( func startWithAlerts(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *proxy.Server, *alerts.Queue) { ) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
t.Helper() t.Helper()
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env) return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
@@ -210,7 +210,7 @@ func startWithAlerts(
// startAppWithAlerts is startWithAlerts in front of the app handler. // startAppWithAlerts is startWithAlerts in front of the app handler.
func startAppWithAlerts( func startAppWithAlerts(
t *testing.T, handler http.HandlerFunc, env map[string]string, t *testing.T, handler http.HandlerFunc, env map[string]string,
) (*sender, *clock, *proxy.Server, *alerts.Queue) { ) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
t.Helper() t.Helper()
app := startApp(t, handler) app := startApp(t, handler)
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"net/netip" "net/netip"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"maps" "maps"
@@ -13,9 +13,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The anomaly thresholds: the prefix of a scope followed by the end of a // The anomaly thresholds: the prefix of a scope followed by the end of a
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// kept. // kept.
geojsURL, _, release := startHeldGeoJS(t) geojsURL, _, release := startHeldGeoJS(t)
var server atomic.Pointer[proxy.Server] var server atomic.Pointer[servewaf.Server]
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
release() release()
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"fmt" "fmt"
@@ -16,10 +16,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -81,7 +81,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
} }
wantDisposition(t, got, tooLarge, proxy.DispositionDeny) wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
@@ -144,13 +144,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
line := s.out.requestLine(t) line := s.out.requestLine(t)
if tc.refused { if tc.refused {
wantDisposition(t, got, tooLarge, proxy.DispositionDeny) wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
return return
} }
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationWAF == nil { if line.DurationWAF == nil {
@@ -175,7 +175,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
}) })
} }
} }
@@ -187,7 +187,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
} }
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
@@ -204,7 +204,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
} }
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil { if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
@@ -224,8 +224,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
return do(t, req) return do(t, req)
} }
wantDisposition(t, ask(), tooLarge, proxy.DispositionDeny) wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
wantDisposition(t, ask(), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
@@ -249,7 +249,7 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
req := s.question(siteA, client, tc.path) req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action) wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
}) })
} }
@@ -265,7 +265,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
req := s.question(siteA, fromDE, "/") req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
@@ -275,7 +275,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
} }
// The client is under no ban, and GeoJS places it in a country. // The client is under no ban, and GeoJS places it in a country.
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
} }
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) { func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
@@ -291,7 +291,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
} }
@@ -306,7 +306,7 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
got := do(t, req) got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != asnDE || country != "DE" { if asn != asnDE || country != "DE" {
@@ -318,7 +318,9 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
// 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.
func newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *output) { func newServeWAFInBubble(
t *testing.T, env map[string]string,
) (*servewaf.Server, *output) {
t.Helper() t.Helper()
settings := map[string]string{deniedCountries: "KP"} settings := map[string]string{deniedCountries: "KP"}
@@ -332,13 +334,13 @@ func newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *o
// askDirectly hands a question about a request from client straight to server, // askDirectly hands a question about a request from client straight to server,
// as change leaves it, and returns the answer's status and how long it took. // as change leaves it, and returns the answer's status and how long it took.
func askDirectly( func askDirectly(
t *testing.T, server *proxy.Server, change func(*http.Request), t *testing.T, server *servewaf.Server, change func(*http.Request),
) (int, time.Duration) { ) (int, time.Duration) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234") req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(proxy.ClientHeader, client) req.Header.Set(servewaf.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
change(req) change(req)
@@ -351,17 +353,17 @@ func askDirectly(
} }
// banClient bans client at now. // banClient bans client at now.
func banClient(_ *testing.T, server *proxy.Server, now time.Time) { func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{}) server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
} }
// countClient counts one request from client at now for the rate limits. // countClient counts one request from client at now for the rate limits.
func countClient(_ *testing.T, server *proxy.Server, now time.Time) { func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit) server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
} }
// listClient puts client on the blocklist at dropURL. // listClient puts client on the blocklist at dropURL.
func listClient(t *testing.T, server *proxy.Server, now time.Time) { func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
t.Helper() t.Helper()
err := server.Lists.Load([]reputation.List{{ err := server.Lists.Load([]reputation.List{{
@@ -373,13 +375,13 @@ func listClient(t *testing.T, server *proxy.Server, now time.Time) {
} }
// decideOnClient puts client on the CrowdSec decision list. // decideOnClient puts client on the CrowdSec decision list.
func decideOnClient(t *testing.T, server *proxy.Server, now time.Time) { func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
t.Helper() t.Helper()
loadDecisions(t, server, now, clientsDecision) loadDecisions(t, server, now, clientsDecision)
} }
// leaveAsItIs prepares nothing. // leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *proxy.Server, time.Time) {} func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Parallel() t.Parallel()
@@ -390,7 +392,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *proxy.Server, now time.Time) prepare func(t *testing.T, server *servewaf.Server, now time.Time)
// cookie is the request's Cookie header. // cookie is the request's Cookie header.
cookie string cookie string
status int status int
@@ -461,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *proxy.Server, now time.Time) prepare func(t *testing.T, server *servewaf.Server, now time.Time)
status int status int
action string action string
}{ }{
@@ -519,10 +521,10 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
t.Cleanup(func() { _ = resolver.Close() }) t.Cleanup(func() { _ = resolver.Close() })
server, out, _ := newServeWAFWith(t, func(params proxy.Params) *proxy.Server { server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL params.AbuseIPDBURL = silentAbuseIPDBURL
return proxy.NewServeWAF(params) return servewaf.New(params)
}, 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(),
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"bufio" "bufio"
@@ -15,8 +15,8 @@ import (
"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"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -382,7 +382,7 @@ func (c *clock) advance(d time.Duration) {
// window. // window.
func startWithClock( func startWithClock(
t *testing.T, geojsURL string, env map[string]string, t *testing.T, geojsURL string, env map[string]string,
) (*sender, *clock, *proxy.Server) { ) (*sender, *clock, *servewaf.Server) {
t.Helper() t.Helper()
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"fmt" "fmt"
@@ -16,8 +16,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"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/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The biased thresholds. // The biased thresholds.
@@ -419,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// clock. // clock.
func startWithLookups( func startWithLookups(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *proxy.Server, *alerts.Queue) { ) (*sender, *servewaf.Server, *alerts.Queue) {
t.Helper() t.Helper()
s, _, server, queue := startWithLookupsAndClock(t, env) s, _, server, queue := startWithLookupsAndClock(t, env)
@@ -434,7 +434,7 @@ func startWithLookups(
// country, and no other address. // country, and no other address.
func startWithLookupsAndClock( func startWithLookupsAndClock(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *proxy.Server, *alerts.Queue) { ) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
@@ -463,11 +463,11 @@ func (s *sender) uploadFrom(from string) logLine {
// serveFromDE hands a question about a request from fromDE with method and body // serveFromDE hands a question about a request from fromDE with method and body
// straight to server's handler, without the network, and returns once it is // straight to server's handler, without the network, and returns once it is
// answered. // answered.
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) { func serveFromDE(t *testing.T, server *servewaf.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.Header.Set(proxy.ClientHeader, fromDE) req.Header.Set(servewaf.ClientHeader, fromDE)
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"bufio" "bufio"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"crypto/rand" "crypto/rand"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"time" "time"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"io" "io"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"slices" "slices"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"encoding/json" "encoding/json"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -9,9 +9,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The CrowdSec settings, and the tests' engine, which is never asked: each // The CrowdSec settings, and the tests' engine, which is never asked: each
@@ -168,7 +168,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
// decisionsURL, answer, the engine's answer, fetched at fetched, as // decisionsURL, answer, the engine's answer, fetched at fetched, as
// reputation.json would at start. // reputation.json would at start.
func loadDecisions( func loadDecisions(
t *testing.T, server *proxy.Server, fetched time.Time, answer string, t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
) { ) {
t.Helper() t.Helper()
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"maps" "maps"
@@ -10,9 +10,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
@@ -51,7 +51,7 @@ func (r refused) send(s *sender, from string) logLine {
// 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,
) (*sender, *clock, *proxy.Server) { ) (*sender, *clock, *servewaf.Server) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -1,11 +1,11 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
) )
for range healthChecks { for range healthChecks {
got := s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK) got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", 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 "+
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"io" "io"
@@ -8,9 +8,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK) s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
s.adminRequest("", http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized) s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized)
s.scrape() s.scrape()
if clients := server.Limiter.Snapshot(); len(clients) != 0 { if clients := server.Limiter.Snapshot(); len(clients) != 0 {
@@ -126,7 +126,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
} }
// historyOf returns the history of the client at addr. // historyOf returns the history of the client at addr.
func historyOf(t *testing.T, server *proxy.Server, addr string) ratelimit.History { func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History {
t.Helper() t.Helper()
client := netip.MustParsePrefix(addr + "/32") client := netip.MustParsePrefix(addr + "/32")
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -379,7 +379,7 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// clock of the test's own: the wait lasts exactly as long as it should, // clock of the test's own: the wait lasts exactly as long as it should,
// however slowly the test process runs. Nothing in it may wait on the // however slowly the test process runs. Nothing in it may wait on the
// network, which would keep that clock from moving on: the request is // network, which would keep that clock from moving on: the request is
// handed to the proxy's handler, and GeoJS is one that never answers. // handed to servewaf's handler, and GeoJS is one that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// Not the default. The exclusive list needs the answer, and the app // Not the default. The exclusive list needs the answer, and the app
// is never reached. // is never reached.
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"io" "io"
@@ -12,8 +12,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(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{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
s.adminRequest(authorization, http.MethodGet, proxy.MetricsPath, "", s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "",
http.StatusNotFound) http.StatusNotFound)
} }
} }
@@ -46,18 +46,18 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
method, path, authorization string method, path, authorization string
status int status int
}{ }{
{http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized}, {http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized},
{ {
http.MethodGet, proxy.MetricsPath, "Bearer " + strings.ToUpper(token), http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token),
http.StatusUnauthorized, http.StatusUnauthorized,
}, },
{http.MethodGet, proxy.MetricsPath, "Basic " + token, http.StatusUnauthorized}, {http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized},
{http.MethodGet, proxy.MetricsPath, bearer, http.StatusOK}, {http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK},
{http.MethodGet, proxy.MetricsPath, "bearer " + token, http.StatusOK}, {http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK},
{http.MethodPost, proxy.MetricsPath, bearer, http.StatusNotFound}, {http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound},
{http.MethodGet, proxy.MetricsPath + "/", bearer, http.StatusNotFound}, {http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound},
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound}, {http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
{http.MethodPost, proxy.HealthPath, "", http.StatusNotFound}, {http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound},
} { } {
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status) got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
@@ -180,7 +180,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
wantMetric(t, s.scrape(), 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, servewaf.BansPath, banOtherClient, http.StatusOK)
wantMetric(t, s.scrape(), admins, 2) wantMetric(t, s.scrape(), admins, 2)
} }
@@ -365,7 +365,7 @@ func keptAnswer(addr, country string) lookup.Answer {
func scrape(t *testing.T, addr string) string { func scrape(t *testing.T, addr string) string {
t.Helper() t.Helper()
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody) req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody)
req.Header.Set("Authorization", bearer) req.Header.Set("Authorization", bearer)
got := do(t, req) got := do(t, req)
@@ -380,7 +380,7 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string { func (s *sender) scrape() string {
s.t.Helper() s.t.Helper()
return string(s.adminRequest(bearer, http.MethodGet, proxy.MetricsPath, "", return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "",
http.StatusOK).body) http.StatusOK).body)
} }
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -1,10 +1,7 @@
// Package proxy is servewaf's server. It runs the checks on the request each of // Package servewaf is the server the servewaf command runs. It answers rproxy's
// rproxy's questions carries, and answers rproxy what to do with that request. It // questions, takes rproxy's reports and answers its own endpoints, and writes a
// takes rproxy's report once the visitor's answer has ended, which counts the // log line for each.
// request's bytes and writes its last log line. It answers servewaf's own package servewaf
// endpoints: the health check, the metrics, the bans and the clients. It passes
// nothing to an app: internal/rproxy does that.
package proxy
import ( import (
"io" "io"
@@ -46,7 +43,7 @@ const BansPath = "/_smallwebwaf/bans"
// 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 NewServeWAF needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
// RequestLog receives two JSON lines per question, one at its decision and // RequestLog receives two JSON lines per question, one at its decision and
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"bufio" "bufio"
@@ -18,10 +18,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"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/rproxy"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -300,7 +300,7 @@ func startProxyWithGeoJS(
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,
) (string, *output, *proxy.Server) { ) (string, *output, *servewaf.Server) {
t.Helper() t.Helper()
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env) addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
@@ -313,7 +313,7 @@ func startProxyWithClock(
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, *servewaf.Server, *alerts.Queue) {
t.Helper() t.Helper()
server, out, alertQueue := newServeWAF(t, geojsURL, now, env) server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
@@ -324,7 +324,7 @@ func startProxyWithAlerts(
// serve serves server on a port of its own until the test ends, and sets its // serve serves server on a port of its own until the test ends, and sets its
// Addr to where it listens. // Addr to where it listens.
func serve(t *testing.T, server *proxy.Server) { func serve(t *testing.T, server *servewaf.Server) {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
@@ -401,17 +401,17 @@ func startRProxy(
// inspected by it. // inspected by it.
func newServeWAF( func newServeWAF(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string, t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
) (*proxy.Server, *output, *alerts.Queue) { ) (*servewaf.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env) return newServeWAFWith(t, servewaf.New, geojsURL, now, env)
} }
// newServeWAFWith is newServeWAF for the server build makes. // newServeWAFWith is newServeWAF for the server build makes.
func newServeWAFWith( func newServeWAFWith(
t *testing.T, build func(proxy.Params) *proxy.Server, t *testing.T, build func(servewaf.Params) *servewaf.Server,
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) { ) (*servewaf.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -463,7 +463,7 @@ func newServeWAFWith(
} }
} }
server := build(proxy.Params{ server := build(servewaf.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
ProcessLog: processLog, ProcessLog: processLog,
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"fmt" "fmt"
@@ -15,10 +15,10 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The reputation settings. // The reputation settings.
@@ -617,7 +617,7 @@ const (
// AbuseIPDB. // AbuseIPDB.
const abuseipdb = reputation.AbuseIPDBSource const abuseipdb = reputation.AbuseIPDBSource
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at // abuseIPDBURL is where newServeWAF has clients checked with AbuseIPDB: at
// abuseIPDBStandIn, which TestMain registers with Go's default transport, // abuseIPDBStandIn, which TestMain registers with Go's default transport,
// through which AbuseIPDB is asked. // through which AbuseIPDB is asked.
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check" const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
@@ -975,7 +975,7 @@ func verdictsFetched() time.Time {
// verdictsFetched, as reputation.json would at start: one that lists the // verdictsFetched, as reputation.json would at start: one that lists the
// client from each zone listedBy gives for it, and one that does not from // client from each zone listedBy gives for it, and one that does not from
// each other zone. // each other zone.
func loadVerdicts(server *proxy.Server, listedBy map[string][]string) { func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) {
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones())) verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
for client, zones := range listedBy { for client, zones := range listedBy {
@@ -993,7 +993,7 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
// loadScores puts into server's AbuseIPDB the score scores gives each // loadScores puts into server's AbuseIPDB the score scores gives each
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json // client, an IPv4 address, fetched at verdictsFetched, as reputation.json
// would at start. // would at start.
func loadScores(server *proxy.Server, scores map[string]int64) { func loadScores(server *servewaf.Server, scores map[string]int64) {
kept := make([]reputation.Score, 0, len(scores)) kept := make([]reputation.Score, 0, len(scores))
for client, score := range scores { for client, score := range scores {
kept = append(kept, reputation.Score{ kept = append(kept, reputation.Score{
@@ -1007,7 +1007,7 @@ func loadScores(server *proxy.Server, scores map[string]int64) {
// wantAbuseIPDBChecks checks how many clients server has checked with // wantAbuseIPDBChecks checks how many clients server has checked with
// AbuseIPDB. // AbuseIPDB.
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) { func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
t.Helper() t.Helper()
if got := server.AbuseIPDB.Checked(); got != want { if got := server.AbuseIPDB.Checked(); got != want {
@@ -1017,7 +1017,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
// loadLists puts copies of lists into server's lists, by URL, each with // loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as reputation.json would at start. // its lines, fetched at listsFetched, as reputation.json would at start.
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) { func loadLists(t *testing.T, server *servewaf.Server, copies map[string][]string) {
t.Helper() t.Helper()
lists := make([]reputation.List, 0, len(copies)) lists := make([]reputation.List, 0, len(copies))
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"io" "io"
@@ -10,9 +10,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -220,7 +220,7 @@ func TestTimingsAreInOrder(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
refused := out.endedLines(t, 2)[1] refused := out.endedLines(t, 2)[1]
wantStatus(t, get(t, server.Addr, proxy.HealthPath), http.StatusOK) wantStatus(t, get(t, server.Addr, servewaf.HealthPath), http.StatusOK)
health := out.endedLines(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
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"slices" "slices"
@@ -1,4 +1,4 @@
package proxy package servewaf
import ( import (
"bytes" "bytes"
@@ -103,13 +103,13 @@ type Report struct {
DurationUpstreamTotal float64 `json:"duration_upstream_total"` DurationUpstreamTotal float64 `json:"duration_upstream_total"`
} }
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers // New returns the server `smallwebwaf servewaf` runs. It answers
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It // rproxy's questions, takes rproxy's reports and answers its own endpoints. It
// passes nothing to an app. rproxy applies the size and time limits to the // passes nothing to an app. rproxy applies the size and time limits to the
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it // visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
// is far above rproxy's, and a question refused for its size would have rproxy // is far above rproxy's, and a question refused for its size would have rproxy
// let the request through. // let the request through.
func NewServeWAF(params Params) *Server { func New(params Params) *Server {
h := newHandler(params) h := newHandler(params)
return &Server{ return &Server{
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"context" "context"
@@ -19,9 +19,9 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -57,7 +57,7 @@ type serveWAF struct {
t *testing.T t *testing.T
addr string addr string
out *output out *output
server *proxy.Server server *servewaf.Server
clock *clock clock *clock
queue *alerts.Queue queue *alerts.Queue
} }
@@ -67,13 +67,13 @@ type serveWAF struct {
func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF { func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF {
t.Helper() t.Helper()
return startServeWAFWith(t, geojsURL, env, func(*proxy.Server) {}) return startServeWAFWith(t, geojsURL, env, func(*servewaf.Server) {})
} }
// startServeWAFWith is startServeWAF with change applied to the server before // startServeWAFWith is startServeWAF with change applied to the server before
// it starts. // it starts.
func startServeWAFWith( func startServeWAFWith(
t *testing.T, geojsURL string, env map[string]string, change func(*proxy.Server), t *testing.T, geojsURL string, env map[string]string, change func(*servewaf.Server),
) *serveWAF { ) *serveWAF {
t.Helper() t.Helper()
@@ -103,8 +103,8 @@ func (s *serveWAF) questionWith(
req := newRequest(s.t, method, s.addr, path, body) req := newRequest(s.t, method, s.addr, path, body)
req.Host = host req.Host = host
req.Header.Set(proxy.ClientHeader, from) req.Header.Set(servewaf.ClientHeader, from)
req.Header.Set(proxy.PeerHeader, rproxyPeer) req.Header.Set(servewaf.PeerHeader, rproxyPeer)
req.Header.Set(requestIDHeader, rand.Text()) req.Header.Set(requestIDHeader, rand.Text())
return req return req
@@ -131,13 +131,13 @@ func (s *serveWAF) allowed(from string) string {
s.t.Helper() s.t.Helper()
req := s.question(siteA, from, "/") req := s.question(siteA, from, "/")
wantDisposition(s.t, do(s.t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(s.t, do(s.t, req), http.StatusOK, servewaf.DispositionAllow)
return req.Header.Get(requestIDHeader) return req.Header.Get(requestIDHeader)
} }
// report sends rproxy's report and returns the answer. // report sends rproxy's report and returns the answer.
func (s *serveWAF) report(report proxy.Report) answer { func (s *serveWAF) report(report servewaf.Report) answer {
s.t.Helper() s.t.Helper()
body, err := json.Marshal(report) body, err := json.Marshal(report)
@@ -152,7 +152,7 @@ func (s *serveWAF) report(report proxy.Report) answer {
func (s *serveWAF) reportBody(body string) answer { func (s *serveWAF) reportBody(body string) answer {
s.t.Helper() s.t.Helper()
return do(s.t, newRequest(s.t, http.MethodPost, s.addr, proxy.ReportPath, return do(s.t, newRequest(s.t, http.MethodPost, s.addr, servewaf.ReportPath,
strings.NewReader(body))) strings.NewReader(body)))
} }
@@ -161,7 +161,7 @@ func (s *serveWAF) reportBody(body string) answer {
func (s *serveWAF) passedOn(from string) { func (s *serveWAF) passedOn(from string) {
s.t.Helper() s.t.Helper()
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: s.allowed(from), RequestID: s.allowed(from),
Status: http.StatusOK, Status: http.StatusOK,
RequestBytes: bodyBytes, RequestBytes: bodyBytes,
@@ -182,7 +182,7 @@ func (s *serveWAF) history() ratelimit.History {
func (s *serveWAF) metrics() string { func (s *serveWAF) metrics() string {
s.t.Helper() s.t.Helper()
req := newRequest(s.t, http.MethodGet, s.addr, proxy.MetricsPath, http.NoBody) req := newRequest(s.t, http.MethodGet, s.addr, servewaf.MetricsPath, http.NoBody)
req.Header.Set("Authorization", bearer) req.Header.Set("Authorization", bearer)
got := do(s.t, req) got := do(s.t, req)
@@ -196,7 +196,7 @@ func wantDisposition(t *testing.T, got answer, status int, disposition string) {
t.Helper() t.Helper()
wantStatus(t, got, status) wantStatus(t, got, status)
if answered := got.header.Get(proxy.DispositionHeader); answered != disposition { if answered := got.header.Get(servewaf.DispositionHeader); answered != disposition {
t.Errorf("answered with disposition %q, want %q", answered, disposition) t.Errorf("answered with disposition %q, want %q", answered, disposition)
} }
} }
@@ -248,10 +248,10 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
req := s.question(siteA, client, "/page?q=1&token=visible") req := s.question(siteA, client, "/page?q=1&token=visible")
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set(proxy.ContentLengthHeader, "1234") req.Header.Set(servewaf.ContentLengthHeader, "1234")
got := do(t, req) got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
wantBody(t, got, "") wantBody(t, got, "")
if id := got.header.Get(requestIDHeader); id != "" { if id := got.header.Get(requestIDHeader); id != "" {
@@ -278,7 +278,7 @@ func TestIPv6VisitorIsCountedInItsGroup(t *testing.T) {
s.allowed("2001:db8::1") s.allowed("2001:db8::1")
got := s.ask("2001:db8::2", "/") got := s.ask("2001:db8::2", "/")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
} }
func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) { func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) {
@@ -287,9 +287,9 @@ func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) {
s := startServeWAF(t, "", map[string]string{denyNets: client}) s := startServeWAF(t, "", map[string]string{denyNets: client})
req := s.question(siteA, "::ffff:"+client, "/") req := s.question(siteA, "::ffff:"+client, "/")
req.Header.Set(proxy.PeerHeader, "::ffff:"+rproxyPeer) req.Header.Set(servewaf.PeerHeader, "::ffff:"+rproxyPeer)
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.ClientIP != client || line.PeerIP != rproxyPeer { if line.ClientIP != client || line.PeerIP != rproxyPeer {
@@ -305,13 +305,13 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) {
change func(*http.Request) change func(*http.Request)
}{ }{
{"client not an address", func(req *http.Request) { {"client not an address", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "not an address") req.Header.Set(servewaf.ClientHeader, "not an address")
}}, }},
{"client empty", func(req *http.Request) { {"client empty", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "") req.Header.Set(servewaf.ClientHeader, "")
}}, }},
{"client address with a zone", func(req *http.Request) { {"client address with a zone", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "2001:db8::1%eth0") req.Header.Set(servewaf.ClientHeader, "2001:db8::1%eth0")
}}, }},
{"id absent", func(req *http.Request) { {"id absent", func(req *http.Request) {
req.Header.Del(requestIDHeader) req.Header.Del(requestIDHeader)
@@ -353,7 +353,7 @@ func TestLongestValidIDIsTaken(t *testing.T) {
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(requestIDHeader, strings.Repeat("x", 128)) req.Header.Set(requestIDHeader, strings.Repeat("x", 128))
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
} }
func TestQuestionWithABodyOver64KiBGets400(t *testing.T) { func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
@@ -361,15 +361,15 @@ func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
s := startServeWAF(t, "", nil) s := startServeWAF(t, "", nil)
over := strings.NewReader(strings.Repeat("x", proxy.QuestionBodyMaxBytes+1)) over := strings.NewReader(strings.Repeat("x", servewaf.QuestionBodyMaxBytes+1))
got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over)) got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over))
wantDisposition(t, got, http.StatusBadRequest, "") wantDisposition(t, got, http.StatusBadRequest, "")
wantProcessMessages(t, s, "a question is refused", 1) wantProcessMessages(t, s, "a question is refused", 1)
wantNoRequestLines(t, s) wantNoRequestLines(t, s)
whole := strings.NewReader(strings.Repeat("x", proxy.QuestionBodyMaxBytes)) whole := strings.NewReader(strings.Repeat("x", servewaf.QuestionBodyMaxBytes))
got = do(t, s.questionWith(http.MethodPost, siteA, client, "/", whole)) got = do(t, s.questionWith(http.MethodPost, siteA, client, "/", whole))
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
} }
func TestQuestionWithAHeldIDIsRefused(t *testing.T) { func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
@@ -385,15 +385,15 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
wantProcessMessages(t, s, "a question is refused", 1) wantProcessMessages(t, s, "a question is refused", 1)
// A denied request's id is held as well. // A denied request's id is held as well.
denied := s.question(siteA, otherClient, proxy.HealthPath) denied := s.question(siteA, otherClient, servewaf.HealthPath)
wantDisposition(t, do(t, denied), http.StatusNotFound, proxy.DispositionDeny) wantDisposition(t, do(t, denied), http.StatusNotFound, servewaf.DispositionDeny)
again = s.question(siteA, client, "/") again = s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader)) again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader))
wantDisposition(t, do(t, again), http.StatusBadRequest, "") wantDisposition(t, do(t, again), http.StatusBadRequest, "")
// The report ends the holding, and the refused questions changed nothing. // The report ends the holding, and the refused questions changed nothing.
got := s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes}) got := s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
if history := s.history(); history.ResponseBytes != answerBytes { if history := s.history(); history.ResponseBytes != answerBytes {
@@ -402,7 +402,7 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
again = s.question(siteA, client, "/") again = s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, id) again.Header.Set(requestIDHeader, id)
wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, again), http.StatusOK, servewaf.DispositionAllow)
} }
func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) { func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
@@ -436,7 +436,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
// A report for a question not yet answered is ignored, and that question's // A report for a question not yet answered is ignored, and that question's
// id is taken. // id is taken.
got := s.report(proxy.Report{RequestID: id, ResponseBytes: 1000}) got := s.report(servewaf.Report{RequestID: id, ResponseBytes: 1000})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
// This would wait on the same lookup were the id not taken. // This would wait on the same lookup were the id not taken.
@@ -451,7 +451,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
select { select {
case got := <-answered: case got := <-answered:
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("the question got no answer") t.Fatal("the question got no answer")
} }
@@ -466,7 +466,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
} }
// The true report comes after, and is the one that counts. // The true report comes after, and is the one that counts.
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes}) got = s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
if history := s.history(); history.ResponseBytes != answerBytes { if history := s.history(); history.ResponseBytes != answerBytes {
@@ -484,13 +484,13 @@ func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) {
s := startServeWAF(t, "", nil) s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Del(proxy.PeerHeader) req.Header.Del(servewaf.PeerHeader)
if peer != "" { if peer != "" {
req.Header.Set(proxy.PeerHeader, peer) req.Header.Set(servewaf.PeerHeader, peer)
} }
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
if line := s.out.requestLine(t); line.PeerIP != "invalid IP" { if line := s.out.requestLine(t); line.PeerIP != "invalid IP" {
t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP) t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP)
@@ -511,8 +511,8 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) {
}) })
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(proxy.ContentLengthHeader, announced) req.Header.Set(servewaf.ContentLengthHeader, announced)
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
if line := s.out.requestLine(t); line.ContentLength != 0 { if line := s.out.requestLine(t); line.ContentLength != 0 {
t.Errorf("log line has content_length %d, want none", line.ContentLength) t.Errorf("log line has content_length %d, want none", line.ContentLength)
@@ -527,13 +527,13 @@ func TestHeaderSentTwiceUsesItsFirstValue(t *testing.T) {
s := startServeWAF(t, "", nil) s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Add(proxy.ClientHeader, otherClient) req.Header.Add(servewaf.ClientHeader, otherClient)
req.Header.Add(proxy.PeerHeader, "172.18.0.3") req.Header.Add(servewaf.PeerHeader, "172.18.0.3")
req.Header.Set(requestIDHeader, "first") req.Header.Set(requestIDHeader, "first")
req.Header.Add(requestIDHeader, "second") req.Header.Add(requestIDHeader, "second")
req.Header.Set(proxy.ContentLengthHeader, "1234") req.Header.Set(servewaf.ContentLengthHeader, "1234")
req.Header.Add(proxy.ContentLengthHeader, "5678") req.Header.Add(servewaf.ContentLengthHeader, "5678")
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.ClientIP != client || line.PeerIP != rproxyPeer || if line.ClientIP != client || line.PeerIP != rproxyPeer ||
@@ -562,7 +562,7 @@ func sendQuestionWithPartOfItsBody(t *testing.T, conn net.Conn) {
_, err := fmt.Fprintf(conn, _, err := fmt.Fprintf(conn,
"POST / HTTP/1.1\r\nHost: %s\r\n%s: %s\r\n%s: an-id\r\n"+ "POST / HTTP/1.1\r\nHost: %s\r\n%s: %s\r\n%s: an-id\r\n"+
"Content-Length: 100\r\n\r\npart", "Content-Length: 100\r\n\r\npart",
siteA, proxy.ClientHeader, client, requestIDHeader) siteA, servewaf.ClientHeader, client, requestIDHeader)
if err != nil { if err != nil {
t.Fatalf("send the question: %v", err) t.Fatalf("send the question: %v", err)
} }
@@ -592,7 +592,7 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAFWith(t, "", map[string]string{metricsToken: token}, s := startServeWAFWith(t, "", map[string]string{metricsToken: token},
func(server *proxy.Server) { server.ReadTimeout = readTimeout }) func(server *servewaf.Server) { server.ReadTimeout = readTimeout })
conn := dial(t, s.addr) conn := dial(t, s.addr)
sendQuestionWithPartOfItsBody(t, conn) sendQuestionWithPartOfItsBody(t, conn)
@@ -615,7 +615,7 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
// The id was not taken. // 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, servewaf.DispositionAllow)
}) })
} }
} }
@@ -632,7 +632,7 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
}) })
got := s.ask(client, "/") got := s.ask(client, "/")
wantDisposition(t, got, status, proxy.DispositionDeny) wantDisposition(t, got, status, servewaf.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n") wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied) wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
}) })
@@ -647,7 +647,7 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
}) })
got := s.ask(client, "/") got := s.ask(client, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionClose) wantDisposition(t, got, http.StatusOK, servewaf.DispositionClose)
wantBody(t, got, "") wantBody(t, got, "")
// The visitor is sent nothing. // The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied) wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
@@ -664,12 +664,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
}) })
got := s.ask(client, "/blocked") got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionRuleBlocked)
got = s.ask(client, "/.env") got = s.ask(client, "/.env")
wantDisposition(t, got, http.StatusTooManyRequests, proxy.DispositionDeny) wantDisposition(t, got, http.StatusTooManyRequests, servewaf.DispositionDeny)
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests, wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
requestlog.ActionBanned) requestlog.ActionBanned)
} }
@@ -681,15 +681,17 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
metricsToken: token, denyNets: otherClient, metricsToken: token, denyNets: otherClient,
}) })
for _, path := range []string{proxy.HealthPath, proxy.MetricsPath, proxy.ReportPath} { for _, path := range []string{
servewaf.HealthPath, servewaf.MetricsPath, servewaf.ReportPath,
} {
got := s.ask(client, path) got := s.ask(client, path)
wantDisposition(t, got, http.StatusNotFound, proxy.DispositionDeny) wantDisposition(t, got, http.StatusNotFound, servewaf.DispositionDeny)
} }
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin) wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
got := s.ask(otherClient, proxy.MetricsPath) got := s.ask(otherClient, servewaf.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
} }
func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) { func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
@@ -698,7 +700,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client}) s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client})
got := s.ask(client, "/") got := s.ask(client, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
@@ -714,7 +716,7 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
for range 2 { for range 2 {
got := s.ask(client, "/blocked") got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
@@ -733,7 +735,7 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
for range 2 { for range 2 {
got := s.ask(client, "/blocked") got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
} }
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
@@ -762,7 +764,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
}) })
got := s.ask(fromDE, "/") got := s.ask(fromDE, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != tc.wantASN || country != tc.wantCountry { if asn != tc.wantASN || country != tc.wantCountry {
@@ -787,7 +789,7 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
} }
// 100 bytes are over the limit of 99. // 100 bytes are over the limit of 99.
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: id, Status: http.StatusBadGateway, RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
}) })
@@ -811,7 +813,7 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
} }
got = s.ask(client, "/") got = s.ask(client, "/")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
} }
func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) { func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
@@ -825,10 +827,10 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
}) })
s.passedOn(fromKP) s.passedOn(fromKP)
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, servewaf.DispositionAllow)
s.passedOn(fromDE) s.passedOn(fromDE)
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, servewaf.DispositionDeny)
} }
func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) { func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) {
@@ -846,7 +848,7 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing
t.Errorf("anomaly counters %v before the report, want none", counters) t.Errorf("anomaly counters %v before the report, want none", counters)
} }
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: id, Status: http.StatusBadGateway, RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
}) })
@@ -874,7 +876,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
s.allowed(client) s.allowed(client)
denied := s.question(siteA, client, "/") denied := s.question(siteA, client, "/")
wantDisposition(t, do(t, denied), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, do(t, denied), http.StatusForbidden, servewaf.DispositionDeny)
counters := s.server.Anomalies.Snapshot() counters := s.server.Anomalies.Snapshot()
if len(counters) != 1 || counters[0].Minute.Current != 1 { if len(counters) != 1 || counters[0].Minute.Current != 1 {
@@ -891,7 +893,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
wantMetric(t, s.metrics(), series, 1) wantMetric(t, s.metrics(), series, 1)
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden, RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotal: 2, ResponseBytes: 1000, DurationTotal: 2,
}) })
@@ -924,7 +926,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`, wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`,
1) // the scrape itself 1) // the scrape itself
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: id, Status: http.StatusBadGateway, RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 5, DurationUpstreamTotal: 4, DurationTotal: 5, DurationUpstreamTotal: 4,
@@ -942,7 +944,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
// check needs a server that has not been scraped yet. // check needs a server that has not been scraped yet.
unscraped := startServeWAF(t, "", map[string]string{metricsToken: token}) unscraped := startServeWAF(t, "", map[string]string{metricsToken: token})
got = unscraped.report(proxy.Report{ got = unscraped.report(servewaf.Report{
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5, RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -955,7 +957,7 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
got := s.report(proxy.Report{RequestID: "no-such-request", ResponseBytes: 1000}) got := s.report(servewaf.Report{RequestID: "no-such-request", ResponseBytes: 1000})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
if clients := s.server.Limiter.Snapshot(); len(clients) != 0 { if clients := s.server.Limiter.Snapshot(); len(clients) != 0 {
@@ -972,7 +974,7 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
// A second report of the same request does not count it twice. // A second report of the same request does not count it twice.
id := s.allowed(client) id := s.allowed(client)
for range 2 { for range 2 {
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes}) got = s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
} }
@@ -1160,7 +1162,7 @@ func TestBytesAtTheirMostEachWayBanRatherThanOverflow(t *testing.T) {
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit}) s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: s.allowed(client), Status: http.StatusOK, RequestID: s.allowed(client), Status: http.StatusOK,
RequestBytes: reportedBytesLimit, ResponseBytes: reportedBytesLimit, RequestBytes: reportedBytesLimit, ResponseBytes: reportedBytesLimit,
}) })
@@ -1245,9 +1247,9 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
id := req.Header.Get(requestIDHeader) id := req.Header.Get(requestIDHeader)
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: id, Status: http.StatusCreated, RequestID: id, Status: http.StatusCreated,
RequestBytes: bodyBytes, ResponseBytes: answerBytes, RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 12.5, DurationUpstreamTotal: 10.25, DurationTotal: 12.5, DurationUpstreamTotal: 10.25,
@@ -1294,7 +1296,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/", "close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
0, requestlog.ActionDenied, 0, requestlog.ActionDenied,
}, },
{"own prefix", nil, proxy.HealthPath, http.StatusNotFound, requestlog.ActionAdmin}, {"own prefix", nil, servewaf.HealthPath, http.StatusNotFound, requestlog.ActionAdmin},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1305,7 +1307,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
id := req.Header.Get(requestIDHeader) id := req.Header.Get(requestIDHeader)
do(t, req) do(t, req)
got := s.report(proxy.Report{ got := s.report(servewaf.Report{
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3, RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3,
}) })
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
@@ -1341,18 +1343,18 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) {
t.Fatalf("question: %v", err) t.Fatalf("question: %v", err)
} }
wantDisposition(t, readAnswer(res), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, readAnswer(res), http.StatusOK, servewaf.DispositionAllow)
} }
// The table is full again with next and those, without oldest. // The table is full again with next and those, without oldest.
got := s.report(proxy.Report{RequestID: oldest, ResponseBytes: answerBytes}) got := s.report(servewaf.Report{RequestID: oldest, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 0 { if counted := s.history().Requests; counted != 0 {
t.Errorf("history holds %d requests, want the dropped one not counted", counted) t.Errorf("history holds %d requests, want the dropped one not counted", counted)
} }
got = s.report(proxy.Report{RequestID: next, ResponseBytes: answerBytes}) got = s.report(servewaf.Report{RequestID: next, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent) wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 1 { if counted := s.history().Requests; counted != 1 {
@@ -1366,10 +1368,10 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
s := startServeWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)}) s := startServeWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)})
got := do(t, s.question(siteA, client, "/.env")) got := do(t, s.question(siteA, client, "/.env"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
got = do(t, s.question(siteB, client, "/")) got = do(t, s.question(siteB, client, "/"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
if lines[0].Host != siteA || lines[1].Host != siteB { if lines[0].Host != siteA || lines[1].Host != siteB {
@@ -1395,7 +1397,7 @@ func TestRequestsOnEverySiteCountTowardOneSetOfLimits(t *testing.T) {
s.allowed(client) s.allowed(client)
got := do(t, s.question(siteB, client, "/")) got := do(t, s.question(siteB, client, "/"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
} }
func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) { func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
@@ -1410,13 +1412,13 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
path, authorization string path, authorization string
want int want int
}{ }{
{proxy.HealthPath, "", http.StatusOK}, {servewaf.HealthPath, "", http.StatusOK},
{proxy.MetricsPath, bearer, http.StatusOK}, {servewaf.MetricsPath, bearer, http.StatusOK},
{proxy.MetricsPath, "", http.StatusUnauthorized}, {servewaf.MetricsPath, "", http.StatusUnauthorized},
{proxy.BansPath, adminBearer, http.StatusOK}, {servewaf.BansPath, adminBearer, http.StatusOK},
{proxy.BansPath, "", http.StatusUnauthorized}, {servewaf.BansPath, "", http.StatusUnauthorized},
{proxy.ClientsPath + client, adminBearer, http.StatusOK}, {servewaf.ClientsPath + client, adminBearer, http.StatusOK},
{proxy.ReportPath, "", http.StatusNotFound}, {servewaf.ReportPath, "", http.StatusNotFound},
{"/_smallwebwaf/other", "", http.StatusNotFound}, {"/_smallwebwaf/other", "", http.StatusNotFound},
{"/", "", http.StatusNotFound}, {"/", "", http.StatusNotFound},
} { } {
@@ -1429,9 +1431,9 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
req.Header.Set("Authorization", tc.authorization) req.Header.Set("Authorization", tc.authorization)
if strings.HasPrefix(tc.path, "/_smallwebwaf/") { if strings.HasPrefix(tc.path, "/_smallwebwaf/") {
wantDisposition(t, do(t, req), http.StatusNotFound, proxy.DispositionDeny) wantDisposition(t, do(t, req), http.StatusNotFound, servewaf.DispositionDeny)
} else { } else {
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
} }
} }
} }
@@ -1441,10 +1443,11 @@ func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) {
s := startServeWAF(t, "", nil) s := startServeWAF(t, "", nil)
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK) wantStatus(t, get(t, s.addr, servewaf.HealthPath), http.StatusOK)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if _, there := line.fields["servewaf_line"]; there || line.Path != proxy.HealthPath { if _, there := line.fields["servewaf_line"]; there ||
line.Path != servewaf.HealthPath {
t.Errorf("the health check's log line is %+v", line.fields) t.Errorf("the health check's log line is %+v", line.fields)
} }
} }
@@ -1456,12 +1459,12 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
req := s.questionWith(http.MethodPost, siteA, client, "/", req := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader("a body")) strings.NewReader("a body"))
req.Header.Set(proxy.ContentLengthHeader, "1073741824") req.Header.Set(servewaf.ContentLengthHeader, "1073741824")
req.Header.Set(forwardedFor, otherClient) req.Header.Set(forwardedFor, otherClient)
req.Header.Set("X-Long", strings.Repeat("x", 40<<10)) req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
got := do(t, req) got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.ClientIP != client || line.ContentLength != 1<<30 { if line.ClientIP != client || line.ContentLength != 1<<30 {
@@ -1529,7 +1532,7 @@ carried header:Content-Length block ^6$
strings.NewReader("a body")) strings.NewReader("a body"))
if tc.announced != "" { if tc.announced != "" {
req.Header.Set(proxy.ContentLengthHeader, tc.announced) req.Header.Set(servewaf.ContentLengthHeader, tc.announced)
} }
wantStatus(t, do(t, req), tc.want) wantStatus(t, do(t, req), tc.want)
@@ -1547,19 +1550,19 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
req := s.questionWith(http.MethodPost, siteA, client, "/", req := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader(body)) strings.NewReader(body))
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
req.Header.Set(proxy.ContentLengthHeader, "100000") req.Header.Set(servewaf.ContentLengthHeader, "100000")
return do(t, req) return do(t, req)
} }
got := ask(formData, "id=1'%20OR%20'1'='1") got := ask(formData, "id=1'%20OR%20'1'='1")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
// The first 64 KiB of a longer JSON body cannot be parsed, which is no // The first 64 KiB of a longer JSON body cannot be parsed, which is no
// attack, so the Core Rule Set does not read it. // attack, so the Core Rule Set does not read it.
cut := `{"text":"` + strings.Repeat("x", proxy.QuestionBodyMaxBytes) cut := `{"text":"` + strings.Repeat("x", servewaf.QuestionBodyMaxBytes)
got = ask("application/json", cut[:proxy.QuestionBodyMaxBytes]) got = ask("application/json", cut[:servewaf.QuestionBodyMaxBytes])
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow) wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
} }
func TestCoreRuleSetAlertNamesTheSite(t *testing.T) { func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
@@ -1568,7 +1571,7 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
s := startServeWAF(t, "", map[string]string{wafMode: block}) s := startServeWAF(t, "", map[string]string{wafMode: block})
got := s.ask(client, sqlInjection) got := s.ask(client, sqlInjection)
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock || if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock ||
@@ -1583,7 +1586,7 @@ func TestReputationHitAlertNamesTheSite(t *testing.T) {
s := startServeWAF(t, "", map[string]string{blocklistURLs: dropURL}) s := startServeWAF(t, "", map[string]string{blocklistURLs: dropURL})
loadLists(t, s.server, map[string][]string{dropURL: {client}}) loadLists(t, s.server, map[string][]string{dropURL: {client}})
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny) wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, servewaf.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package proxy_test package servewaf_test
import ( import (
"net/http" "net/http"
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// healthCheckTimeout bounds the whole health check. // healthCheckTimeout bounds the whole health check.
@@ -107,7 +107,7 @@ func checkRProxyAndApp(
// listenAddr, which the settings have checked has one. // listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error { func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr) _, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + proxy.HealthPath health := "http://" + net.JoinHostPort("127.0.0.1", port) + servewaf.HealthPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody) req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
if err != nil { if err != nil {
+10 -10
View File
@@ -19,7 +19,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
@@ -153,7 +153,7 @@ func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
// One request's three lines carry one id, rproxy's with servewaf's answer. // One request's three lines carry one id, rproxy's with servewaf's answer.
line := firstOut.line(t, "path", bannedProbe) line := firstOut.line(t, "path", bannedProbe)
if line["disposition"] != proxy.DispositionDeny || line["client_ip"] != placed { if line["disposition"] != servewaf.DispositionDeny || line["client_ip"] != placed {
t.Errorf("rproxy's line is %v", line) t.Errorf("rproxy's line is %v", line)
} }
@@ -225,7 +225,7 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
wantStatus(t, site+"/after-stop", placed, http.StatusOK) wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop") line := out.line(t, "path", "/after-stop")
if line["disposition"] != proxy.DispositionAllow || if line["disposition"] != servewaf.DispositionAllow ||
line["default_disposition"] != true { line["default_disposition"] != true {
t.Errorf("rproxy's line is %v", line) t.Errorf("rproxy's line is %v", line)
} }
@@ -318,7 +318,7 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
const smuggledID = "smuggled-request" const smuggledID = "smuggled-request"
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" + smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
proxy.ClientHeader + ": " + otherClient + "\r\n" + servewaf.ClientHeader + ": " + otherClient + "\r\n" +
"X-Request-ID: " + smuggledID + "\r\n\r\n" "X-Request-ID: " + smuggledID + "\r\n\r\n"
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+ status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
@@ -459,10 +459,10 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
protocols := make(chan string, 10) protocols := make(chan string, 10)
backend := httptest.NewUnstartedServer(http.HandlerFunc( backend := httptest.NewUnstartedServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" { if r.Header.Get(servewaf.ClientHeader) != "" {
protocols <- r.Proto protocols <- r.Proto
w.Header().Set(proxy.DispositionHeader, proxy.DispositionDeny) w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionDeny)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
} }
})) }))
@@ -532,8 +532,8 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
release := make(chan struct{}) release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" { if r.Header.Get(servewaf.ClientHeader) != "" {
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow) w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
return return
} }
@@ -640,7 +640,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc( backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow) w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
})) }))
t.Cleanup(backend.Close) t.Cleanup(backend.Close)
@@ -757,7 +757,7 @@ func TestMainRunsRProxy(t *testing.T) {
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9") addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+proxy.HealthPath, http.NoBody) "http://"+addr+servewaf.HealthPath, http.NoBody)
if err != nil { if err != nil {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
+8 -8
View File
@@ -14,7 +14,7 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
@@ -67,7 +67,7 @@ func TestMainRunsServeWAF(t *testing.T) {
}) })
addr, _ := out.line(t, "msg", "starting")["address"].(string) addr, _ := out.line(t, "msg", "starting")["address"].(string)
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow) ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow)
err = cmd.Process.Signal(syscall.SIGTERM) err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil { if err != nil {
@@ -132,7 +132,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Fatalf("new request: %v", err) t.Fatalf("new request: %v", err)
} }
req.Header.Set(proxy.ClientHeader, placed) req.Header.Set(servewaf.ClientHeader, placed)
req.Header.Set("X-Request-ID", rand.Text()) req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
@@ -147,9 +147,9 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
_ = res.Body.Close() _ = res.Body.Close()
if res.StatusCode != status || if res.StatusCode != status ||
res.Header.Get(proxy.DispositionHeader) != disposition { res.Header.Get(servewaf.DispositionHeader) != disposition {
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode, t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
res.Header.Get(proxy.DispositionHeader), status, disposition) res.Header.Get(servewaf.DispositionHeader), status, disposition)
} }
} }
@@ -193,12 +193,12 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
} }
runServeWAF(t, env, func(addr string) { runServeWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow) ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow)
// The default rule file bans a probe for this. // The default rule file bans a probe for this.
ask(t, addr, "/.env", http.StatusForbidden, proxy.DispositionDeny) ask(t, addr, "/.env", http.StatusForbidden, servewaf.DispositionDeny)
}) })
runServeWAF(t, env, func(addr string) { runServeWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny) ask(t, addr, "/", http.StatusForbidden, servewaf.DispositionDeny)
}) })
} }
+5 -5
View File
@@ -23,11 +23,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
) )
@@ -190,7 +190,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
func newServer( func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger, cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue, now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
) (*proxy.Server, error) { ) (*servewaf.Server, error) {
var lookupFile *lookup.File var lookupFile *lookup.File
if cfg.LookupSource == "file" { if cfg.LookupSource == "file" {
@@ -207,7 +207,7 @@ func newServer(
} }
} }
server := proxy.NewServeWAF(proxy.Params{ server := servewaf.New(servewaf.Params{
Config: cfg, Config: cfg,
RequestLog: stdout, RequestLog: stdout,
ProcessLog: processLog, ProcessLog: processLog,
@@ -230,7 +230,7 @@ func newServer(
// loadStateFiles reads the state files into the parts of server and into // loadStateFiles reads the state files into the parts of server and into
// alertQueue, as state.Load does. // alertQueue, as state.Load does.
func loadStateFiles( func loadStateFiles(
cfg *config.Config, server *proxy.Server, alertQueue *alerts.Queue, cfg *config.Config, server *servewaf.Server, alertQueue *alerts.Queue,
now func() time.Time, processLog *slog.Logger, now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) { ) (*state.Files, error) {
return state.Load(state.Params{ return state.Load(state.Params{
@@ -316,7 +316,7 @@ func startSending(
// shutdownTimeout to finish, and writes every state file, alerts.json with // shutdownTimeout to finish, and writes every state file, alerts.json with
// the alerts still waiting. // the alerts still waiting.
func serve( func serve(
ctx context.Context, server *proxy.Server, listener net.Listener, ctx context.Context, server *servewaf.Server, listener net.Listener,
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue, files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
processLog *slog.Logger, processLog *slog.Logger,
) int { ) int {
+5 -5
View File
@@ -20,7 +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/servewaf"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf" "sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
) )
@@ -1225,9 +1225,9 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "500", "SWWAF_RATE_LIMIT_PER_MINUTE": "120",
"SWWAF_RATE_LIMIT_PER_HOUR": "20000", "SWWAF_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "300000", rateLimitPerDay: "6000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "", "SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
@@ -1627,7 +1627,7 @@ 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(servewaf.ClientHeader, from)
req.Header.Set("X-Request-ID", rand.Text()) req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
+8 -4
View File
@@ -35,9 +35,10 @@ GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025
# gitea's one user, who owns the repository. # gitea's one user, who owns the repository.
PASSWORD="owner-password" PASSWORD="owner-password"
# The files the script makes, and traefik's and the gateway's addresses on the # The files the script makes, gitea's secret key for this run, and traefik's
# shared network once the setup runs. # and the gateway's addresses on the shared network once the setup runs.
WORK="" WORK=""
secret_key=""
traefik="" traefik=""
gateway="" gateway=""
@@ -53,9 +54,11 @@ cleanup() {
} }
# compose <argument>...: docker compose on the setup, under this run's project # compose <argument>...: docker compose on the setup, under this run's project
# name, with the image built here and no port of traefik's published. # name, with the image built here, this run's secret key and no port of
# traefik's published.
compose() { compose() {
SMALLWEBWAF_IMAGE="$IMAGE" docker compose --project-name "$NAME" \ SMALLWEBWAF_IMAGE="$IMAGE" GITEA_SECRET_KEY="$secret_key" \
docker compose --project-name "$NAME" \
--file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@" --file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@"
} }
@@ -141,6 +144,7 @@ main() {
printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml" printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml"
docker build --no-cache -t "$IMAGE" . docker build --no-cache -t "$IMAGE" .
secret_key="$(docker run --rm "$GITEA_IMAGE" gitea generate secret SECRET_KEY)"
# rproxy is healthy once gitea takes connections. # rproxy is healthy once gitea takes connections.
compose up --detach --quiet-pull --wait --wait-timeout 120 || compose up --detach --quiet-pull --wait --wait-timeout 120 ||