Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230db01127 | ||
|
|
21cac70b50 | ||
|
|
2ab57eb278 | ||
|
|
a63b7e4d2d | ||
|
|
3cb41f27a8 | ||
|
|
6deb9d7581 | ||
|
|
8632da556a | ||
|
|
344ce13c96 |
+4
-25
@@ -133,32 +133,11 @@ RUN mkdir /etc/sysusers.d \
|
||||
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
|
||||
> /etc/sysusers.d/runit.conf
|
||||
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at
|
||||
# the snapshot moment, which is never earlier than the Ubuntu image above.
|
||||
# apt checks every package against the snapshot's InRelease files, and
|
||||
# this step checks those against the hashes named here, which are those
|
||||
# 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) \
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||
# them over plain HTTP and checks each one against the archive's signed
|
||||
# package lists, so no CA certificates are needed.
|
||||
RUN apt-get update \
|
||||
&& 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||
run example-app gitea-requests
|
||||
run example-app gitea-requests gitea-setup
|
||||
|
||||
# Makefile targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||
# which test checks. build and run are for working on the code by hand;
|
||||
# example-app checks the image with an app built on it; gitea-requests
|
||||
# reports the gitea requests the Core Rule Set refuses at the defaults.
|
||||
# reports the gitea requests the Core Rule Set refuses at the defaults;
|
||||
# gitea-setup checks the setup in deploy/gitea.
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -49,3 +50,6 @@ example-app:
|
||||
|
||||
gitea-requests:
|
||||
@script/gitea-requests
|
||||
|
||||
gitea-setup:
|
||||
@script/gitea-setup
|
||||
|
||||
@@ -4,58 +4,45 @@
|
||||
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
|
||||
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
|
||||
refuses the ones that look like abuse. `smallwebwaf` is one binary with two
|
||||
commands that do this together:
|
||||
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
|
||||
one image, with two commands that do this together:
|
||||
|
||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. It accepts
|
||||
requests on the app's behalf and asks `servewaf` about each one. It passes the
|
||||
allowed ones to the app, and the app's answers back. It keeps nothing, and
|
||||
never touches disk.
|
||||
- `smallwebwaf servewaf` decides. It runs the checks, and keeps the bans,
|
||||
counters and history. One `servewaf` serves many sites.
|
||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
||||
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
||||
to the app, and passes the app's answers back. It keeps nothing and never
|
||||
touches disk.
|
||||
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `servewaf` serves many sites: a client
|
||||
banned through one site is refused on all of them, and its requests on all of
|
||||
them count toward one set of limits.
|
||||
|
||||
The site keeps answering while `servewaf` is down, slow or restarting. `rproxy`
|
||||
waits 2 seconds for an answer, and then passes the request to the app.
|
||||
The site keeps answering while `servewaf` is down, slow or restarting: without
|
||||
an answer in time, `rproxy` passes the request to the app.
|
||||
|
||||
`rproxy` requires one setting, `SWRP_WAF_BACKEND`, which says where `servewaf`
|
||||
is. In a container of its own, it also needs `SWRP_UPSTREAM_URL`, which says
|
||||
where the app is. Every other setting has a default chosen for a service on the
|
||||
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
|
||||
reference documents under [`docs/`](docs/) are listed under "Documents" below.
|
||||
It protects against:
|
||||
|
||||
## What it protects against
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
||||
client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves
|
||||
room for programmatic use of an API, such as an automation fleet on another
|
||||
host. A fleet of about 20 agents makes about 120 requests a minute at its
|
||||
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.
|
||||
- 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
|
||||
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||
a while at first, for three times as long at each repeat, and permanently when
|
||||
it keeps at it.
|
||||
- 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
|
||||
next request during the ban makes the ban permanent. The default rule file
|
||||
does not cover probes for other apps, such as a WordPress login. A `ban` rule
|
||||
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
|
||||
default rule file bans such a client at its first probe, and its next request
|
||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||
paths, ban probes for other apps, such as a WordPress login, the same way.
|
||||
- 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
|
||||
common web attacks. Coraza, a Go engine, runs those rules (see "Attack
|
||||
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is
|
||||
refused.
|
||||
traversal. The OWASP Core Rule Set, a published set of rules that recognise
|
||||
common web attacks, runs on every request through Coraza, a Go engine. A
|
||||
request the rules take for an attack is refused.
|
||||
- Abuse from networks and countries you do not want. You can:
|
||||
- refuse whole netblocks and countries;
|
||||
- give the clients of chosen networks and countries a fraction of every
|
||||
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;
|
||||
- ban clients named by your own CrowdSec engine.
|
||||
- Slow and oversized requests. Every request and every answer has a time limit
|
||||
@@ -63,29 +50,26 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
|
||||
|
||||
For the operator, it:
|
||||
|
||||
- logs each request as JSON lines, saying what it did and why (see
|
||||
[`docs/request-log.md`](docs/request-log.md));
|
||||
- sends alerts to a webhook, Slack or ntfy (see
|
||||
[`docs/alerts.md`](docs/alerts.md));
|
||||
- serves Prometheus metrics (see [`docs/metrics.md`](docs/metrics.md));
|
||||
- logs each request as JSON lines, saying what it did and why;
|
||||
- sends alerts to a webhook, Slack or ntfy;
|
||||
- serves Prometheus metrics;
|
||||
- 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
|
||||
[`docs/state-files.md`](docs/state-files.md)).
|
||||
that a restart loses nothing and a ban can be lifted with an editor.
|
||||
|
||||
Not built, and not planned:
|
||||
|
||||
- TLS and routing by host name. Both stay with traefik, the reverse proxy in
|
||||
front of `rproxy`.
|
||||
- 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.
|
||||
|
||||
## Getting started
|
||||
|
||||
Build the image from a clone. `make docker` runs the tests and the linter, then
|
||||
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
|
||||
amd64 archive.
|
||||
### The image
|
||||
|
||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||
the linter, then builds the image, tagged `smallwebwaf`.
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||
@@ -93,62 +77,38 @@ cd smallwebwaf
|
||||
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 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 is three containers on one private network:
|
||||
|
||||
- the app;
|
||||
- `rproxy`, in front of the app, which traefik routes to;
|
||||
- `servewaf`, with a volume for its state files.
|
||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
||||
`/var/lib/smallwebwaf` for its state files.
|
||||
|
||||
```sh
|
||||
docker network create site
|
||||
|
||||
docker run --detach --name servewaf --network site \
|
||||
--env SMALLWEBWAF_COMMAND=servewaf \
|
||||
--volume servewaf-state:/var/lib/smallwebwaf \
|
||||
<registry>/smallwebwaf@sha256:<digest>
|
||||
|
||||
docker run --detach --name app --network site \
|
||||
<registry>/app@sha256:<digest>
|
||||
|
||||
docker run --detach --name rproxy --network site \
|
||||
--env SWRP_WAF_BACKEND=http://servewaf:8080 \
|
||||
--env SWRP_UPSTREAM_URL=http://app:8081 \
|
||||
<registry>/smallwebwaf@sha256:<digest>
|
||||
```
|
||||
|
||||
Here the app listens on port 8081, at its container's address on that network.
|
||||
Point traefik's labels at port 8080 of `rproxy`
|
||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||
`rproxy`, and can ask `servewaf` questions.
|
||||
|
||||
With no setting, `servewaf` sends the address of every new visitor to GeoJS, a
|
||||
web service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
|
||||
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
|
||||
database file on your own host, looks addresses up in that file (see "Lookup of
|
||||
AS number and country" in [`SPEC.md`](SPEC.md)).
|
||||
|
||||
### Many sites, one `servewaf`
|
||||
|
||||
Run one `rproxy` for each app, all with `SWRP_WAF_BACKEND` naming the same
|
||||
`servewaf`. It keeps one table of clients and bans for all of them:
|
||||
|
||||
- A client banned through one site is refused on all of them.
|
||||
- A client's requests on all of them count toward one set of limits.
|
||||
- Log lines, alerts and ban notes name the site by its host.
|
||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
|
||||
of gitea" examples below show this shape.
|
||||
|
||||
### `rproxy` in the app's container
|
||||
|
||||
An app's image can also be built on the `smallwebwaf` image, so that `rproxy`
|
||||
runs beside the app. `servewaf` still runs in a container of its own. The app's
|
||||
`Dockerfile` starts `FROM` the `smallwebwaf` image, named by digest, and adds:
|
||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||
runs beside the app in one container. `servewaf` still runs in a container of
|
||||
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
||||
digest, and adds:
|
||||
|
||||
- the app's binary;
|
||||
- the packages the app needs, from the nixpkgs in the image;
|
||||
@@ -190,39 +150,440 @@ main "$@"
|
||||
|
||||
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
|
||||
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
|
||||
(see "Checking that it works" below). To run `rproxy` and `servewaf` on the host
|
||||
while working on them, see `script/run` under "Entrypoints" below.
|
||||
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. 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
|
||||
`servewaf`:
|
||||
- `docker inspect --format '{{.State.Health.Status}}' <container>` says
|
||||
`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;
|
||||
- does not answer at all;
|
||||
- does not answer within `SWRP_TIMEOUT_MS`, 2 seconds by default.
|
||||
## Configuration
|
||||
|
||||
Its default, `allow`, passes the request to the app without `servewaf`'s answer.
|
||||
`deny` answers `503` instead. Either way the request gets no report, so its
|
||||
bytes are not counted and it is not added to its client's history. When
|
||||
`servewaf` does answer, a `deny` or an error goes to the visitor as `servewaf`
|
||||
gave it, and `SWRP_DEFAULT_DISPOSITION` plays no part.
|
||||
Every setting is an environment variable, read once at start; changing one means
|
||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
default, chosen for a service on the open internet. A setting that needs an
|
||||
account, a key, a token or an alert destination stays off until you give one.
|
||||
|
||||
## 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
|
||||
request, the steps after that are skipped, apart from step 15, and nothing of
|
||||
the request reaches the app. "What happens to a request" in [`SPEC.md`](SPEC.md)
|
||||
gives each step in full, under the same numbers.
|
||||
Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
|
||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
|
||||
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
|
||||
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
|
||||
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `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:
|
||||
|
||||
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
|
||||
request.
|
||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
||||
visitor sent it, with the first 64 KiB of its body.
|
||||
|
||||
`servewaf` takes the checks:
|
||||
|
||||
@@ -231,7 +592,8 @@ gives each step in full, under the same numbers.
|
||||
5. The lookup of the client's AS number and country.
|
||||
6. The country lists.
|
||||
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.
|
||||
10. The trap paths.
|
||||
11. The rule files.
|
||||
@@ -241,84 +603,113 @@ gives each step in full, under the same numbers.
|
||||
|
||||
`rproxy` takes the rest, with `servewaf` counting at the end:
|
||||
|
||||
14. The request is passed to the app.
|
||||
15. After the answer, `rproxy` sends `servewaf` a report. `servewaf` counts the
|
||||
byte limits and adds the request to the client's history.
|
||||
14. The request is passed to the app, streaming.
|
||||
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
||||
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
||||
the byte limits and adds the request to the client's history.
|
||||
|
||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see
|
||||
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
|
||||
Core Rule Set match answers `403`. `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 (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
|
||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
||||
and the status for a size or time limit, such as `413` for a body too large.
|
||||
|
||||
## Settings you are likely to touch
|
||||
### Clients and bans
|
||||
|
||||
Each setting is an environment variable, read at start. Those of `servewaf`
|
||||
start with `SWWAF_`, and those of `rproxy` with `SWRP_`.
|
||||
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the
|
||||
values it takes. The ones most deployments touch:
|
||||
- A client is one IPv4 address or one IPv6 netblock of
|
||||
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
||||
- Each limit is counted over a sliding window with a few integers per client.
|
||||
The table of clients and the table of bans each have a bound
|
||||
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||
lifts it.
|
||||
- Every ban carries notes: the client's AS number and country, the limit it
|
||||
broke or the rule it matched, the request that caused it, and its earlier
|
||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||
|
||||
- `SWWAF_MODE=observe`: `servewaf` refuses nothing and makes no ban of its own.
|
||||
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`.
|
||||
### Attack detection
|
||||
|
||||
## 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
|
||||
`healthy` for each container of `smallwebwaf`:
|
||||
- for `rproxy`, once it answers and the app accepts connections, whether or
|
||||
not `servewaf` answers;
|
||||
- for `servewaf`, once it answers.
|
||||
- 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"]`. In `enforce` mode the same request bans your
|
||||
address for seven days, and your next request makes the ban permanent. Lift
|
||||
the ban by editing `bans.json` (see "Bans" in [`SPEC.md`](SPEC.md)).
|
||||
- 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 the example app on it, runs the app
|
||||
with `rproxy` in its container and `servewaf` in another, and checks them. It
|
||||
needs network access. It checks:
|
||||
- the health checks;
|
||||
- a request passed to the app;
|
||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||
- 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.
|
||||
### State files
|
||||
|
||||
`servewaf` keeps everything in memory, and the five JSON files in
|
||||
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||
renamed over the real one, so a crash leaves a complete file. `servewaf` notices
|
||||
a file edited on disk and takes the edit in; an edit that does not parse is set
|
||||
aside as `<name>.bad`, and never stops the running process (see
|
||||
[`docs/state-files.md`](docs/state-files.md)).
|
||||
|
||||
### The image and its processes
|
||||
|
||||
- The image is built on Ubuntu 26.04 LTS, pinned by digest. Its Ubuntu packages
|
||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
||||
and gid 65532) and starts it again when it exits. The `run` script of
|
||||
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
||||
so a volume needs no change of owner.
|
||||
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
||||
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
|
||||
at its own address on the private network; nothing routes the internet there.
|
||||
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
||||
in the app's container, the app must keep answering for five seconds after
|
||||
`SIGTERM` for that to help them.
|
||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
||||
state file that does not parse, a lookup database that cannot be read, or an
|
||||
address it cannot listen on. Once running, a failing lookup service,
|
||||
reputation source or alert destination never takes `servewaf` down.
|
||||
|
||||
### Packages
|
||||
|
||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
||||
health check.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||
- `internal/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
|
||||
|
||||
@@ -366,83 +757,32 @@ on the host.
|
||||
- `servewaf` listens on port 8090;
|
||||
- its state files go in `bin/state`;
|
||||
- its rule files are those of `share/rules.d`.
|
||||
- `script/example-app`: builds the image and the example app on it, runs them
|
||||
with a volume, and checks them as "Checking that it works" above describes.
|
||||
Then it removes the containers, the network, the volumes and the images. Needs
|
||||
network access. `make example-app`.
|
||||
- `script/example-app`: builds the image and the example app
|
||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||
`servewaf` in another, and checks them. Then it removes the containers, the
|
||||
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
|
||||
`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
|
||||
each request. It sends through them a sample of gitea's ordinary use and the
|
||||
requests the Core Rule Set is known to refuse, and prints what `servewaf` did
|
||||
with each. Needs network access, and a docker daemon that pushes to a registry
|
||||
on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) records the
|
||||
results. `make gitea-requests`.
|
||||
|
||||
## Rationale
|
||||
|
||||
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.
|
||||
its default, but for those that join the containers and
|
||||
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
|
||||
It sends through them a sample of gitea's ordinary use and the requests the
|
||||
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
|
||||
Needs network access, and a docker daemon that pushes to a registry on
|
||||
`localhost` over plain HTTP. `make gitea-requests`.
|
||||
- `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
|
||||
the containers, the network, the volumes and the image it built. Needs network
|
||||
access. `make gitea-setup`.
|
||||
|
||||
## TODO
|
||||
|
||||
@@ -455,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
|
||||
and misses, and why none was adopted.
|
||||
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
|
||||
- [`docs/settings.md`](docs/settings.md): every setting, its default and the
|
||||
values it takes.
|
||||
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
||||
- [`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
|
||||
`/_smallwebwaf/`, with examples.
|
||||
@@ -467,7 +806,8 @@ happens when a process stops, moving a port, and the forwarded headers.
|
||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||
holds.
|
||||
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea.
|
||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
|
||||
in `deploy/gitea`, and what the defaults do there.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -66,10 +66,10 @@ to them.
|
||||
one it does not know, the binary prints the commands and exits with
|
||||
status 2.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/proxy`: `servewaf`'s server. It runs the checks in `check`, and
|
||||
holds `servewaf`'s question, answer and report. It answers `servewaf`'s
|
||||
own endpoints in `answerOwnEndpoint`. It counts bytes, refusals and
|
||||
anomalies, and writes the log lines.
|
||||
- `internal/servewaf`: `servewaf`'s server. It runs the checks in `check`,
|
||||
and holds `servewaf`'s question, answer and report. It answers
|
||||
`servewaf`'s own endpoints in `answerOwnEndpoint`. It counts bytes,
|
||||
refusals and anomalies, and writes the log lines.
|
||||
- `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
|
||||
standard library's `httputil.ReverseProxy`, within the time and size
|
||||
@@ -123,7 +123,9 @@ runs steps 3 to 13, and does the counting of step 15. The first step that
|
||||
produces a final answer ends the checks. Step 15 runs for each request
|
||||
`servewaf` answers with `allow`, `deny` or `close`. `rproxy`'s size and time
|
||||
limits apply to the whole exchange. Each step names the section that holds its
|
||||
rules.
|
||||
rules. Each check is a heuristic. Its name, in parentheses below, is the value
|
||||
of the request log's `heuristic` field. `refusal_limit` and `byte_limits` give
|
||||
theirs in `after_answer_heuristic` instead.
|
||||
|
||||
1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any
|
||||
check, so a health checker never needs an exemption and is never refused.
|
||||
@@ -141,9 +143,9 @@ rules.
|
||||
around its address, a /64 by default, since one abuser usually controls a
|
||||
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
||||
3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12, the
|
||||
byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused; a
|
||||
client in both lists is let through.
|
||||
4. Bans. A client under a ban is refused (see "Bans").
|
||||
byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused
|
||||
(`deny_nets`); a client in both lists is let through.
|
||||
4. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||
5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
|
||||
and country"). The request goes on at once. It waits for the first GeoJS
|
||||
answer, up to `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it:
|
||||
@@ -153,22 +155,26 @@ rules.
|
||||
|
||||
A request that ends before the answer comes is logged without it.
|
||||
|
||||
6. The country lists (see "Country lists").
|
||||
7. The reputation sources (see "Reputation"). A client on a fetched blocklist
|
||||
gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
|
||||
banned. A client listed by a DNS blocklist, or scored at least
|
||||
`SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
|
||||
6. The country lists (`countries`; see "Country lists").
|
||||
7. The reputation sources (see "Reputation"), in this order:
|
||||
- A client on a fetched blocklist gets `SWWAF_BLOCKLIST_ACTION`
|
||||
(`blocklists`).
|
||||
- A client on the CrowdSec decision list is banned (`crowdsec`).
|
||||
- A client listed by a DNS blocklist gets `SWWAF_REPUTATION_ACTION`
|
||||
(`dnsbl`).
|
||||
- A client scored at least `SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB gets
|
||||
`SWWAF_REPUTATION_ACTION` (`abuseipdb`).
|
||||
8. The client's limit percentage (see "Biased thresholds").
|
||||
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
|
||||
times the percentage is refused and bans the client.
|
||||
10. The trap paths (see "Attack detection"). A request for one is refused and
|
||||
bans the client.
|
||||
11. The rule files (see "Rule files"). Matching stops at the first rule that
|
||||
refuses.
|
||||
12. The header limits (see "Answering in time"), and then the Core Rule Set (see
|
||||
"Attack detection"). A Core Rule Set match is refused with `403` while
|
||||
`SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is
|
||||
`detect`.
|
||||
9. Rate limiting (`rate_limits`; see "Request rate limits, per client"). A
|
||||
request over a limit times the percentage is refused and bans the client.
|
||||
10. The trap paths (`trap_paths`; see "Attack detection"). A request for one is
|
||||
refused and bans the client.
|
||||
11. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
||||
rule that refuses.
|
||||
12. The header limits (`header_limits`; see "Answering in time"), and then the
|
||||
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
|
||||
match is refused with `403` while `SWWAF_WAF_MODE` is `block`, and only
|
||||
logged and alerted while it is `detect`.
|
||||
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
||||
that prefix.
|
||||
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
|
||||
@@ -182,14 +188,14 @@ rules.
|
||||
are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a
|
||||
WebSocket, what it carried each way counts once it closes. A client whose
|
||||
total passes a byte limit times the percentage has broken that limit and
|
||||
is banned. The response in progress is not cut off.
|
||||
is banned (`byte_limits`). The response in progress is not cut off.
|
||||
- For a request it allowed, the client's history and the anomaly counters
|
||||
are updated, and the alert thresholds are evaluated.
|
||||
|
||||
For a request it refused, `servewaf` adds nothing to the byte limits. It
|
||||
updates the history and the anomaly counters at its decision. It counts the
|
||||
refusals the error burst counts at the decision too (see "Attack
|
||||
detection").
|
||||
refusals the error burst counts at the decision too (`refusal_limit`; see
|
||||
"Attack detection").
|
||||
|
||||
In `SWWAF_MODE=observe`, every refusal above is logged as what would have
|
||||
happened, nothing is refused, and `servewaf` makes no ban of its own. A path
|
||||
@@ -397,32 +403,31 @@ Conventions:
|
||||
|
||||
### Request rate limits, per client
|
||||
|
||||
Breaking one bans the client. The defaults suit programmatic use of an API, such
|
||||
as an automation fleet on another host, and still stop abusive bots. A fleet of
|
||||
20 agents, each making a request every 10 seconds at its busiest, makes 120
|
||||
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
|
||||
a day. It stays under all three limits.
|
||||
Breaking one bans the client. The defaults let people read a site and stop
|
||||
scrapers that send many requests slowly over hours or days. The hour limit is
|
||||
the main one.
|
||||
|
||||
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
|
||||
a minute and a few quick first visits with an empty cache. A gitea page opened
|
||||
with an empty cache loads about 40 files. A client firing requests as fast as
|
||||
it can passes the limit within seconds.
|
||||
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
|
||||
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
|
||||
under the minute limit. The fleet's 7,200 an hour is about a third of it.
|
||||
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that
|
||||
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.
|
||||
- Per minute: 120, 2 requests a second. It leaves room for a person opening two
|
||||
pages at once with an empty cache. A gitea page opened with an empty cache
|
||||
loads 20 to 60 files, and a handful once cached.
|
||||
- Per hour: 1,500. A person reading code in gitea for a whole hour, a page every
|
||||
20 seconds, makes about 900 requests. A scraper making one request every 2
|
||||
seconds makes 1,800 an hour, more than the limit.
|
||||
- Per day: 6,000, four hours at the hour limit. A scraper making one request
|
||||
every 10 seconds makes 8,640 a day, more than the limit.
|
||||
|
||||
Counting cannot catch a client that stays under all three limits, such as one
|
||||
that keeps up 3 requests a second. Only the other checks can catch an abuser
|
||||
Counting cannot catch a client that stays under all three limits, about 4
|
||||
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
|
||||
the blocklists. An abuser that trips none of them is not caught. A machine that
|
||||
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
|
||||
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
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
the blocklists. An abuser that trips none of them is not caught.
|
||||
|
||||
Some legitimate clients pass the limits:
|
||||
|
||||
- An Actions runner asks gitea for work all day and can pass the limits.
|
||||
- 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
|
||||
static assets or health checks; their bytes are not counted either. A prefix is
|
||||
@@ -435,12 +440,10 @@ matched the same way.
|
||||
|
||||
### Byte limits, per client
|
||||
|
||||
The hour and day limits sit about twice above what 40 people busy with heavy web
|
||||
apps move together on one address. Each moves about 256 MiB an hour, for ten
|
||||
hours a day. A response's bytes are counted when it ends (see step 15). So the
|
||||
minute limit holds three of the largest responses, with their requests, ending
|
||||
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.
|
||||
By default there is no minute limit, so a client can download in a burst at full
|
||||
speed. The hour limit defaults to 50 GiB and the day limit to 100 GiB. They
|
||||
allow several downloads of a few GiB each. A response's bytes are counted when
|
||||
it ends (see step 15).
|
||||
|
||||
### Lookup of AS number and country
|
||||
|
||||
@@ -1544,20 +1547,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
|
||||
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,
|
||||
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.
|
||||
archive, whose packages change with every update.
|
||||
- The Ubuntu image has no CA certificates. Without them Nix cannot download
|
||||
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
|
||||
the Go image that builds `smallwebwaf`, itself pinned by digest.
|
||||
by name. apt fetches the packages over plain HTTP and checks each one against
|
||||
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
|
||||
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
|
||||
@@ -1575,12 +1569,13 @@ It sets no `ENTRYPOINT` or `USER` of its own. The container must start
|
||||
writes. `runsvinit` starts runit's `runsvdir`, which runs the `run` script of
|
||||
each directory under `/etc/service`. Ubuntu's `sv` looks for services there
|
||||
too.
|
||||
- The image's `run` script, `/etc/service/smallwebwaf/run`, starts the command
|
||||
`SMALLWEBWAF_COMMAND` names, `rproxy` when it is unset or empty, as the
|
||||
`smallwebwaf` user (uid and gid 65532) with runit's `chpst`. For `servewaf`,
|
||||
it first makes `SWWAF_STATE_DIR` and every file in it belong to that user. For
|
||||
`rproxy` it changes no owner. With any other value, `smallwebwaf` prints its
|
||||
commands and exits, and the health check fails.
|
||||
- The image's `run` script is `/etc/service/smallwebwaf/run`. It starts
|
||||
`servewaf` when `SMALLWEBWAF_COMMAND` is `servewaf`, and `rproxy` when it is
|
||||
`rproxy`, unset or empty. It runs either as the `smallwebwaf` user (uid and
|
||||
gid 65532), with runit's `chpst`. For `servewaf`, it first makes
|
||||
`SWWAF_STATE_DIR` and every file in it belong to that user. For `rproxy` it
|
||||
changes no owner. With any other value, it starts nothing, and stops with a
|
||||
message naming `SMALLWEBWAF_COMMAND` and its value.
|
||||
|
||||
Every `run` script, the app's included, is a bash script. It starts with
|
||||
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
|
||||
@@ -1629,10 +1624,13 @@ says what it checks:
|
||||
- With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers
|
||||
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in
|
||||
`SWWAF_LISTEN_ADDR`.
|
||||
- Otherwise it passes while `rproxy` answers that request on `127.0.0.1`, at the
|
||||
port in `SWRP_LISTEN_ADDR`, and the app accepts connections at
|
||||
`SWRP_UPSTREAM_URL`. It does not ask `servewaf`, so that the site stays in
|
||||
traefik's routes while `servewaf` is down.
|
||||
- With `SMALLWEBWAF_COMMAND` unset, empty or `rproxy`, it passes while `rproxy`
|
||||
answers that request on `127.0.0.1`, at the port in `SWRP_LISTEN_ADDR`, and
|
||||
the app accepts connections at `SWRP_UPSTREAM_URL`. It does not ask
|
||||
`servewaf`, so that the site stays in traefik's routes while `servewaf` is
|
||||
down.
|
||||
- With any other value, it fails with a message naming `SMALLWEBWAF_COMMAND` and
|
||||
its value.
|
||||
|
||||
traefik sends a container no requests until it shows as healthy. So the check
|
||||
runs every second from the container's start until it first passes, for up to a
|
||||
@@ -1697,15 +1695,14 @@ ranges traefik is on.
|
||||
|
||||
### upaas
|
||||
|
||||
An app deployed by upaas, which rebuilds and restarts an app's Docker container
|
||||
when its repository changes, takes the shape of "`rproxy` in the app's
|
||||
container" with no change to upaas. upaas builds the app's Dockerfile and runs
|
||||
the one container as it runs any app. The traefik labels route to port 8080, and
|
||||
the `SWRP_` settings go with the app's environment variables. `servewaf` runs in
|
||||
a container of its own, on a network the app's container can reach, with its
|
||||
state volume and the directory of any token file. `docs/gitea.md` records what
|
||||
the defaults do in front of gitea, and `script/gitea-requests` checks those
|
||||
notes.
|
||||
upaas rebuilds and restarts an app's Docker container when its repository
|
||||
changes. An app it deploys takes the shape of "`rproxy` in the app's container"
|
||||
with no change to upaas. upaas builds the app's Dockerfile and runs the one
|
||||
container as it runs any app. The traefik labels route to port 8080, and the
|
||||
`SWRP_` settings go with the app's environment variables. `servewaf` runs in a
|
||||
container of its own, on a network the app's container can reach, with its state
|
||||
volume and the directory of any token file. `docs/gitea.md` records what the
|
||||
defaults do in front of gitea, and `script/gitea-requests` checks those notes.
|
||||
|
||||
## Planned, not built
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Gitea behind traefik, with rproxy and servewaf in front of it.
|
||||
# docs/gitea.md says what each setting here does and why. Before starting it:
|
||||
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
|
||||
# - 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
|
||||
# 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.
|
||||
# script/gitea-setup checks this setup.
|
||||
|
||||
name: gitea
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
# traefik v3.7.17, 2026-11-03
|
||||
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
|
||||
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
|
||||
# so: otherwise a visitor could name an address on the shared network,
|
||||
# and skip servewaf's checks.
|
||||
command:
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.websecure.address=:443
|
||||
# Until a certificate resolver is added here, as for your other
|
||||
# sites, traefik answers with a certificate it made itself.
|
||||
- --entrypoints.websecure.http.tls=true
|
||||
# traefik's own limit of 60 seconds for a whole request would cut
|
||||
# off an upload long before rproxy's limit of 30 minutes.
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
shared:
|
||||
# Containers on the shared network find traefik under the
|
||||
# site's name, and so reach it from their own address.
|
||||
aliases:
|
||||
- git.example.org
|
||||
restart: unless-stopped
|
||||
|
||||
gitea:
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
||||
environment:
|
||||
GITEA__server__ROOT_URL: https://git.example.org/
|
||||
# Gitea starts at its defaults, with SQLite, instead of showing its
|
||||
# install page to whoever opens it first.
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
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.
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
rproxy:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
||||
# Pushes, LFS objects, packages and release attachments of up to
|
||||
# 2 GiB, sent in up to 30 minutes.
|
||||
SWRP_MAX_REQUEST_SIZE: 2G
|
||||
SWRP_MAX_REQUEST_TIME_MS: "1800000"
|
||||
# 15 minutes for a clone or a download, half the default.
|
||||
SWRP_MAX_RESPONSE_TIME_MS: "900000"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.gitea.rule: Host(`git.example.org`)
|
||||
traefik.http.routers.gitea.entrypoints: websecure
|
||||
traefik.http.services.gitea.loadbalancer.server.port: "8080"
|
||||
networks:
|
||||
shared:
|
||||
ipv4_address: 10.99.0.2
|
||||
ipv6_address: fd10:99::2
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
servewaf:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
# The containers on the shared network skip the checks. Add the
|
||||
# address of an automation fleet on another host here too; without
|
||||
# that, it is counted like any client.
|
||||
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
|
||||
# and file names, and 31 refusals in a minute ban the address.
|
||||
SWWAF_WAF_MODE: detect
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
networks:
|
||||
- shared
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
# The network traefik and Gitea share. Its containers get addresses in the
|
||||
# upper half of each subnet, which servewaf lets skip the checks. Each
|
||||
# subnet's gateway, the host's own address on it, stays out of that half:
|
||||
# requests docker passes on from a published port can come from it.
|
||||
shared:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.99.0.0/24
|
||||
ip_range: 10.99.0.128/25
|
||||
gateway: 10.99.0.1
|
||||
- subnet: fd10:99::/64
|
||||
ip_range: fd10:99:0:0:8000::/65
|
||||
gateway: fd10:99::1
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
servewaf-state:
|
||||
+169
-49
@@ -1,51 +1,177 @@
|
||||
# smallwebwaf in front of gitea
|
||||
|
||||
These notes record what `smallwebwaf` does in front of gitea at the default
|
||||
settings. They are observations, not requirements; the requirements are in
|
||||
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind `rproxy` and
|
||||
`servewaf`, and sends through them a sample of gitea's ordinary use and the
|
||||
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
|
||||
`servewaf` in front of it. This document says what that setup sets and why, and
|
||||
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
|
||||
`smallwebwaf` behaves.
|
||||
|
||||
## The setup
|
||||
|
||||
Four containers share one Docker network, the shared network:
|
||||
|
||||
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
|
||||
plain HTTP to HTTPS;
|
||||
- `rproxy`, in front of gitea;
|
||||
- gitea 1.27.3, as its image comes;
|
||||
- `servewaf`, with a volume for its state files.
|
||||
|
||||
To start it:
|
||||
|
||||
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
|
||||
host name.
|
||||
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that 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`.
|
||||
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
|
||||
4. Set `GITEA_SECRET_KEY` to the output of
|
||||
`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`.
|
||||
|
||||
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
|
||||
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
|
||||
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
|
||||
|
||||
`make gitea-setup` checks the setup. It builds the image, brings the setup up
|
||||
under a name of its own, and checks:
|
||||
|
||||
- that a page load, a push and a clone sent through traefik pass through
|
||||
`rproxy`, and that `servewaf` checks them;
|
||||
- that gitea logs the visitor's address, not `rproxy`'s;
|
||||
- that a request from a container on the shared network skips the checks;
|
||||
- that with `servewaf` stopped, gitea still answers through `rproxy`.
|
||||
|
||||
## Uploads and downloads
|
||||
|
||||
An upload is a request:
|
||||
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
- a container image layer;
|
||||
- a package file;
|
||||
- a release attachment.
|
||||
|
||||
The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
|
||||
|
||||
- `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
|
||||
off.
|
||||
- `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
|
||||
that takes over 60 seconds is cut off.
|
||||
- traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
|
||||
a whole request would cut off an upload first.
|
||||
|
||||
A clone or a download is a response:
|
||||
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
|
||||
or download that takes longer is cut off.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
|
||||
is larger needs it raised.
|
||||
|
||||
The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
|
||||
GiB a day.
|
||||
|
||||
## The checks
|
||||
|
||||
`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
|
||||
gitea searches and file names. The section "What the Core Rule Set refuses in
|
||||
`block` mode" below lists them. Each such refusal counts toward the error burst,
|
||||
so 31 in one minute ban the address.
|
||||
|
||||
Every other check stays at its default. A client may make 120 requests a minute,
|
||||
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
|
||||
what scrapers hammer, and these limits do most of the work there.
|
||||
|
||||
## The shared network
|
||||
|
||||
Containers on the shared network skip `servewaf`'s checks:
|
||||
|
||||
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
|
||||
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
|
||||
there that asks for the site reaches traefik from its own address. An
|
||||
automation fleet in such a container skips the checks.
|
||||
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
|
||||
on it. Requests docker passes on from a published port can come from the
|
||||
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
|
||||
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`.
|
||||
Without that, it is counted like any client.
|
||||
- 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
|
||||
the checks.
|
||||
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
|
||||
Put only containers you trust on it.
|
||||
|
||||
## gitea's settings
|
||||
|
||||
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
|
||||
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
|
||||
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
|
||||
every visitor as `rproxy`.
|
||||
- `INSTALL_LOCK` is `true`, as "The setup" above says.
|
||||
|
||||
## Methods
|
||||
|
||||
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
|
||||
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
|
||||
|
||||
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
|
||||
- LFS: `GET`, `POST` and `PUT`;
|
||||
- the container registry and the other package registries: `GET`, `HEAD`,
|
||||
`POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- Actions runners: `POST`.
|
||||
|
||||
## The log
|
||||
|
||||
`rproxy` and `servewaf` write each query string to the log whole. A token sent
|
||||
in the query string, as `?token=`, is then in the log. Send it in the
|
||||
`Authorization` header instead.
|
||||
|
||||
## Actions runners
|
||||
|
||||
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
|
||||
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 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
|
||||
within two minutes. Its address is then banned, its 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
|
||||
|
||||
These notes record what the Core Rule Set does in front of gitea in `block`
|
||||
mode, at the other defaults. They are observations, not requirements.
|
||||
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
|
||||
defaults. It sends through them a sample of gitea's ordinary use and the
|
||||
requests named below. It reports each request the Core Rule Set refuses, so that
|
||||
these notes can be checked again when gitea or the Core Rule Set changes
|
||||
version.
|
||||
version. In `detect` mode, as the setup runs it, the same requests are logged
|
||||
with their rule ids, and pass.
|
||||
|
||||
## What the defaults cover
|
||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
||||
toward). The request log names the rule in `waf_rule_ids`, which
|
||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
||||
|
||||
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
|
||||
by it.
|
||||
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
|
||||
repository is large or the link slow. A repository whose clone is larger than
|
||||
1 GiB needs `SWRP_MAX_RESPONSE_SIZE` raised, and the byte limits with it
|
||||
("Byte limits, per client" in `SPEC.md`).
|
||||
- A push is a request, and so is every other upload:
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
- a container image layer;
|
||||
- a package file;
|
||||
- a release attachment.
|
||||
### What it lets through
|
||||
|
||||
At the defaults, an upload larger than 100 MiB or taking more than 60
|
||||
seconds is cut off ("`rproxy`'s limits" in `SPEC.md`). A gitea that takes
|
||||
large uploads needs `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`
|
||||
raised to fit.
|
||||
|
||||
- Archive download and blame or history pages are what scrapers hammer. The
|
||||
request limits do most of the work there.
|
||||
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all
|
||||
day. One 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 job's log and state
|
||||
every second instead, two requests a second. So one runner busy all day sends
|
||||
120 requests a minute and 172,800 a day, under the request limits. Two such
|
||||
runners on one address, each busy all day, make 345,600 and pass the day limit
|
||||
of 300,000. Three busy at once make 21,600 an hour and pass the hour limit of
|
||||
20,000. Five busy at once make 600 a minute and pass the minute limit of 500.
|
||||
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 lets through
|
||||
|
||||
At the defaults, the Core Rule Set lets gitea's ordinary use through, apart from
|
||||
the refusals in the next section:
|
||||
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
|
||||
the sections after this one:
|
||||
|
||||
- browsing and views of files in a repository, with their history, blame and the
|
||||
file tree;
|
||||
@@ -60,12 +186,6 @@ the refusals in the next section:
|
||||
editor, code included, since no body is read;
|
||||
- the page gitea shows after a change, with its message naming what was done.
|
||||
|
||||
## What the Core Rule Set still refuses
|
||||
|
||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
||||
toward). The request log names the rule in `waf_rule_ids`, which
|
||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
||||
|
||||
### A query string that reads as an attack
|
||||
|
||||
Most often this is a search. A search term is a query parameter, which the Core
|
||||
@@ -183,7 +303,7 @@ the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
||||
once. `actions/upload-artifact@v4` does not send the header.
|
||||
|
||||
## What body inspection refuses
|
||||
### What body inspection refuses
|
||||
|
||||
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
||||
front of gitea it then refuses these, until their rule ids, named in the request
|
||||
@@ -195,7 +315,7 @@ log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
||||
- PyPI uploads (922130);
|
||||
- attachments named like `debug.log` or `config.yml` (932180).
|
||||
|
||||
## What `script/gitea-requests` sends
|
||||
### What `script/gitea-requests` sends
|
||||
|
||||
Of the requests above, the script sends:
|
||||
|
||||
|
||||
+3
-6
@@ -8,8 +8,7 @@ is pinned that way.
|
||||
## Contents
|
||||
|
||||
- Ubuntu 26.04 LTS, pinned by digest.
|
||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, from the snapshot of its
|
||||
archive at `20261001T000000Z`.
|
||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu's archive.
|
||||
- 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
|
||||
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
|
||||
@@ -26,10 +25,8 @@ is pinned that way.
|
||||
|
||||
## Pins
|
||||
|
||||
- Ubuntu's packages: the snapshot service, `--snapshot 20261001T000000Z` on both
|
||||
`apt-get update` and `apt-get install`. The SHA-256 hash of each of the
|
||||
snapshot's `InRelease` files is named in the `Dockerfile` and checked after
|
||||
`apt-get update`. The hashes are the amd64 archive's.
|
||||
- Ubuntu's packages are not pinned: they come from the live archive, whose
|
||||
packages change with every update.
|
||||
- nixpkgs: the release file `nixexprs.tar.xz` of
|
||||
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
||||
SHA-256 hash.
|
||||
|
||||
@@ -107,6 +107,35 @@ Every one of these lines has:
|
||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||
request it would have refused. `action` then names what was done, normally
|
||||
`forward`.
|
||||
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
|
||||
is left out when none did.
|
||||
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
|
||||
heuristic and its figures:
|
||||
- `deny_nets`: `netblock`, the entry of `SWWAF_DENY_NETS` the client is in;
|
||||
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
||||
- `countries`: `country`, and `setting`, the country list that refused it;
|
||||
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
||||
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
||||
decision ends;
|
||||
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
|
||||
masked;
|
||||
- `abuseipdb`: `score`, and `min_score`, `SWWAF_ABUSEIPDB_MIN_SCORE`;
|
||||
- `rate_limits`: `window`, `count` and `limit`;
|
||||
- `trap_paths`: `path`, the trap path asked for;
|
||||
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
||||
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
||||
- `core_rule_set`: `score`, and `threshold`, `SWWAF_WAF_ANOMALY_THRESHOLD`;
|
||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||
- `after_answer_heuristic`: the heuristic that banned the client after
|
||||
`servewaf` answered the request, in `observe` mode too: `refusal_limit` or
|
||||
`byte_limits`.
|
||||
- `after_answer_heuristic_figures`: an object of the figures that heuristic
|
||||
acted on, as listed above.
|
||||
|
||||
`byte_limits` bans when the report comes, so only the `report` line names
|
||||
it.
|
||||
|
||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||
`bytes_percent_setting` do the same for the byte limits.
|
||||
|
||||
+18
-15
@@ -99,19 +99,22 @@ chooses between the two (see "The image's command" below).
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
|
||||
requests a client may make in a minute. A count or `off`. The default catches,
|
||||
within seconds, a client firing requests as fast as it can. It leaves room for
|
||||
a fleet of about 20 agents at its busiest, about 120 requests a minute. It
|
||||
also leaves room for a few quick first visits to gitea with an empty cache,
|
||||
about 40 files each.
|
||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `20000`, about 5.5 a second): the most
|
||||
requests a client may make in an hour. A count or `off`. The default leaves
|
||||
room for an automation fleet at work. It catches a client that keeps up more
|
||||
than about 5.5 a second for an hour.
|
||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most
|
||||
requests a client may make in a day. A count or `off`. The default catches a
|
||||
scraper that keeps up 4 a second around the clock.
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||
client may make in a minute. A count or `off`. The default leaves room for a
|
||||
person opening two gitea pages at once with an empty cache, up to 60 files
|
||||
each.
|
||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||
person reading code in gitea for an hour, a page every 20 seconds, makes
|
||||
about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
|
||||
more than the limit.
|
||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||
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
|
||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||
- `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
|
||||
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
||||
bytes counted for a client in each window. A size or `off`.
|
||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||
`response`, `request` or `both`.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// servewaf` start with SWWAF_, every one of them has a default, and most can be
|
||||
// given as a file instead. Those of `smallwebwaf rproxy` start with SWRP_. Each
|
||||
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
|
||||
// since rproxy never touches disk.
|
||||
// since rproxy never touches disk. SMALLWEBWAF_COMMAND chooses which of the two
|
||||
// the image runs.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -370,6 +371,7 @@ var (
|
||||
errDBPathUnused = errors.New("only file reads it")
|
||||
errOver64K = errors.New(
|
||||
"is more than 64K, the most servewaf gets of a body")
|
||||
errNotCommand = errors.New("is not rproxy or servewaf")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d")
|
||||
errNotNumberAboveZero = errors.New(
|
||||
@@ -466,13 +468,13 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"),
|
||||
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
|
||||
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
||||
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
||||
@@ -570,6 +572,22 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its
|
||||
// health check checks: rproxy when it is unset or empty, or servewaf. It cannot
|
||||
// be given as a file. Any other value is an error that names the setting.
|
||||
func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
command, _ := lookupEnv("SMALLWEBWAF_COMMAND")
|
||||
|
||||
switch command {
|
||||
case "", "rproxy":
|
||||
return "rproxy", nil
|
||||
case "servewaf":
|
||||
return command, nil
|
||||
default:
|
||||
return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
|
||||
// file, as FromEnvironment does. The health check in servewaf's container needs
|
||||
// no other setting, so it reads no other, nor a file that another names.
|
||||
|
||||
@@ -244,9 +244,9 @@ func TestDefaults(t *testing.T) {
|
||||
wantSettings(t, cfg, config.Config{
|
||||
ListenAddr: ":8080",
|
||||
Observe: false,
|
||||
RateLimitPerMinute: 500,
|
||||
RateLimitPerHour: 20000,
|
||||
RateLimitPerDay: 300000,
|
||||
RateLimitPerMinute: 120,
|
||||
RateLimitPerHour: 1500,
|
||||
RateLimitPerDay: 6000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
@@ -266,8 +266,8 @@ func TestDefaults(t *testing.T) {
|
||||
LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
|
||||
})
|
||||
wantByteLimitSettings(t, cfg, config.Config{
|
||||
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
|
||||
BytesLimitPerDay: 200 << 30, BytesCount: "both",
|
||||
BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
|
||||
BytesLimitPerDay: 100 << 30, BytesCount: "both",
|
||||
})
|
||||
|
||||
wantNetblocks(t, cfg.AllowNets)
|
||||
@@ -308,7 +308,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||
for _, limit := range []int64{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2271,12 +2271,12 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "45",
|
||||
rateLimitPerHour: "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
rateLimitPerHour: "1500",
|
||||
rateLimitPerDay: "6000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerHour: "20G",
|
||||
bytesLimitPerDay: "200G",
|
||||
bytesLimitPerMinute: "off",
|
||||
bytesLimitPerHour: "50G",
|
||||
bytesLimitPerDay: "100G",
|
||||
bytesCount: "both",
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// countryDenied reports whether the country lists refuse the request, by
|
||||
// the client's country as it was looked up. A client without a country,
|
||||
// or whose country cannot be found, is refused only by
|
||||
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
func (rq *request) countryDenied() bool {
|
||||
denied := rq.h.config.DeniedCountries
|
||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||
country := rq.line.Country
|
||||
|
||||
if slices.Contains(denied, country) {
|
||||
return true
|
||||
}
|
||||
|
||||
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
)
|
||||
|
||||
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
||||
// refuses the requests of a client a source lists.
|
||||
const deny = "deny"
|
||||
|
||||
// blocklistDenied notes the blocklists that list the client, as
|
||||
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
||||
// refuses the request. Being limit, it lowers the client's limits instead
|
||||
// (see limitPercentages), and being log, it does nothing more.
|
||||
func (rq *request) blocklistDenied() bool {
|
||||
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||
rq.blocklisted = len(listedBy) > 0
|
||||
rq.noteListed(listedBy, "listed by a blocklist")
|
||||
|
||||
return rq.blocklisted && rq.h.config.BlocklistAction == deny
|
||||
}
|
||||
|
||||
// crowdSecBanned reports whether a decision of the CrowdSec decision list
|
||||
// on the client is in force at now. If one is, it notes the list, as
|
||||
// noteHit does, and bans the client until that decision ends.
|
||||
func (rq *request) crowdSecBanned(now time.Time) bool {
|
||||
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
|
||||
if !listed {
|
||||
return false
|
||||
}
|
||||
|
||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||
"listed by the CrowdSec decision list")
|
||||
rq.banForCrowdSec(now, decision)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
||||
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
|
||||
// deny, refuses the request. Being limit, it lowers the client's limits
|
||||
// instead (see limitPercentages), and being log, it does nothing more. A
|
||||
// zone without a verdict on the client is asked about it in the
|
||||
// background, and the request does not wait for the answer. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) dnsblDenied(ctx context.Context) bool {
|
||||
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
||||
rq.dnsblListed = len(listedBy) > 0
|
||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||
|
||||
return rq.dnsblListed && rq.h.config.ReputationAction == deny
|
||||
}
|
||||
|
||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
||||
// its score of the client is a hit, and reports whether
|
||||
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
||||
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
||||
// client without a score is checked in the background, by the request's
|
||||
// address, if its history counts an offence, and the request does not
|
||||
// wait for the answer. The score is then used for each address of the
|
||||
// client. ctx is the request's own context.
|
||||
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
||||
if rq.h.config.AbuseIPDBKey == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
client := rq.h.clientGroup(rq.client)
|
||||
held, _ := rq.h.limiter.Client(client)
|
||||
offender := held.History.Offences != ratelimit.Offences{}
|
||||
|
||||
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
|
||||
rq.abuseIPDBHit = true
|
||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
||||
|
||||
return rq.h.config.ReputationAction == deny
|
||||
}
|
||||
|
||||
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||
// with reason.
|
||||
func (rq *request) noteListed(sources []string, reason string) {
|
||||
for _, source := range sources {
|
||||
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// noteHit adds hit's source, which lists the client, to the log line's
|
||||
// reputation, and hit to the notes of a ban the request makes, counts the
|
||||
// source in the metrics, and raises a reputation_hit alert with reason,
|
||||
// whose detail gives hit's source and score.
|
||||
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||
detail := map[string]any{"source": hit.Source}
|
||||
if hit.Score != nil {
|
||||
detail["score"] = *hit.Score
|
||||
}
|
||||
|
||||
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
||||
rq.reputation = append(rq.reputation, hit)
|
||||
rq.h.metrics.ReputationHit(hit.Source)
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventReputationHit,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: rq.h.clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reason: reason,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// trapPath reports whether the request asks for a path in
|
||||
// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query,
|
||||
// as sent or once percent-decoded, is one of them. Such a request is a
|
||||
// clear sign of attack, as a ban rule's match is: it bans the client's
|
||||
// netblock, or in observe mode raises the alert for the ban it would have
|
||||
// made. The ban's notes give the path as sent.
|
||||
func (rq *request) trapPath(now time.Time) bool {
|
||||
path := rules.Path(rq.in)
|
||||
if !slices.Contains(rq.h.config.TrapPaths, path) &&
|
||||
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// checkRules checks the request against the rules of the rule files at
|
||||
// now, notes the ids of those it matches in the log line, and returns the
|
||||
// action of the rule that refuses it, ActionRuleBlocked for a block rule
|
||||
// and ActionBanned for a ban rule, or "" when none does. A ban rule bans
|
||||
// the client's netblock for a clear sign of attack, or in observe mode
|
||||
// raises the alert for the ban it would have made. Either rule's match
|
||||
// is noted as an offence, for the client's history.
|
||||
func (rq *request) checkRules(now time.Time) string {
|
||||
matched := rq.h.rules.Match(rq.in)
|
||||
|
||||
for _, rule := range matched {
|
||||
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
|
||||
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
|
||||
}
|
||||
|
||||
if len(matched) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Only the last rule matched can refuse the request.
|
||||
switch last := matched[len(matched)-1]; last.Action {
|
||||
case rules.ActionBlock:
|
||||
rq.ruleBlocked = true
|
||||
|
||||
return requestlog.ActionRuleBlocked
|
||||
case rules.ActionBan:
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
||||
|
||||
return requestlog.ActionBanned
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,16 @@ type Line struct {
|
||||
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
|
||||
// for the header limits.
|
||||
WouldAction string `json:"would_action,omitempty"`
|
||||
// Heuristic is the name of the heuristic that refused the request, in
|
||||
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
||||
// name.
|
||||
Heuristic string `json:"heuristic,omitempty"`
|
||||
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
|
||||
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
|
||||
// the heuristic that banned the client after servewaf answered the
|
||||
// request.
|
||||
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
|
||||
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
|
||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||
// limits counted whose client a biased threshold gives a percentage of
|
||||
// the rate limits below 100, that percentage and the setting that gave
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package rproxy_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -14,9 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"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.
|
||||
@@ -69,11 +71,11 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -83,11 +85,11 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
report := servewaf.report(t)
|
||||
report := standIn.report(t)
|
||||
if report.Status != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("report %+v, want status 413", report)
|
||||
}
|
||||
@@ -98,9 +100,9 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -130,12 +132,12 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
|
||||
var asked atomic.Bool
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked.Store(true)
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -163,12 +165,12 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("announced, is refused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(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",
|
||||
})
|
||||
|
||||
@@ -177,7 +179,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -185,13 +187,13 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, large[:1])
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
_, _ = 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",
|
||||
})
|
||||
|
||||
@@ -203,7 +205,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
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 {
|
||||
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"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -238,9 +240,9 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -249,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -268,9 +270,9 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -286,11 +288,11 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -298,9 +300,9 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
if len(servewaf.questions) != 0 {
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
}
|
||||
@@ -332,9 +334,9 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
if len(servewaf.questions) != 0 {
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
})
|
||||
@@ -402,11 +404,11 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
announced = 100 * questionBodyMaxBytes
|
||||
)
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -452,9 +454,9 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -482,9 +484,9 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -496,11 +498,11 @@ func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -551,8 +553,8 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, servewaf.URL, startAppThatTakesNothing(t),
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||
map[string]string{maxRequestTime: "3000"})
|
||||
|
||||
conn := dial(t, addr)
|
||||
@@ -578,14 +580,14 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
<-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",
|
||||
})
|
||||
|
||||
@@ -594,7 +596,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -602,10 +604,12 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const start = "the start"
|
||||
|
||||
release := make(chan struct{})
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "the start")
|
||||
_, _ = io.WriteString(w, start)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
|
||||
<-release
|
||||
@@ -613,20 +617,32 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||
maxResponseTime: "300",
|
||||
// The time limit is long enough for a loaded host to pass on the start of the
|
||||
// answer before it runs out.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: "3000",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
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.
|
||||
_, err := io.ReadAll(conn)
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), 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)
|
||||
}
|
||||
}
|
||||
@@ -640,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
part := make([]byte, writeSize)
|
||||
|
||||
@@ -651,8 +667,10 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||
maxResponseTime: "500",
|
||||
// The time limit is long enough for a loaded host to start passing on the
|
||||
// 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.
|
||||
@@ -666,7 +684,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
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 {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusBadGateway {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -691,11 +709,11 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = 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",
|
||||
})
|
||||
|
||||
@@ -714,12 +732,12 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
working := make(chan struct{})
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(working)
|
||||
<-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)
|
||||
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"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -764,7 +782,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
|
||||
_, _ = 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",
|
||||
})
|
||||
|
||||
@@ -784,17 +802,19 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
||||
{"a deny", post, http.StatusForbidden},
|
||||
{
|
||||
"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,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "500",
|
||||
// The time limit is long enough for a loaded host to read the first part of
|
||||
// the body before it runs out.
|
||||
addr, _ := startRProxy(t, 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,
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// 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.
|
||||
const chunked = "chunked"
|
||||
@@ -29,9 +29,9 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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"
|
||||
|
||||
@@ -42,16 +42,16 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||
req.Header.Set(proxy.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(proxy.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(proxy.ContentLengthHeader, "12345")
|
||||
req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "12345")
|
||||
// Go's client then sends no User-Agent, so that one in the question would be
|
||||
// Go's own.
|
||||
req.Header["User-Agent"] = nil
|
||||
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
if asked.method != http.MethodDelete || asked.target != target ||
|
||||
asked.host != site {
|
||||
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(forwardedProtoHeader, "http")
|
||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||
want.Set(proxy.ClientHeader, localhost)
|
||||
want.Set(proxy.PeerHeader, localhost)
|
||||
want.Set(servewaf.ClientHeader, localhost)
|
||||
want.Set(servewaf.PeerHeader, localhost)
|
||||
|
||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||
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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
forwarded := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded <- r.Header.Clone()
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -109,9 +109,9 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
req.Header.Set(forwardedProtoHeader, "https")
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := servewaf.question(t)
|
||||
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
||||
asked.header.Get(proxy.PeerHeader) != localhost ||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != localhost ||
|
||||
asked.header.Get(forwardedForHeader) != visitor ||
|
||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||
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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
req.Header[forwardedForHeader] = tc.header
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -197,7 +197,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := randomBytes(size)
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
received := make(chan []byte, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
whole, _ := io.ReadAll(r.Body)
|
||||
@@ -205,7 +205,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
|
||||
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))
|
||||
if !announced {
|
||||
@@ -215,7 +215,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
want := body[:min(size, questionBodyMaxBytes)]
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -243,7 +243,7 @@ func wantQuestionBody(
|
||||
len(want))
|
||||
}
|
||||
|
||||
values := asked.header.Values(proxy.ContentLengthHeader)
|
||||
values := asked.header.Values(servewaf.ContentLengthHeader)
|
||||
|
||||
if announced {
|
||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||
@@ -264,9 +264,9 @@ func wantQuestionBody(
|
||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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
|
||||
// the answer comes at once.
|
||||
@@ -278,7 +278,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -306,8 +306,8 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||
standIn := startServeWAF(t, deny)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
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)
|
||||
}
|
||||
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
if string(asked.body) != tc.body ||
|
||||
!slices.Equal(asked.transferEncoding, tc.encoding) {
|
||||
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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
|
||||
if len(servewaf.questions) != 0 {
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -372,18 +372,18 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reached := make(chan string, 1)
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
if got.status != http.StatusOK {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -416,9 +416,9 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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)
|
||||
send(t, conn, request)
|
||||
@@ -433,11 +433,11 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -447,9 +447,9 @@ func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||
|
||||
const host = "a%b"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -468,15 +468,15 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||
|
||||
const want = "http"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
if got := asked.header.Get(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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -509,9 +509,9 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
asked := servewaf.question(t)
|
||||
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
||||
asked.header.Get(proxy.PeerHeader) != tc.peer {
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"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
|
||||
@@ -129,7 +129,7 @@ func (rq *request) readFirstPartOfBody() bool {
|
||||
}
|
||||
|
||||
_ = 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
|
||||
// the answer can name the side rproxy was waiting on.
|
||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||
@@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
report := &proxy.Report{
|
||||
report := &servewaf.Report{
|
||||
RequestID: rq.id,
|
||||
Status: min(rq.out.status, reportStatusMax),
|
||||
RequestBytes: requestBytes,
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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
|
||||
@@ -85,7 +85,7 @@ func New(params Params) *Server {
|
||||
transport: newTransport(),
|
||||
serveWAFTransport: newServeWAFTransport(),
|
||||
reportURL: (&url.URL{
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath,
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
|
||||
}).String(),
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
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()
|
||||
|
||||
return
|
||||
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq.noteAnswer(answer)
|
||||
|
||||
switch rq.disposition {
|
||||
case proxy.DispositionAllow:
|
||||
case servewaf.DispositionAllow:
|
||||
rq.passToApp(r.Context())
|
||||
case proxy.DispositionClose:
|
||||
case servewaf.DispositionClose:
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +54,7 @@ const (
|
||||
|
||||
// The headers the tests set and look at.
|
||||
const (
|
||||
dispositionHeader = proxy.DispositionHeader
|
||||
dispositionHeader = servewaf.DispositionHeader
|
||||
forwardedForHeader = "X-Forwarded-For"
|
||||
forwardedProtoHeader = "X-Forwarded-Proto"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
@@ -70,9 +70,9 @@ const (
|
||||
|
||||
// What the stand-in for servewaf answers.
|
||||
const (
|
||||
dispositionAllow = proxy.DispositionAllow
|
||||
dispositionDeny = proxy.DispositionDeny
|
||||
dispositionClose = proxy.DispositionClose
|
||||
dispositionAllow = servewaf.DispositionAllow
|
||||
dispositionDeny = servewaf.DispositionDeny
|
||||
dispositionClose = servewaf.DispositionClose
|
||||
// deniedBody is the body of a deny.
|
||||
deniedBody = "refused by servewaf"
|
||||
)
|
||||
@@ -173,7 +173,7 @@ type serveWAF struct {
|
||||
*httptest.Server
|
||||
|
||||
questions chan question
|
||||
reports chan proxy.Report
|
||||
reports chan servewaf.Report
|
||||
}
|
||||
|
||||
// startServeWAF starts a stand-in for servewaf.
|
||||
@@ -182,11 +182,11 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
||||
|
||||
s := &serveWAF{
|
||||
questions: make(chan question, 10),
|
||||
reports: make(chan proxy.Report, 10),
|
||||
reports: make(chan servewaf.Report, 10),
|
||||
}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(proxy.ClientHeader) == "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) == "" {
|
||||
s.takeReport(t, w, r)
|
||||
|
||||
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) {
|
||||
t.Helper()
|
||||
|
||||
var report proxy.Report
|
||||
var report servewaf.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)
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ func (s *serveWAF) wantNoReport(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
select {
|
||||
@@ -246,7 +246,7 @@ func (s *serveWAF) report(t *testing.T) proxy.Report {
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("no report")
|
||||
|
||||
return proxy.Report{}
|
||||
return servewaf.Report{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func startServeWAFAnsweringReports(
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
answer(w, r)
|
||||
|
||||
return
|
||||
@@ -498,7 +498,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
|
||||
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||
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(countryHeader, "DE")
|
||||
allow(w, r)
|
||||
@@ -512,7 +512,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
_, _ = 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"
|
||||
|
||||
@@ -530,7 +530,7 @@ func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||
}
|
||||
|
||||
received := <-seen
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
|
||||
if received.RequestURI != target || received.Host != site {
|
||||
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",
|
||||
}
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -602,14 +602,14 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
||||
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -641,12 +641,12 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header()["Content-Type"] = nil
|
||||
_, _ = 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)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -704,11 +704,11 @@ func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
||||
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
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, "/")
|
||||
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
||||
@@ -720,7 +720,7 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||
t.Errorf("the visitor got %s", dispositionHeader)
|
||||
}
|
||||
|
||||
report := servewaf.report(t)
|
||||
report := standIn.report(t)
|
||||
if report.Status != http.StatusForbidden ||
|
||||
report.ResponseBytes != int64(len(deniedBody)) ||
|
||||
report.DurationUpstreamTotal != 0 {
|
||||
@@ -736,9 +736,9 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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))
|
||||
if got.status != http.StatusForbidden ||
|
||||
@@ -758,15 +758,15 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
}{
|
||||
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
||||
{"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.Parallel()
|
||||
|
||||
log := heldLog{release: make(chan struct{})}
|
||||
servewaf := startServeWAF(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
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) })
|
||||
|
||||
@@ -794,9 +794,9 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
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) {
|
||||
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.WriteHeader(http.StatusOK)
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
report := servewaf.report(t)
|
||||
report := standIn.report(t)
|
||||
if report.Status != 0 || report.ResponseBytes != 0 {
|
||||
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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if 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) {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -977,11 +977,11 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
||||
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
||||
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.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "<html>broken")
|
||||
})
|
||||
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if contentType, sent := got.header["Content-Type"]; sent {
|
||||
@@ -1015,11 +1015,11 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
|
||||
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.Write(make([]byte, size))
|
||||
})
|
||||
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
||||
@@ -1135,15 +1135,15 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||
t.Errorf("answered %+v, want 502", got)
|
||||
}
|
||||
|
||||
out.requestLine(t)
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(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.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
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",
|
||||
defaultDisposition: disposition,
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||
servewaf.question(t)
|
||||
standIn.question(t)
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
@@ -1226,7 +1226,7 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
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",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
out.requestLine(t)
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||
@@ -1274,25 +1274,25 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
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" {
|
||||
t.Errorf("answered %+v, want ok", got)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// With servewaf up too, it is not asked.
|
||||
addr, _ = startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
get(t, addr, proxy.HealthPath)
|
||||
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
||||
get(t, addr, servewaf.HealthPath)
|
||||
|
||||
if len(servewaf.questions) != 0 {
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("servewaf was asked about the health check")
|
||||
}
|
||||
}
|
||||
@@ -1353,8 +1353,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
})
|
||||
|
||||
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{
|
||||
timeoutSetting: "1000",
|
||||
timeoutSetting: "3000",
|
||||
})
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
@@ -1394,15 +1396,15 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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()
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
out.requestLine(t)
|
||||
servewaf.wantNoReport(t)
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestReportGivesWhatHappened(t *testing.T) {
|
||||
@@ -1410,14 +1412,14 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
||||
|
||||
const body = "the visitor's body"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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, "/",
|
||||
strings.NewReader(body))))
|
||||
|
||||
report := servewaf.report(t)
|
||||
report := standIn.report(t)
|
||||
|
||||
if report.RequestID == "" || report.Status != http.StatusOK ||
|
||||
report.RequestBytes != int64(len(body)) ||
|
||||
@@ -1440,7 +1442,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
allow(w, r)
|
||||
|
||||
return
|
||||
@@ -1495,9 +1497,9 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
maxResponseTime: "2000",
|
||||
})
|
||||
@@ -1533,7 +1535,7 @@ func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
report := servewaf.report(t)
|
||||
report := standIn.report(t)
|
||||
if report.Status != http.StatusSwitchingProtocols ||
|
||||
report.RequestBytes != 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"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1570,7 +1572,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
||||
_, _ = buffered.WriteString(goodbye)
|
||||
_ = buffered.Flush()
|
||||
})
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
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
|
||||
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||
closed := make(chan error, 1)
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1631,7 +1633,7 @@ func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||
_, err = buffered.ReadByte()
|
||||
closed <- err
|
||||
})
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
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"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -1692,7 +1694,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
|
||||
req.Header.Set(requestIDHeader, fromTraefik)
|
||||
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] {
|
||||
@@ -1709,16 +1711,16 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
body = "the visitor's body"
|
||||
)
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
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.Host = site
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
line := out.requestLine(t)
|
||||
asked := servewaf.question(t)
|
||||
asked := standIn.question(t)
|
||||
|
||||
for name, want := range map[string]any{
|
||||
"type": "request",
|
||||
|
||||
+11
-11
@@ -11,7 +11,7 @@ import (
|
||||
"net/http"
|
||||
"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.
|
||||
@@ -38,10 +38,10 @@ type servewafAnswer struct {
|
||||
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
||||
// on.
|
||||
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
||||
disposition := answer.header.Get(proxy.DispositionHeader)
|
||||
disposition := answer.header.Get(servewaf.DispositionHeader)
|
||||
|
||||
switch disposition {
|
||||
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose:
|
||||
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
||||
rq.disposition = disposition
|
||||
rq.answered = true
|
||||
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.byDefault = true
|
||||
|
||||
if rq.disposition == proxy.DispositionAllow {
|
||||
if rq.disposition == servewaf.DispositionAllow {
|
||||
rq.passToApp(ctx)
|
||||
} else {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
@@ -169,13 +169,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
question.TransferEncoding = in.TransferEncoding
|
||||
|
||||
question.Header = in.Header.Clone()
|
||||
question.Header.Set(proxy.ClientHeader, rq.client.String())
|
||||
question.Header.Set(proxy.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(proxy.ContentLengthHeader)
|
||||
question.Header.Set(servewaf.ClientHeader, rq.client.String())
|
||||
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(servewaf.ContentLengthHeader)
|
||||
|
||||
announced := in.Header.Get("Content-Length")
|
||||
if announced != "" {
|
||||
question.Header.Set(proxy.ContentLengthHeader, announced)
|
||||
question.Header.Set(servewaf.ContentLengthHeader, announced)
|
||||
}
|
||||
|
||||
question.Header.Set(requestIDHeader, rq.id)
|
||||
@@ -196,7 +196,7 @@ func (rq *request) answerWith(answer *servewafAnswer) {
|
||||
|
||||
header := rq.out.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
|
||||
// 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
|
||||
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||
// 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()
|
||||
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
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode the report: %w", err)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic abuseipdb. It reads SWWAF_ABUSEIPDB_KEY,
|
||||
// SWWAF_ABUSEIPDB_MIN_SCORE and SWWAF_REPUTATION_ACTION.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// abuseIPDB notes AbuseIPDB, as noteHit does, with the score, when its score
|
||||
// of the client is a hit, and refuses the request when SWWAF_REPUTATION_ACTION
|
||||
// is deny, as dnsbl does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does
|
||||
// nothing. A client without a score is checked in the background, by the
|
||||
// request's address, if its history counts an offence, and the request does
|
||||
// not wait for the answer. The score is then used for each address of the
|
||||
// client. Its figures are the score and SWWAF_ABUSEIPDB_MIN_SCORE.
|
||||
type abuseIPDB struct{}
|
||||
|
||||
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||
|
||||
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
if rq.h.config.AbuseIPDBKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
client := rq.h.clientGroup(rq.client)
|
||||
held, _ := rq.h.limiter.Client(client)
|
||||
offender := held.History.Offences != ratelimit.Offences{}
|
||||
|
||||
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
||||
if !hit {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.abuseIPDBHit = true
|
||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
||||
|
||||
if rq.h.config.ReputationAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{
|
||||
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
@@ -88,11 +88,11 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
|
||||
// Any other request under /_smallwebwaf/ is not found.
|
||||
for _, e := range []adminEndpoint{
|
||||
{http.MethodPut, proxy.BansPath, banOtherClient},
|
||||
{http.MethodDelete, proxy.BansPath, ""},
|
||||
{http.MethodGet, proxy.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, proxy.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""},
|
||||
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath, ""},
|
||||
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
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)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
// Its notes count the request it refused.
|
||||
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
|
||||
// kept, marked lifted.
|
||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "",
|
||||
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
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.
|
||||
s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
@@ -181,7 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
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",
|
||||
},
|
||||
} {
|
||||
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) {
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
// 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" {
|
||||
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.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "",
|
||||
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -342,10 +342,10 @@ type adminEndpoint struct {
|
||||
// bans on otherClient, and asking about otherClient.
|
||||
func adminEndpoints() []adminEndpoint {
|
||||
return []adminEndpoint{
|
||||
{http.MethodGet, proxy.BansPath, ""},
|
||||
{http.MethodPost, proxy.BansPath, banOtherClient},
|
||||
{http.MethodDelete, proxy.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, proxy.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, servewaf.BansPath, ""},
|
||||
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
// never sent them, and returns the queue they wait in as well.
|
||||
func startWithAlerts(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||
@@ -210,7 +210,7 @@ func startWithAlerts(
|
||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||
func startAppWithAlerts(
|
||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, handler)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"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
|
||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
// kept.
|
||||
geojsURL, _, release := startHeldGeoJS(t)
|
||||
|
||||
var server atomic.Pointer[proxy.Server]
|
||||
var server atomic.Pointer[servewaf.Server]
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
release()
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -81,7 +81,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
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)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
@@ -91,6 +91,18 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startServeWAF(t, "", nil)
|
||||
req := s.question(siteA, client, "/")
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
do(t, req)
|
||||
|
||||
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
||||
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
||||
}
|
||||
|
||||
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -144,13 +156,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
line := s.out.requestLine(t)
|
||||
|
||||
if tc.refused {
|
||||
wantDisposition(t, got, tooLarge, proxy.DispositionDeny)
|
||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.DurationWAF == nil {
|
||||
@@ -175,7 +187,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
req := s.question(siteA, client, "/")
|
||||
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 +199,7 @@ func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
req := s.question(siteA, client, "/")
|
||||
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) {
|
||||
@@ -204,7 +216,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
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)
|
||||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||
@@ -224,8 +236,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, proxy.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, proxy.DispositionDeny)
|
||||
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
@@ -249,7 +261,7 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
req := s.question(siteA, client, tc.path)
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -265,7 +277,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
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)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
@@ -275,7 +287,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -291,7 +303,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
||||
req := s.question(siteA, client, "/")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -306,7 +318,7 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
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")
|
||||
if asn != asnDE || country != "DE" {
|
||||
@@ -318,7 +330,9 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
||||
// answers, a setting needs its answer, and everything is at its default but
|
||||
// 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()
|
||||
|
||||
settings := map[string]string{deniedCountries: "KP"}
|
||||
@@ -332,13 +346,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,
|
||||
// as change leaves it, and returns the answer's status and how long it took.
|
||||
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) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||
req.Header.Set(proxy.ClientHeader, client)
|
||||
req.Header.Set(servewaf.ClientHeader, client)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
change(req)
|
||||
|
||||
@@ -351,17 +365,17 @@ func askDirectly(
|
||||
}
|
||||
|
||||
// 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{})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
@@ -373,13 +387,13 @@ func listClient(t *testing.T, server *proxy.Server, now time.Time) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
loadDecisions(t, server, now, clientsDecision)
|
||||
}
|
||||
|
||||
// leaveAsItIs prepares nothing.
|
||||
func leaveAsItIs(*testing.T, *proxy.Server, time.Time) {}
|
||||
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
||||
|
||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -390,7 +404,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// 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 string
|
||||
status int
|
||||
@@ -461,7 +475,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// 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
|
||||
action string
|
||||
}{
|
||||
@@ -519,10 +533,10 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
|
||||
|
||||
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
|
||||
|
||||
return proxy.NewServeWAF(params)
|
||||
return servewaf.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
deniedCountries: "KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
@@ -1,7 +1,10 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
// The heuristic active_ban, and the code that makes bans, which several
|
||||
// heuristics share. active_ban reads SWWAF_MODE; the bans are the ban ledger's.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
@@ -18,11 +21,15 @@ func (rq *request) banResponse(action string) *refusal {
|
||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||
}
|
||||
|
||||
// banned reports whether a ban on a netblock the client is in covers the
|
||||
// request at now, and notes for the log line when that ban ends. A
|
||||
// request that makes the ban permanent, or in observe mode would have,
|
||||
// raises the alert for it.
|
||||
func (rq *request) banned(now time.Time) bool {
|
||||
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
||||
// in, in force at now. It notes for the log line when that ban ends. A request
|
||||
// that makes the ban permanent, or in observe mode would have, raises the alert
|
||||
// for it. Its figures are the ban's netblock and when it ends.
|
||||
type activeBan struct{}
|
||||
|
||||
func (activeBan) name() string { return "active_ban" }
|
||||
|
||||
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
check := rq.h.ledger.Check
|
||||
if rq.h.config.Observe {
|
||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||
@@ -38,85 +45,24 @@ func (rq *request) banned(now time.Time) bool {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banned
|
||||
}
|
||||
|
||||
// limitBroken counts the request for the rate limits at now, notes the
|
||||
// client's counts for the log line, and reports whether the request takes
|
||||
// the client over a rate limit, as its limit percentage lowers it, which
|
||||
// breaks it.
|
||||
func (rq *request) limitBroken(now time.Time) bool {
|
||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
||||
rq.limitPercent.percent)
|
||||
rq.line.Counts = counts
|
||||
|
||||
if over {
|
||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||
if !banned {
|
||||
return nil
|
||||
}
|
||||
|
||||
return over
|
||||
}
|
||||
|
||||
// countBytes counts the request's bytes, as countedBytes gives them, for
|
||||
// the byte limits, once its report has come, and notes the client's byte totals
|
||||
// for the log line; its requests stay there as the rate limits counted them.
|
||||
// Only an allowed request has them counted, and only one the rate limits
|
||||
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
||||
// that take the client over a byte limit, as its limit percentage for the byte
|
||||
// limits lowers it, break it; the response was passed on whole.
|
||||
func (rq *request) countBytes() {
|
||||
if !rq.counted || rq.line.WouldAction != "" {
|
||||
return
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||
rq.countedBytes(), rq.bytesPercent.percent)
|
||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||
rq.line.Counts.HourBytes = counts.HourBytes
|
||||
rq.line.Counts.DayBytes = counts.DayBytes
|
||||
|
||||
if over {
|
||||
rq.banForLimit(now, hit, rq.out.status)
|
||||
return &verdict{
|
||||
action: requestlog.ActionBanned,
|
||||
figures: map[string]any{
|
||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// countRefusal counts the request for the error burst once it has been
|
||||
// answered, if servewaf refused it after a rule file match, a trap path or a
|
||||
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
||||
// not counted, and nothing is while the threshold is off.
|
||||
func (rq *request) countRefusal() {
|
||||
cfg := rq.h.config
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return
|
||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||
// window, the count and the limit.
|
||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||
return map[string]any{
|
||||
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||
}
|
||||
|
||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||
return
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||
cfg.ErrorBurstThreshold)
|
||||
if !over {
|
||||
return
|
||||
}
|
||||
|
||||
// What the client was sent, or in observe mode would have been.
|
||||
status := rq.out.status
|
||||
|
||||
switch rq.line.WouldAction {
|
||||
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
||||
status = http.StatusForbidden
|
||||
case requestlog.ActionBanned:
|
||||
status = cfg.BanResponse
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, status)
|
||||
}
|
||||
|
||||
// countedBytes returns the request's bytes, once it has ended, as the
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -228,6 +228,22 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
|
||||
})
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantHeuristic(t, line, "active_ban",
|
||||
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
||||
}
|
||||
|
||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -382,7 +398,7 @@ func (c *clock) advance(d time.Duration) {
|
||||
// window.
|
||||
func startWithClock(
|
||||
t *testing.T, geojsURL string, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server) {
|
||||
) (*sender, *clock, *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The biased thresholds.
|
||||
@@ -419,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
// clock.
|
||||
func startWithLookups(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *proxy.Server, *alerts.Queue) {
|
||||
) (*sender, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||
@@ -434,7 +434,7 @@ func startWithLookups(
|
||||
// country, and no other address.
|
||||
func startWithLookupsAndClock(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
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
|
||||
// straight to server's handler, without the network, and returns once it is
|
||||
// answered.
|
||||
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) {
|
||||
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
||||
t.Helper()
|
||||
|
||||
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")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
@@ -0,0 +1,41 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic blocklists. It reads SWWAF_BLOCKLIST_ACTION, and the blocklists
|
||||
// through the reputation lists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// blocklists notes the blocklists that list the client, as noteListed does,
|
||||
// and refuses the request when SWWAF_BLOCKLIST_ACTION is deny. Being limit, it
|
||||
// lowers the client's limits instead (see limitPercentages), and being log, it
|
||||
// does nothing more. Its figure is the blocklists that list the client.
|
||||
type blocklists struct{}
|
||||
|
||||
func (blocklists) name() string { return "blocklists" }
|
||||
|
||||
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||
rq.blocklisted = len(listedBy) > 0
|
||||
rq.noteListed(listedBy, "listed by a blocklist")
|
||||
|
||||
if !rq.refusedByBlocklists(listedBy) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{"lists": listedBy},
|
||||
}
|
||||
}
|
||||
|
||||
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
||||
// client, given listedBy, the blocklists that list it: it does when one lists
|
||||
// it and SWWAF_BLOCKLIST_ACTION is deny.
|
||||
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
||||
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic byte_limits. It reads SWWAF_BYTES_COUNT, and the byte limits
|
||||
// and biased thresholds through the limiter and limitPercentages.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
||||
// byte limits, once its report has come, and notes the client's byte totals for
|
||||
// the log line; its requests stay there as the rate limits counted them. Only
|
||||
// an allowed request has them counted, and only one the rate limits counted; in
|
||||
// observe mode, not one that enforce mode would have refused. Bytes that take
|
||||
// the client over a byte limit, as its limit percentage for the byte limits
|
||||
// lowers it, break it, which bans the client; the response was passed on whole.
|
||||
// Its figures are the window, the count and the limit.
|
||||
type byteLimits struct{}
|
||||
|
||||
func (byteLimits) name() string { return "byte_limits" }
|
||||
|
||||
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
if !rq.counted || rq.line.WouldAction != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||
rq.countedBytes(), rq.bytesPercent.percent)
|
||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||
rq.line.Counts.HourBytes = counts.HourBytes
|
||||
rq.line.Counts.DayBytes = counts.DayBytes
|
||||
|
||||
if !over {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, rq.out.status)
|
||||
|
||||
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -98,6 +98,21 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
|
||||
|
||||
line, _ := s.download()
|
||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||
|
||||
// Two answers of 70 bytes are over the limit of 99.
|
||||
line, _ = s.download()
|
||||
wantHeuristic(t, line, "", "null")
|
||||
wantAfterAnswerHeuristic(t, line, "byte_limits",
|
||||
`{"window": "minute", "count": 140, "limit": 99}`)
|
||||
}
|
||||
|
||||
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,6 +1,10 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
// The heuristic core_rule_set. It reads SWWAF_WAF_MODE, SWWAF_WAF_EXEMPT_PATHS
|
||||
// and SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
@@ -9,18 +13,21 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
||||
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
||||
// pathExempt decides, and notes the rules it matched and its score in the
|
||||
// log line, and the rules in the metrics. A score at or over
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||
// and in block mode refuses the request, which is an offence its client's
|
||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
||||
// request it does not refuse.
|
||||
func (rq *request) checkCoreRuleSet() string {
|
||||
// and in block mode refuses the request with 403, which is an offence its
|
||||
// client's history counts. Its figures are the score and the threshold.
|
||||
type coreRuleSet struct{}
|
||||
|
||||
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||
|
||||
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
@@ -40,18 +47,21 @@ func (rq *request) checkCoreRuleSet() string {
|
||||
|
||||
threshold := cfg.WAFAnomalyThreshold
|
||||
if threshold == 0 || result.Score < threshold {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.alertWAFBlock(result)
|
||||
|
||||
if cfg.WAFMode == config.WAFModeDetect {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.wafBlocked = true
|
||||
|
||||
return requestlog.ActionWAFBlocked
|
||||
return &verdict{
|
||||
action: requestlog.ActionWAFBlocked,
|
||||
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
||||
}
|
||||
}
|
||||
|
||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -110,6 +110,16 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionWAFBlocked)
|
||||
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
||||
}
|
||||
|
||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic countries. It reads SWWAF_DENIED_COUNTRIES and
|
||||
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// countries refuses a request by the client's country as it was looked up. A
|
||||
// client without a country, or whose country cannot be found, is refused only
|
||||
// by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. Its figures are the country and the
|
||||
// setting that refused it.
|
||||
type countries struct{}
|
||||
|
||||
func (countries) name() string { return "countries" }
|
||||
|
||||
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
denied := rq.h.config.DeniedCountries
|
||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||
country := rq.line.Country
|
||||
|
||||
var setting string
|
||||
|
||||
switch {
|
||||
case slices.Contains(denied, country):
|
||||
setting = "SWWAF_DENIED_COUNTRIES"
|
||||
case len(allowed) > 0 && !slices.Contains(allowed, country):
|
||||
setting = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionCountryDenied,
|
||||
figures: map[string]any{"country": country, "setting": setting},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h", deniedCountries: "kp", allowedCountries: "de",
|
||||
})
|
||||
|
||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||
wantHeuristic(t, line, "countries",
|
||||
`{"country": "KP", "setting": "SWWAF_DENIED_COUNTRIES"}`)
|
||||
|
||||
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||
wantHeuristic(t, line, "countries",
|
||||
`{"country": "", "setting": "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||
}
|
||||
|
||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic crowdsec. It reads SWWAF_CROWDSEC_LAPI_URL, and the decisions
|
||||
// through the reputation lists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
||||
// the client is in force at now. It then notes the list, as noteHit does, and
|
||||
// bans the client until that decision ends. Its figures are the decision's
|
||||
// scenario and when it ends.
|
||||
type crowdSec struct{}
|
||||
|
||||
func (crowdSec) name() string { return "crowdsec" }
|
||||
|
||||
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
decision, listed := rq.crowdSecDecisionInForce(now)
|
||||
if !listed {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||
"listed by the CrowdSec decision list")
|
||||
rq.banForCrowdSec(now, decision)
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionBanned,
|
||||
figures: map[string]any{
|
||||
"scenario": decision.Scenario,
|
||||
"expires": requestlog.FormatTime(decision.Expires),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
|
||||
// the client that is in force at now, and reports whether there is one, which
|
||||
// the heuristic crowdsec refuses the request for.
|
||||
func (rq *request) crowdSecDecisionInForce(
|
||||
now time.Time,
|
||||
) (reputation.Decision, bool) {
|
||||
return rq.h.lists.CrowdSecDecision(rq.client, now)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||
@@ -95,6 +95,21 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
||||
float64(fetched.Unix()))
|
||||
}
|
||||
|
||||
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
||||
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
||||
})
|
||||
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "`+client+`"}]`)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
||||
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
||||
}
|
||||
|
||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -168,7 +183,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||
// reputation.json would at start.
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic deny_nets. It reads SWWAF_DENY_NETS.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// denyNets refuses a client in SWWAF_DENY_NETS. Its figure is the netblock of
|
||||
// the setting the client is in.
|
||||
type denyNets struct{}
|
||||
|
||||
func (denyNets) name() string { return "deny_nets" }
|
||||
|
||||
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
for _, netblock := range rq.h.config.DenyNets {
|
||||
if netblock.Contains(rq.client) {
|
||||
return &verdict{
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{figureNetblock: netblock.String()},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic dnsbl. It reads SWWAF_REPUTATION_ACTION, and the DNSBL zones'
|
||||
// verdicts through the DNSBL checker.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||
// does, and refuses the request when SWWAF_REPUTATION_ACTION is deny. Being
|
||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||
// being log, it does nothing more. A zone without a verdict on the client is
|
||||
// asked about it in the background, and the request does not wait for the
|
||||
// answer. Its figure is the zones that list the client, their keys masked.
|
||||
type dnsbl struct{}
|
||||
|
||||
func (dnsbl) name() string { return "dnsbl" }
|
||||
|
||||
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
||||
rq.dnsblListed = len(listedBy) > 0
|
||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||
|
||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{"zones": listedBy},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The header limits: the most cookies and header fields a request may have
|
||||
// when the checks reach the Core Rule Set. Real clients never reach them.
|
||||
const (
|
||||
maxCookies = 50
|
||||
maxHeaderFields = 100
|
||||
)
|
||||
|
||||
// headerLimits refuses a request over the header limits, with 431. Its figures
|
||||
// are the request's header fields and cookies, and the limit of each.
|
||||
type headerLimits struct{}
|
||||
|
||||
func (headerLimits) name() string { return "header_limits" }
|
||||
|
||||
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
fields, cookies := rq.headerCounts()
|
||||
if !overHeaderLimits(fields, cookies) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionTooLarge,
|
||||
figures: map[string]any{
|
||||
"fields": fields, "fields_limit": maxHeaderFields,
|
||||
"cookies": cookies, "cookies_limit": maxCookies,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// overHeaderLimits reports whether a request with these counts of header fields
|
||||
// and cookies, as headerCounts counts them, is over the header limits: it has
|
||||
// more than maxHeaderFields fields or more than maxCookies cookies.
|
||||
func overHeaderLimits(fields, cookies int) bool {
|
||||
return fields > maxHeaderFields || cookies > maxCookies
|
||||
}
|
||||
|
||||
// headerCounts returns the request's header fields, each value of a header sent
|
||||
// twice being one, and its cookies, each part of a Cookie header between
|
||||
// semicolons being one unless it is empty or only spaces and tabs. The Core
|
||||
// Rule Set runs each of its rules on each cookie, so that thousands of small
|
||||
// cookies keep it busy for over a second. It trims only spaces and control
|
||||
// characters, of which a header value can hold only tabs, so a part of Unicode
|
||||
// spaces is a cookie to it.
|
||||
func (rq *request) headerCounts() (int, int) {
|
||||
fields, cookies := 0, 0
|
||||
|
||||
for _, values := range rq.in.Header {
|
||||
fields += len(values)
|
||||
}
|
||||
|
||||
for _, value := range rq.in.Header.Values("Cookie") {
|
||||
for part := range strings.SplitSeq(value, ";") {
|
||||
if strings.Trim(part, " \t") != "" {
|
||||
cookies++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields, cookies
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
)
|
||||
|
||||
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 {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
@@ -0,0 +1,133 @@
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// To add a heuristic, write one file with a type that implements heuristic,
|
||||
// and add one line for it to one of the lists below. Its settings go in
|
||||
// config.Config, and the comment at the top of its file names them.
|
||||
|
||||
// heuristic is one of the checks that decide what servewaf does with a request.
|
||||
type heuristic interface {
|
||||
// name is the heuristic's name, which the log line gives.
|
||||
name() string
|
||||
// check looks at the request at now, and returns nil to let it through.
|
||||
// ctx is the request's own context.
|
||||
check(ctx context.Context, rq *request, now time.Time) *verdict
|
||||
}
|
||||
|
||||
// verdict is a heuristic refusing a request, or banning its client: the action
|
||||
// the log line names, one of requestlog's Action constants, and the figures the
|
||||
// heuristic acted on, by name, such as the count and the limit.
|
||||
type verdict struct {
|
||||
action string
|
||||
figures map[string]any
|
||||
}
|
||||
|
||||
// The names of the figures that more than one heuristic gives.
|
||||
const (
|
||||
figureWindow = "window"
|
||||
figureCount = "count"
|
||||
figureLimit = "limit"
|
||||
figureScore = "score"
|
||||
figureNetblock = "netblock"
|
||||
)
|
||||
|
||||
// The lists of heuristics, each in the order its heuristics run. A client in
|
||||
// SWWAF_ALLOW_NETS skips them all.
|
||||
|
||||
// beforeLookup are the heuristics that run before the client's AS number and
|
||||
// country are looked up, so that a client they refuse is not looked up.
|
||||
func beforeLookup() []heuristic {
|
||||
return []heuristic{
|
||||
denyNets{},
|
||||
activeBan{},
|
||||
}
|
||||
}
|
||||
|
||||
// afterLookup are the heuristics that run once the lookup has been made.
|
||||
func afterLookup() []heuristic {
|
||||
return []heuristic{
|
||||
countries{},
|
||||
blocklists{},
|
||||
crowdSec{},
|
||||
dnsbl{},
|
||||
abuseIPDB{},
|
||||
rateLimits{},
|
||||
trapPaths{},
|
||||
ruleFiles{},
|
||||
headerLimits{},
|
||||
coreRuleSet{},
|
||||
}
|
||||
}
|
||||
|
||||
// afterAnswer are the heuristics that run on a request once servewaf has
|
||||
// answered it, before its decision line is written. They ban the client, and do
|
||||
// not change what the request got: their verdict's action is not used.
|
||||
func afterAnswer() []heuristic {
|
||||
return []heuristic{
|
||||
refusalLimit{},
|
||||
}
|
||||
}
|
||||
|
||||
// onReport are the heuristics that run on an allowed request when rproxy's
|
||||
// report of it comes, before its report line is written. Like afterAnswer, they
|
||||
// only ban.
|
||||
func onReport() []heuristic {
|
||||
return []heuristic{
|
||||
byteLimits{},
|
||||
}
|
||||
}
|
||||
|
||||
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
|
||||
// it is called.
|
||||
func (rq *request) runAfterAnswer(ctx context.Context) {
|
||||
rq.runBanningHeuristics(ctx, afterAnswer())
|
||||
}
|
||||
|
||||
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
|
||||
// and returns the first verdict, or nil when none gives one. The log line's
|
||||
// heuristic and heuristic_figures name the heuristic that gave it, and give its
|
||||
// figures. ctx is the request's own context.
|
||||
func (rq *request) runHeuristics(
|
||||
ctx context.Context, list []heuristic, now time.Time,
|
||||
) *verdict {
|
||||
name, v := rq.firstVerdict(ctx, list, now)
|
||||
if v != nil {
|
||||
rq.line.Heuristic = name
|
||||
rq.line.HeuristicFigures = v.figures
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
|
||||
// the time it is called. The log line's after_answer_heuristic and
|
||||
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
|
||||
// give its figures, so that heuristic and heuristic_figures still name the one
|
||||
// that refused the request. ctx is the request's own context.
|
||||
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
|
||||
name, v := rq.firstVerdict(ctx, list, rq.h.now())
|
||||
if v != nil {
|
||||
rq.line.AfterAnswerHeuristic = name
|
||||
rq.line.AfterAnswerHeuristicFigures = v.figures
|
||||
}
|
||||
}
|
||||
|
||||
// firstVerdict runs list on the request at now, in order, until a heuristic
|
||||
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
|
||||
// nil when none gives one. ctx is the request's own context.
|
||||
func (rq *request) firstVerdict(
|
||||
ctx context.Context, list []heuristic, now time.Time,
|
||||
) (string, *verdict) {
|
||||
for _, each := range list {
|
||||
v := each.check(ctx, rq, now)
|
||||
if v != nil {
|
||||
return each.name(), v
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
s.adminRequest("", http.MethodGet, proxy.HealthPath, "", http.StatusOK)
|
||||
s.adminRequest("", http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized)
|
||||
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
||||
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized)
|
||||
s.scrape()
|
||||
|
||||
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.
|
||||
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()
|
||||
|
||||
client := netip.MustParsePrefix(addr + "/32")
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"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,
|
||||
// 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
|
||||
// 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) {
|
||||
// Not the default. The exclusive list needs the answer, and the app
|
||||
// is never reached.
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
s.adminRequest(authorization, http.MethodGet, proxy.MetricsPath, "",
|
||||
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "",
|
||||
http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -46,18 +46,18 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
method, path, authorization string
|
||||
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.MethodGet, proxy.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
||||
{http.MethodGet, proxy.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, proxy.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, proxy.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, proxy.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
||||
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, servewaf.MetricsPath + "/", 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)
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||
wantMetric(t, s.scrape(), admins, 1)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ func keptAnswer(addr, country string) lookup.Answer {
|
||||
func scrape(t *testing.T, addr string) string {
|
||||
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)
|
||||
|
||||
got := do(t, req)
|
||||
@@ -380,7 +380,7 @@ func scrape(t *testing.T, addr string) string {
|
||||
func (s *sender) scrape() string {
|
||||
s.t.Helper()
|
||||
|
||||
return string(s.adminRequest(bearer, http.MethodGet, proxy.MetricsPath, "",
|
||||
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "",
|
||||
http.StatusOK).body)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -14,6 +14,18 @@ import (
|
||||
// observe is the value of SWWAF_MODE for observe mode.
|
||||
const observe = "observe"
|
||||
|
||||
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
mode: observe, denyNets: client,
|
||||
})
|
||||
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
||||
}
|
||||
|
||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Package proxy is servewaf's server. It runs the checks on the request each of
|
||||
// rproxy's questions carries, and answers rproxy what to do with that request. It
|
||||
// takes rproxy's report once the visitor's answer has ended, which counts the
|
||||
// request's bytes and writes its last log line. It answers servewaf's own
|
||||
// endpoints: the health check, the metrics, the bans and the clients. It passes
|
||||
// nothing to an app: internal/rproxy does that.
|
||||
package proxy
|
||||
// Package servewaf is the server the servewaf command runs. It answers rproxy's
|
||||
// questions, takes rproxy's reports and answers its own endpoints, and writes a
|
||||
// log line for each.
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -46,7 +43,7 @@ const BansPath = "/_smallwebwaf/bans"
|
||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_smallwebwaf/clients/"
|
||||
|
||||
// Params are what NewServeWAF needs.
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -300,7 +300,7 @@ func startProxyWithGeoJS(
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *proxy.Server) {
|
||||
) (string, *output, *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||
@@ -313,7 +313,7 @@ func startProxyWithClock(
|
||||
func startProxyWithAlerts(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
||||
) (string, *output, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
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
|
||||
// Addr to where it listens.
|
||||
func serve(t *testing.T, server *proxy.Server) {
|
||||
func serve(t *testing.T, server *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
@@ -401,17 +401,17 @@ func startRProxy(
|
||||
// inspected by it.
|
||||
func newServeWAF(
|
||||
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()
|
||||
|
||||
return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env)
|
||||
return newServeWAFWith(t, servewaf.New, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newServeWAFWith is newServeWAF for the server build makes.
|
||||
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,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
@@ -463,7 +463,7 @@ func newServeWAFWith(
|
||||
}
|
||||
}
|
||||
|
||||
server := build(proxy.Params{
|
||||
server := build(servewaf.Params{
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
@@ -0,0 +1,65 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic rate_limits. It reads SWWAF_RATE_LIMIT_EXEMPT_NETS,
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS and SWWAF_BAN_RESPONSE, and the rate limits and
|
||||
// biased thresholds through the limiter and limitPercentages.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// rateLimits counts the request for the rate limits, unless
|
||||
// countedByRateLimits exempts it, by the client's limit percentages, which it
|
||||
// notes for the log line with the client's counts. A request that takes the
|
||||
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
|
||||
// refused, and bans the client. A request exempt from the rate limits is exempt
|
||||
// from the byte limits too. Its figures are the window, the count and the
|
||||
// limit.
|
||||
type rateLimits struct{}
|
||||
|
||||
func (rateLimits) name() string { return "rate_limits" }
|
||||
|
||||
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
rq.counted = rq.countedByRateLimits()
|
||||
if !rq.counted {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
||||
|
||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
||||
rq.limitPercent.percent)
|
||||
rq.line.Counts = counts
|
||||
|
||||
if !over {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||
|
||||
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
|
||||
}
|
||||
|
||||
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
||||
// request at now, whatever its client's limit percentages: the rate limits
|
||||
// count it, and one more request from its client takes it over a whole rate
|
||||
// limit. It counts nothing.
|
||||
func (rq *request) overAWholeRateLimit(now time.Time) bool {
|
||||
return rq.countedByRateLimits() &&
|
||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
|
||||
}
|
||||
|
||||
// countedByRateLimits reports whether the rate limits count the request: its
|
||||
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
|
||||
func (rq *request) countedByRateLimits() bool {
|
||||
cfg := rq.h.config
|
||||
|
||||
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -72,6 +72,17 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic refusal_limit. It reads SWWAF_ERROR_BURST_THRESHOLD and
|
||||
// SWWAF_BAN_RESPONSE.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// refusalLimit counts the request once it has been answered, if servewaf
|
||||
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||
// in observe mode if enforce mode would have: more than
|
||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||
// a limit, which bans the client. A client in SWWAF_ALLOW_NETS, which the
|
||||
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||
// Its figures are the window, the count and the limit.
|
||||
type refusalLimit struct{}
|
||||
|
||||
func (refusalLimit) name() string { return "refusal_limit" }
|
||||
|
||||
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||
cfg.ErrorBurstThreshold)
|
||||
if !over {
|
||||
return nil
|
||||
}
|
||||
|
||||
// What the client was sent, or in observe mode would have been.
|
||||
status := rq.out.status
|
||||
|
||||
switch rq.line.WouldAction {
|
||||
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
||||
status = http.StatusForbidden
|
||||
case requestlog.ActionBanned:
|
||||
status = cfg.BanResponse
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, status)
|
||||
|
||||
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
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.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server) {
|
||||
) (*sender, *clock, *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
@@ -106,6 +106,25 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
||||
|
||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||
|
||||
line := blockRule.send(s, client)
|
||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||
|
||||
// The second refusal is the request refusal_limit bans for, and the line
|
||||
// names both.
|
||||
line = blockRule.send(s, client)
|
||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||
wantAfterAnswerHeuristic(t, line, "refusal_limit",
|
||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package servewaf
|
||||
|
||||
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
||||
// abuseipdb.
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
||||
// refuses the requests of a client a source lists.
|
||||
const deny = "deny"
|
||||
|
||||
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||
// with reason.
|
||||
func (rq *request) noteListed(sources []string, reason string) {
|
||||
for _, source := range sources {
|
||||
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// noteHit adds hit's source, which lists the client, to the log line's
|
||||
// reputation, and hit to the notes of a ban the request makes, counts the
|
||||
// source in the metrics, and raises a reputation_hit alert with reason,
|
||||
// whose detail gives hit's source and score.
|
||||
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||
detail := map[string]any{"source": hit.Source}
|
||||
if hit.Score != nil {
|
||||
detail["score"] = *hit.Score
|
||||
}
|
||||
|
||||
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
||||
rq.reputation = append(rq.reputation, hit)
|
||||
rq.h.metrics.ReputationHit(hit.Source)
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventReputationHit,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: rq.h.clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reason: reason,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The reputation settings.
|
||||
@@ -118,6 +118,56 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
heuristic string
|
||||
env map[string]string
|
||||
load func(*testing.T, *servewaf.Server)
|
||||
figures string
|
||||
}{
|
||||
{
|
||||
"blocklists",
|
||||
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
|
||||
func(t *testing.T, server *servewaf.Server) {
|
||||
t.Helper()
|
||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||
},
|
||||
`{"lists": ["` + dropURL + `"]}`,
|
||||
},
|
||||
{
|
||||
"dnsbl",
|
||||
map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
reputationAction: actionDeny,
|
||||
},
|
||||
func(_ *testing.T, server *servewaf.Server) {
|
||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||
},
|
||||
`{"zones": ["` + dnsblZone + `"]}`,
|
||||
},
|
||||
{
|
||||
"abuseipdb",
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||
func(_ *testing.T, server *servewaf.Server) {
|
||||
loadScores(server, map[string]int64{client: 80})
|
||||
},
|
||||
`{"score": 80, "min_score": 75}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.heuristic, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, tc.env)
|
||||
tc.load(t, server)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||
wantHeuristic(t, line, tc.heuristic, tc.figures)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -617,7 +667,7 @@ const (
|
||||
// AbuseIPDB.
|
||||
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,
|
||||
// through which AbuseIPDB is asked.
|
||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||
@@ -975,7 +1025,7 @@ func verdictsFetched() time.Time {
|
||||
// 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
|
||||
// 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()))
|
||||
|
||||
for client, zones := range listedBy {
|
||||
@@ -993,7 +1043,7 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
||||
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||
// 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))
|
||||
for client, score := range scores {
|
||||
kept = append(kept, reputation.Score{
|
||||
@@ -1007,7 +1057,7 @@ func loadScores(server *proxy.Server, scores map[string]int64) {
|
||||
|
||||
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||
// AbuseIPDB.
|
||||
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
||||
func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
|
||||
t.Helper()
|
||||
|
||||
if got := server.AbuseIPDB.Checked(); got != want {
|
||||
@@ -1017,7 +1067,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
||||
|
||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||
// 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()
|
||||
|
||||
lists := make([]reputation.List, 0, len(copies))
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -160,16 +160,20 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
||||
}
|
||||
|
||||
// check is the one place where a request can be refused. It returns nil to let
|
||||
// the request through. The checks of checkClient decide: a request they refuse
|
||||
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
||||
// line names what enforce mode would have done. ctx is the request's own context.
|
||||
// the request through. The heuristics of checkClient decide: a request they
|
||||
// refuse is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core
|
||||
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
|
||||
// and the log line names what enforce mode would have done. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) check(ctx context.Context) *refusal {
|
||||
action := rq.checkClient(ctx)
|
||||
v := rq.checkClient(ctx)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
action := v.action
|
||||
|
||||
switch {
|
||||
case action == "":
|
||||
return nil
|
||||
case rq.h.config.Observe:
|
||||
rq.line.WouldAction = action
|
||||
|
||||
@@ -183,35 +187,21 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
}
|
||||
}
|
||||
|
||||
// checkClient runs the checks on the request's client, and returns the
|
||||
// action of the first that refuses the request, or "" when none does. A
|
||||
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
||||
// so that a client either refuses is not looked up, then the lookup of
|
||||
// its AS number and country, then the country lists, then the blocklists,
|
||||
// then the CrowdSec decision list, which bans the client it lists, then
|
||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
||||
// them refuses is not counted for the rate limits. Then come the
|
||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||
// every other request is counted, each of them by the client's limit
|
||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
|
||||
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
||||
// is exempt from the byte limits too. ctx is the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
return ""
|
||||
// checkClient runs the heuristics of beforeLookup on the request, then looks
|
||||
// up the client's AS number and country, then runs those of afterLookup. It
|
||||
// returns the first verdict, or nil when none gives one. A client in
|
||||
// SWWAF_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
|
||||
// own context.
|
||||
func (rq *request) checkClient(ctx context.Context) *verdict {
|
||||
if isInside(rq.client, rq.h.config.AllowNets) {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
if isInside(rq.client, cfg.DenyNets) {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
if rq.banned(now) {
|
||||
return requestlog.ActionBanned
|
||||
v := rq.runHeuristics(ctx, beforeLookup(), now)
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
|
||||
rq.lookUp(ctx, now)
|
||||
@@ -220,59 +210,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
||||
// again: a request counted at the time it arrived could be counted
|
||||
// after a later one of its client's, which the rate limits would take
|
||||
// for the clock set back.
|
||||
now = rq.h.now()
|
||||
|
||||
if rq.countryDenied() {
|
||||
return requestlog.ActionCountryDenied
|
||||
}
|
||||
|
||||
if rq.blocklistDenied() {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
if rq.crowdSecBanned(now) {
|
||||
return requestlog.ActionBanned
|
||||
}
|
||||
|
||||
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
||||
return requestlog.ActionDenied
|
||||
}
|
||||
|
||||
rq.counted = rq.countedByRateLimits()
|
||||
if rq.counted {
|
||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
||||
}
|
||||
|
||||
if rq.counted && rq.limitBroken(now) {
|
||||
return requestlog.ActionRateLimited
|
||||
}
|
||||
|
||||
if rq.trapPath(now) {
|
||||
return requestlog.ActionBanned
|
||||
}
|
||||
|
||||
action := rq.checkRules(now)
|
||||
if action != "" {
|
||||
return action
|
||||
}
|
||||
|
||||
if rq.overHeaderLimits() {
|
||||
return requestlog.ActionTooLarge
|
||||
}
|
||||
|
||||
return rq.checkCoreRuleSet()
|
||||
}
|
||||
|
||||
// countedByRateLimits reports whether the rate limits count the request: its
|
||||
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
|
||||
func (rq *request) countedByRateLimits() bool {
|
||||
cfg := rq.h.config
|
||||
|
||||
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
||||
return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
|
||||
}
|
||||
|
||||
// pathExempt reports whether r is exempt under prefixes,
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -10,9 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -220,7 +221,7 @@ func TestTimingsAreInOrder(t *testing.T) {
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
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]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
@@ -272,6 +273,48 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantHeuristic checks that line names heuristic as the one that refused the
|
||||
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
|
||||
// a line that names none.
|
||||
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||
t.Helper()
|
||||
|
||||
if line.Heuristic != heuristic ||
|
||||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
|
||||
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
|
||||
line.Heuristic, line.HeuristicFigures, heuristic, figures)
|
||||
}
|
||||
}
|
||||
|
||||
// wantAfterAnswerHeuristic checks after_answer_heuristic and
|
||||
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
|
||||
// heuristic_figures.
|
||||
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||
t.Helper()
|
||||
|
||||
if line.AfterAnswerHeuristic != heuristic ||
|
||||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
|
||||
t.Errorf("log line has after_answer_heuristic %q with "+
|
||||
"after_answer_heuristic_figures %v, want %q with %s",
|
||||
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
|
||||
// the log line's figures are decoded.
|
||||
func decodeFigures(t *testing.T, figures string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var decoded map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(figures), &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", figures, err)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// microseconds is a timing in whole microseconds.
|
||||
func microseconds(milliseconds float64) int64 {
|
||||
return int64(math.Round(milliseconds * 1000))
|
||||
@@ -0,0 +1,55 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||
// SWWAF_RULES_DIR and SWWAF_RULES_ENABLED name.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// ruleFiles checks the request against the rules of the rule files at now,
|
||||
// notes the ids of those it matches in the log line, and refuses the request
|
||||
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
|
||||
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
|
||||
// for a clear sign of attack, or in observe mode raises the alert for the ban
|
||||
// it would have made. Either rule's match is noted as an offence, for the
|
||||
// client's history. Its figures are the rule's id and action.
|
||||
type ruleFiles struct{}
|
||||
|
||||
func (ruleFiles) name() string { return "rule_files" }
|
||||
|
||||
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
matched := rq.h.rules.Match(rq.in)
|
||||
|
||||
for _, rule := range matched {
|
||||
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
|
||||
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
|
||||
}
|
||||
|
||||
if len(matched) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only the last rule matched can refuse the request.
|
||||
last := matched[len(matched)-1]
|
||||
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
|
||||
|
||||
switch last.Action {
|
||||
case rules.ActionBlock:
|
||||
rq.ruleBlocked = true
|
||||
|
||||
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
|
||||
case rules.ActionBan:
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
||||
|
||||
return &verdict{action: requestlog.ActionBanned, figures: figures}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -93,6 +93,21 @@ func TestEachRuleAction(t *testing.T) {
|
||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
})
|
||||
|
||||
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||
requestlog.ActionRuleBlocked)
|
||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
||||
|
||||
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
||||
}
|
||||
|
||||
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package proxy
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -52,13 +53,6 @@ const ReportPath = "/_smallwebwaf/report"
|
||||
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||
const QuestionBodyMaxBytes = 64 << 10
|
||||
|
||||
// The header limits: the most cookies and header fields a request may have
|
||||
// when the checks reach the Core Rule Set. Real clients never reach them.
|
||||
const (
|
||||
maxCookies = 50
|
||||
maxHeaderFields = 100
|
||||
)
|
||||
|
||||
const (
|
||||
// maxAwaitingReport is the most answered requests held until their
|
||||
// reports come. Past it, the request answered longest ago is dropped.
|
||||
@@ -103,13 +97,13 @@ type Report struct {
|
||||
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
|
||||
// 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
|
||||
// is far above rproxy's, and a question refused for its size would have rproxy
|
||||
// let the request through.
|
||||
func NewServeWAF(params Params) *Server {
|
||||
func New(params Params) *Server {
|
||||
h := newHandler(params)
|
||||
|
||||
return &Server{
|
||||
@@ -147,48 +141,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||
// its client: it is over the header limits, a blocklist lists its client while
|
||||
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
||||
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
||||
// rproxy lets a request through when servewaf answers too late.
|
||||
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
||||
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
||||
// which refuses nothing, none is. rproxy lets a request through when servewaf
|
||||
// answers too late.
|
||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||
if rq.h.config.Observe {
|
||||
return false
|
||||
}
|
||||
|
||||
lists := rq.h.lists
|
||||
_, decided := lists.CrowdSecDecision(rq.client, now)
|
||||
_, decided := rq.crowdSecDecisionInForce(now)
|
||||
|
||||
return decided || rq.overHeaderLimits() ||
|
||||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
|
||||
(rq.countedByRateLimits() &&
|
||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
|
||||
}
|
||||
|
||||
// overHeaderLimits reports whether servewaf refuses the request for its
|
||||
// headers: it has more than maxHeaderFields fields, each value of a header sent
|
||||
// twice being one, or more than maxCookies cookies, each part of a Cookie header
|
||||
// between semicolons being one unless it is empty or only spaces and tabs. The
|
||||
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
|
||||
// cookies keep it busy for over a second. It trims only spaces and control
|
||||
// characters, of which a header value can hold only tabs, so a part of Unicode
|
||||
// spaces is a cookie to it.
|
||||
func (rq *request) overHeaderLimits() bool {
|
||||
fields, cookies := 0, 0
|
||||
|
||||
for _, values := range rq.in.Header {
|
||||
fields += len(values)
|
||||
}
|
||||
|
||||
for _, value := range rq.in.Header.Values("Cookie") {
|
||||
for part := range strings.SplitSeq(value, ";") {
|
||||
if strings.Trim(part, " \t") != "" {
|
||||
cookies++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields > maxHeaderFields || cookies > maxCookies
|
||||
return decided || overHeaderLimits(rq.headerCounts()) ||
|
||||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
||||
rq.overAWholeRateLimit(now)
|
||||
}
|
||||
|
||||
// answerQuestion runs the checks on the visitor's request a question
|
||||
@@ -219,18 +185,18 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if refused == nil && !underOwnPrefix {
|
||||
// In observe mode, what enforce mode would have refused counts here.
|
||||
defer rq.countRefusal()
|
||||
defer rq.runAfterAnswer(r.Context())
|
||||
|
||||
rq.allow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The last deferred runs first: countRefusal before addToHistory, so that a
|
||||
// broken error burst is in the client's history.
|
||||
// The last deferred runs first: runAfterAnswer before addToHistory, so that
|
||||
// a broken refusal limit is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
defer rq.runAfterAnswer(r.Context())
|
||||
|
||||
if refused != nil {
|
||||
rq.deny(*refused)
|
||||
@@ -412,7 +378,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Remove reports true to one report only, of several that name the id. A
|
||||
// question still being handled is held as nil.
|
||||
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
||||
h.finishWith(held, report)
|
||||
h.finishWith(r.Context(), held, report)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -475,11 +441,11 @@ func (report *Report) checkRanges() error {
|
||||
|
||||
// finishWith finishes a request with what its report says, and writes the
|
||||
// request's last log line, which holds its decision's fields and the report's.
|
||||
// For an allowed request it first counts the request's bytes, and adds it to its
|
||||
// client's history, the anomaly counters and the metrics, with the status and
|
||||
// bytes the report gives. A denied request's counting was finished at its
|
||||
// decision.
|
||||
func (h *handler) finishWith(held *decision, report Report) {
|
||||
// For an allowed request it first runs the heuristics of onReport, and adds the
|
||||
// request to its client's history, the anomaly counters and the metrics, with
|
||||
// the status and bytes the report gives. A denied request's counting was
|
||||
// finished at its decision. ctx is the report's own context.
|
||||
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
|
||||
rq := &request{
|
||||
decision: *held,
|
||||
h: h,
|
||||
@@ -496,7 +462,7 @@ func (h *handler) finishWith(held *decision, report Report) {
|
||||
if rq.allowed {
|
||||
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
||||
|
||||
rq.countBytes()
|
||||
rq.runBanningHeuristics(ctx, onReport())
|
||||
rq.countAnomalies()
|
||||
rq.addToHistory()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -57,7 +57,7 @@ type serveWAF struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
out *output
|
||||
server *proxy.Server
|
||||
server *servewaf.Server
|
||||
clock *clock
|
||||
queue *alerts.Queue
|
||||
}
|
||||
@@ -67,13 +67,13 @@ type serveWAF struct {
|
||||
func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF {
|
||||
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
|
||||
// it starts.
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
@@ -103,8 +103,8 @@ func (s *serveWAF) questionWith(
|
||||
|
||||
req := newRequest(s.t, method, s.addr, path, body)
|
||||
req.Host = host
|
||||
req.Header.Set(proxy.ClientHeader, from)
|
||||
req.Header.Set(proxy.PeerHeader, rproxyPeer)
|
||||
req.Header.Set(servewaf.ClientHeader, from)
|
||||
req.Header.Set(servewaf.PeerHeader, rproxyPeer)
|
||||
req.Header.Set(requestIDHeader, rand.Text())
|
||||
|
||||
return req
|
||||
@@ -131,13 +131,13 @@ func (s *serveWAF) allowed(from string) string {
|
||||
s.t.Helper()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
body, err := json.Marshal(report)
|
||||
@@ -152,7 +152,7 @@ func (s *serveWAF) report(report proxy.Report) answer {
|
||||
func (s *serveWAF) reportBody(body string) answer {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func (s *serveWAF) reportBody(body string) answer {
|
||||
func (s *serveWAF) passedOn(from string) {
|
||||
s.t.Helper()
|
||||
|
||||
got := s.report(proxy.Report{
|
||||
got := s.report(servewaf.Report{
|
||||
RequestID: s.allowed(from),
|
||||
Status: http.StatusOK,
|
||||
RequestBytes: bodyBytes,
|
||||
@@ -182,7 +182,7 @@ func (s *serveWAF) history() ratelimit.History {
|
||||
func (s *serveWAF) metrics() string {
|
||||
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)
|
||||
|
||||
got := do(s.t, req)
|
||||
@@ -196,7 +196,7 @@ func wantDisposition(t *testing.T, got answer, status int, disposition string) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -248,10 +248,10 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
|
||||
req := s.question(siteA, client, "/page?q=1&token=visible")
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set(proxy.ContentLengthHeader, "1234")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "1234")
|
||||
|
||||
got := do(t, req)
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
wantBody(t, got, "")
|
||||
|
||||
if id := got.header.Get(requestIDHeader); id != "" {
|
||||
@@ -278,7 +278,7 @@ func TestIPv6VisitorIsCountedInItsGroup(t *testing.T) {
|
||||
s.allowed("2001:db8::1")
|
||||
|
||||
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) {
|
||||
@@ -287,9 +287,9 @@ func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) {
|
||||
s := startServeWAF(t, "", map[string]string{denyNets: 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)
|
||||
if line.ClientIP != client || line.PeerIP != rproxyPeer {
|
||||
@@ -305,13 +305,13 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) {
|
||||
change func(*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) {
|
||||
req.Header.Set(proxy.ClientHeader, "")
|
||||
req.Header.Set(servewaf.ClientHeader, "")
|
||||
}},
|
||||
{"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) {
|
||||
req.Header.Del(requestIDHeader)
|
||||
@@ -353,7 +353,7 @@ func TestLongestValidIDIsTaken(t *testing.T) {
|
||||
|
||||
req := s.question(siteA, client, "/")
|
||||
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) {
|
||||
@@ -361,15 +361,15 @@ func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
|
||||
|
||||
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))
|
||||
wantDisposition(t, got, http.StatusBadRequest, "")
|
||||
wantProcessMessages(t, s, "a question is refused", 1)
|
||||
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))
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
|
||||
@@ -385,15 +385,15 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
|
||||
wantProcessMessages(t, s, "a question is refused", 1)
|
||||
|
||||
// A denied request's id is held as well.
|
||||
denied := s.question(siteA, otherClient, proxy.HealthPath)
|
||||
wantDisposition(t, do(t, denied), http.StatusNotFound, proxy.DispositionDeny)
|
||||
denied := s.question(siteA, otherClient, servewaf.HealthPath)
|
||||
wantDisposition(t, do(t, denied), http.StatusNotFound, servewaf.DispositionDeny)
|
||||
|
||||
again = s.question(siteA, client, "/")
|
||||
again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader))
|
||||
wantDisposition(t, do(t, again), http.StatusBadRequest, "")
|
||||
|
||||
// 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)
|
||||
|
||||
if history := s.history(); history.ResponseBytes != answerBytes {
|
||||
@@ -402,7 +402,7 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
|
||||
|
||||
again = s.question(siteA, client, "/")
|
||||
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) {
|
||||
@@ -436,7 +436,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
|
||||
|
||||
// A report for a question not yet answered is ignored, and that question's
|
||||
// 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)
|
||||
|
||||
// This would wait on the same lookup were the id not taken.
|
||||
@@ -451,7 +451,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
|
||||
|
||||
select {
|
||||
case got := <-answered:
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
case <-time.After(waitLimit):
|
||||
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.
|
||||
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
|
||||
got = s.report(servewaf.Report{RequestID: id, ResponseBytes: answerBytes})
|
||||
wantStatus(t, got, http.StatusNoContent)
|
||||
|
||||
if history := s.history(); history.ResponseBytes != answerBytes {
|
||||
@@ -484,13 +484,13 @@ func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) {
|
||||
s := startServeWAF(t, "", nil)
|
||||
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Del(proxy.PeerHeader)
|
||||
req.Header.Del(servewaf.PeerHeader)
|
||||
|
||||
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" {
|
||||
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.Header.Set(proxy.ContentLengthHeader, announced)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
|
||||
req.Header.Set(servewaf.ContentLengthHeader, announced)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
if line := s.out.requestLine(t); line.ContentLength != 0 {
|
||||
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)
|
||||
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Add(proxy.ClientHeader, otherClient)
|
||||
req.Header.Add(proxy.PeerHeader, "172.18.0.3")
|
||||
req.Header.Add(servewaf.ClientHeader, otherClient)
|
||||
req.Header.Add(servewaf.PeerHeader, "172.18.0.3")
|
||||
req.Header.Set(requestIDHeader, "first")
|
||||
req.Header.Add(requestIDHeader, "second")
|
||||
req.Header.Set(proxy.ContentLengthHeader, "1234")
|
||||
req.Header.Add(proxy.ContentLengthHeader, "5678")
|
||||
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "1234")
|
||||
req.Header.Add(servewaf.ContentLengthHeader, "5678")
|
||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
if line.ClientIP != client || line.PeerIP != rproxyPeer ||
|
||||
@@ -562,7 +562,7 @@ func sendQuestionWithPartOfItsBody(t *testing.T, conn net.Conn) {
|
||||
_, err := fmt.Fprintf(conn,
|
||||
"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",
|
||||
siteA, proxy.ClientHeader, client, requestIDHeader)
|
||||
siteA, servewaf.ClientHeader, client, requestIDHeader)
|
||||
if err != nil {
|
||||
t.Fatalf("send the question: %v", err)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
sendQuestionWithPartOfItsBody(t, conn)
|
||||
@@ -615,7 +615,7 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
|
||||
// The id was not taken.
|
||||
again := s.question(siteA, client, "/")
|
||||
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, "/")
|
||||
wantDisposition(t, got, status, proxy.DispositionDeny)
|
||||
wantDisposition(t, got, status, servewaf.DispositionDeny)
|
||||
wantBody(t, got, http.StatusText(status)+"\n")
|
||||
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
|
||||
})
|
||||
@@ -647,7 +647,7 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
|
||||
})
|
||||
|
||||
got := s.ask(client, "/")
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionClose)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionClose)
|
||||
wantBody(t, got, "")
|
||||
// The visitor is sent nothing.
|
||||
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
|
||||
@@ -664,12 +664,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
|
||||
})
|
||||
|
||||
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,
|
||||
requestlog.ActionRuleBlocked)
|
||||
|
||||
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,
|
||||
requestlog.ActionBanned)
|
||||
}
|
||||
@@ -681,15 +681,17 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
|
||||
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)
|
||||
wantDisposition(t, got, http.StatusNotFound, proxy.DispositionDeny)
|
||||
wantDisposition(t, got, http.StatusNotFound, servewaf.DispositionDeny)
|
||||
}
|
||||
|
||||
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
|
||||
|
||||
got := s.ask(otherClient, proxy.MetricsPath)
|
||||
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||
got := s.ask(otherClient, servewaf.MetricsPath)
|
||||
wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
|
||||
}
|
||||
|
||||
func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
|
||||
@@ -698,7 +700,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
|
||||
s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client})
|
||||
|
||||
got := s.ask(client, "/")
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
@@ -714,7 +716,7 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
|
||||
|
||||
for range 2 {
|
||||
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)
|
||||
@@ -733,7 +735,7 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
|
||||
|
||||
for range 2 {
|
||||
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)
|
||||
@@ -762,7 +764,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
|
||||
})
|
||||
|
||||
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")
|
||||
if asn != tc.wantASN || country != tc.wantCountry {
|
||||
@@ -787,7 +789,7 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
|
||||
}
|
||||
|
||||
// 100 bytes are over the limit of 99.
|
||||
got := s.report(proxy.Report{
|
||||
got := s.report(servewaf.Report{
|
||||
RequestID: id, Status: http.StatusBadGateway,
|
||||
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||
})
|
||||
@@ -811,7 +813,7 @@ func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
|
||||
}
|
||||
|
||||
got = s.ask(client, "/")
|
||||
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||
wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
|
||||
}
|
||||
|
||||
func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
|
||||
@@ -825,10 +827,10 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
|
||||
})
|
||||
|
||||
s.passedOn(fromKP)
|
||||
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
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) {
|
||||
@@ -846,7 +848,7 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing
|
||||
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,
|
||||
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||
})
|
||||
@@ -874,7 +876,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
|
||||
s.allowed(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()
|
||||
if len(counters) != 1 || counters[0].Minute.Current != 1 {
|
||||
@@ -891,7 +893,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
|
||||
|
||||
wantMetric(t, s.metrics(), series, 1)
|
||||
|
||||
got := s.report(proxy.Report{
|
||||
got := s.report(servewaf.Report{
|
||||
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
|
||||
ResponseBytes: 1000, DurationTotal: 2,
|
||||
})
|
||||
@@ -924,7 +926,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
|
||||
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`,
|
||||
1) // the scrape itself
|
||||
|
||||
got := s.report(proxy.Report{
|
||||
got := s.report(servewaf.Report{
|
||||
RequestID: id, Status: http.StatusBadGateway,
|
||||
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||
DurationTotal: 5, DurationUpstreamTotal: 4,
|
||||
@@ -942,7 +944,7 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
|
||||
// check needs a server that has not been scraped yet.
|
||||
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,
|
||||
})
|
||||
wantStatus(t, got, http.StatusNoContent)
|
||||
@@ -955,7 +957,7 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
id := s.allowed(client)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1160,7 +1162,7 @@ func TestBytesAtTheirMostEachWayBanRatherThanOverflow(t *testing.T) {
|
||||
|
||||
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,
|
||||
RequestBytes: reportedBytesLimit, ResponseBytes: reportedBytesLimit,
|
||||
})
|
||||
@@ -1245,9 +1247,9 @@ func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
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,
|
||||
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||
DurationTotal: 12.5, DurationUpstreamTotal: 10.25,
|
||||
@@ -1294,7 +1296,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
|
||||
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
|
||||
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.Parallel()
|
||||
@@ -1305,7 +1307,7 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
|
||||
id := req.Header.Get(requestIDHeader)
|
||||
do(t, req)
|
||||
|
||||
got := s.report(proxy.Report{
|
||||
got := s.report(servewaf.Report{
|
||||
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3,
|
||||
})
|
||||
wantStatus(t, got, http.StatusNoContent)
|
||||
@@ -1341,18 +1343,18 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) {
|
||||
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.
|
||||
got := s.report(proxy.Report{RequestID: oldest, ResponseBytes: answerBytes})
|
||||
got := s.report(servewaf.Report{RequestID: oldest, ResponseBytes: answerBytes})
|
||||
wantStatus(t, got, http.StatusNoContent)
|
||||
|
||||
if counted := s.history().Requests; counted != 0 {
|
||||
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)
|
||||
|
||||
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)})
|
||||
|
||||
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, "/"))
|
||||
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||
wantDisposition(t, got, http.StatusForbidden, servewaf.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
if lines[0].Host != siteA || lines[1].Host != siteB {
|
||||
@@ -1395,7 +1397,7 @@ func TestRequestsOnEverySiteCountTowardOneSetOfLimits(t *testing.T) {
|
||||
s.allowed(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) {
|
||||
@@ -1410,13 +1412,13 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
|
||||
path, authorization string
|
||||
want int
|
||||
}{
|
||||
{proxy.HealthPath, "", http.StatusOK},
|
||||
{proxy.MetricsPath, bearer, http.StatusOK},
|
||||
{proxy.MetricsPath, "", http.StatusUnauthorized},
|
||||
{proxy.BansPath, adminBearer, http.StatusOK},
|
||||
{proxy.BansPath, "", http.StatusUnauthorized},
|
||||
{proxy.ClientsPath + client, adminBearer, http.StatusOK},
|
||||
{proxy.ReportPath, "", http.StatusNotFound},
|
||||
{servewaf.HealthPath, "", http.StatusOK},
|
||||
{servewaf.MetricsPath, bearer, http.StatusOK},
|
||||
{servewaf.MetricsPath, "", http.StatusUnauthorized},
|
||||
{servewaf.BansPath, adminBearer, http.StatusOK},
|
||||
{servewaf.BansPath, "", http.StatusUnauthorized},
|
||||
{servewaf.ClientsPath + client, adminBearer, http.StatusOK},
|
||||
{servewaf.ReportPath, "", http.StatusNotFound},
|
||||
{"/_smallwebwaf/other", "", http.StatusNotFound},
|
||||
{"/", "", http.StatusNotFound},
|
||||
} {
|
||||
@@ -1429,9 +1431,9 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
|
||||
req.Header.Set("Authorization", tc.authorization)
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1456,12 +1459,12 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
|
||||
|
||||
req := s.questionWith(http.MethodPost, siteA, client, "/",
|
||||
strings.NewReader("a body"))
|
||||
req.Header.Set(proxy.ContentLengthHeader, "1073741824")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "1073741824")
|
||||
req.Header.Set(forwardedFor, otherClient)
|
||||
req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
|
||||
|
||||
got := do(t, req)
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
if line.ClientIP != client || line.ContentLength != 1<<30 {
|
||||
@@ -1529,7 +1532,7 @@ carried header:Content-Length block ^6$
|
||||
strings.NewReader("a body"))
|
||||
|
||||
if tc.announced != "" {
|
||||
req.Header.Set(proxy.ContentLengthHeader, tc.announced)
|
||||
req.Header.Set(servewaf.ContentLengthHeader, tc.announced)
|
||||
}
|
||||
|
||||
wantStatus(t, do(t, req), tc.want)
|
||||
@@ -1547,19 +1550,19 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
|
||||
req := s.questionWith(http.MethodPost, siteA, client, "/",
|
||||
strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set(proxy.ContentLengthHeader, "100000")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "100000")
|
||||
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
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
|
||||
// attack, so the Core Rule Set does not read it.
|
||||
cut := `{"text":"` + strings.Repeat("x", proxy.QuestionBodyMaxBytes)
|
||||
got = ask("application/json", cut[:proxy.QuestionBodyMaxBytes])
|
||||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||
cut := `{"text":"` + strings.Repeat("x", servewaf.QuestionBodyMaxBytes)
|
||||
got = ask("application/json", cut[:servewaf.QuestionBodyMaxBytes])
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
|
||||
@@ -1568,7 +1571,7 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||
|
||||
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]
|
||||
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})
|
||||
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]
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -104,6 +104,15 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
|
||||
}
|
||||
|
||||
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic trap_paths. It reads SWWAF_TRAP_PATHS.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// trapPaths refuses a request for a path in SWWAF_TRAP_PATHS: its path as a
|
||||
// path rule sees it, without the query, as sent or once percent-decoded, is one
|
||||
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
|
||||
// it bans the client's netblock, or in observe mode raises the alert for the
|
||||
// ban it would have made. The ban's notes give the path as sent, and so does
|
||||
// its figure.
|
||||
type trapPaths struct{}
|
||||
|
||||
func (trapPaths) name() string { return "trap_paths" }
|
||||
|
||||
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
path := rules.Path(rq.in)
|
||||
if !slices.Contains(rq.h.config.TrapPaths, path) &&
|
||||
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionBanned,
|
||||
figures: map[string]any{"path": path},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package proxy_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -83,6 +83,16 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
||||
|
||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
||||
requestlog.ActionBanned)
|
||||
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
||||
}
|
||||
|
||||
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// healthCheckTimeout bounds the whole health check.
|
||||
@@ -21,11 +21,13 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
|
||||
|
||||
// HealthCheck is the container's health check. In servewaf's container, where
|
||||
// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health
|
||||
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In any other container
|
||||
// it returns 0 while rproxy answers its health endpoint on 127.0.0.1, at the port
|
||||
// in SWRP_LISTEN_ADDR, and the app accepts connections at the address in
|
||||
// SWRP_UPSTREAM_URL. Otherwise it writes why to stderr and returns 1. It reads no
|
||||
// other setting, nor a file that another names, so neither can fail it.
|
||||
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container,
|
||||
// where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
|
||||
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
|
||||
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
|
||||
// any other value of SMALLWEBWAF_COMMAND included, it writes why to stderr and
|
||||
// returns 1. It reads no other setting, nor a file that another names, so neither
|
||||
// can fail it.
|
||||
// args are the arguments after `healthcheck`; it takes none, and given
|
||||
// one it names it on stderr and returns 1 without checking anything.
|
||||
func HealthCheck(
|
||||
@@ -42,12 +44,17 @@ func HealthCheck(
|
||||
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
check := checkRProxyAndApp
|
||||
if command, _ := lookupEnv("SMALLWEBWAF_COMMAND"); command == "servewaf" {
|
||||
check = checkServeWAF
|
||||
command, err := config.Command(lookupEnv)
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("invalid setting: %w", err)
|
||||
case command == "servewaf":
|
||||
err = checkServeWAF(ctx, lookupEnv)
|
||||
default:
|
||||
err = checkRProxyAndApp(ctx, lookupEnv)
|
||||
}
|
||||
|
||||
err := check(ctx, lookupEnv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "unhealthy:", err)
|
||||
|
||||
@@ -100,7 +107,7 @@ func checkRProxyAndApp(
|
||||
// listenAddr, which the settings have checked has one.
|
||||
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
|
||||
_, 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)
|
||||
if err != nil {
|
||||
|
||||
@@ -103,8 +103,13 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
||||
}
|
||||
|
||||
wantHealthCheck(t, settings(), 0, "")
|
||||
wantHealthCheck(t, settings(commandSetting, ""), 0, "")
|
||||
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
|
||||
|
||||
// SMALLWEBWAF_COMMAND cannot be given as a file.
|
||||
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")),
|
||||
0, "")
|
||||
|
||||
// Another setting does not fail it, even an invalid one, and neither does
|
||||
// SWRP_WAF_BACKEND being unset.
|
||||
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
|
||||
@@ -127,6 +132,14 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
||||
}, 1, "unhealthy: ask rproxy: ")
|
||||
}
|
||||
|
||||
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
|
||||
"unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+
|
||||
"\"healthcheck\" is not rproxy or servewaf\n")
|
||||
}
|
||||
|
||||
func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"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.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
|
||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||
|
||||
line := out.line(t, "path", "/after-stop")
|
||||
if line["disposition"] != proxy.DispositionAllow ||
|
||||
if line["disposition"] != servewaf.DispositionAllow ||
|
||||
line["default_disposition"] != true {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
|
||||
const smuggledID = "smuggled-request"
|
||||
|
||||
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"
|
||||
|
||||
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)
|
||||
backend := httptest.NewUnstartedServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
protocols <- r.Proto
|
||||
|
||||
w.Header().Set(proxy.DispositionHeader, proxy.DispositionDeny)
|
||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionDeny)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
}))
|
||||
@@ -532,8 +532,8 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow)
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -640,7 +640,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow)
|
||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
|
||||
}))
|
||||
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")
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"http://"+addr+proxy.HealthPath, http.NoBody)
|
||||
"http://"+addr+servewaf.HealthPath, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestMainRunsServeWAF(t *testing.T) {
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
req.Header.Set(proxy.ClientHeader, placed)
|
||||
req.Header.Set(servewaf.ClientHeader, placed)
|
||||
req.Header.Set("X-Request-ID", rand.Text())
|
||||
|
||||
transport := &http.Transport{}
|
||||
@@ -147,9 +147,9 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
|
||||
_ = res.Body.Close()
|
||||
|
||||
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,
|
||||
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) {
|
||||
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
|
||||
ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow)
|
||||
// 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) {
|
||||
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
|
||||
ask(t, addr, "/", http.StatusForbidden, servewaf.DispositionDeny)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
|
||||
func newServer(
|
||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||
) (*proxy.Server, error) {
|
||||
) (*servewaf.Server, error) {
|
||||
var lookupFile *lookup.File
|
||||
|
||||
if cfg.LookupSource == "file" {
|
||||
@@ -207,7 +207,7 @@ func newServer(
|
||||
}
|
||||
}
|
||||
|
||||
server := proxy.NewServeWAF(proxy.Params{
|
||||
server := servewaf.New(servewaf.Params{
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
@@ -230,7 +230,7 @@ func newServer(
|
||||
// loadStateFiles reads the state files into the parts of server and into
|
||||
// alertQueue, as state.Load does.
|
||||
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,
|
||||
) (*state.Files, error) {
|
||||
return state.Load(state.Params{
|
||||
@@ -316,7 +316,7 @@ func startSending(
|
||||
// shutdownTimeout to finish, and writes every state file, alerts.json with
|
||||
// the alerts still waiting.
|
||||
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,
|
||||
processLog *slog.Logger,
|
||||
) int {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -1225,9 +1225,9 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "120",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "1500",
|
||||
rateLimitPerDay: "6000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_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(proxy.ClientHeader, from)
|
||||
req.Header.Set(servewaf.ClientHeader, from)
|
||||
req.Header.Set("X-Request-ID", rand.Text())
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/bin/sh
|
||||
# script/gitea-setup: build the image, bring up deploy/gitea under a project
|
||||
# name of its own, and check it:
|
||||
# - that a page load, a push and a clone sent through traefik pass through
|
||||
# rproxy, and that servewaf checks them;
|
||||
# - that gitea logs the visitor's address, not rproxy's;
|
||||
# - that a request from a container on the shared network skips the checks;
|
||||
# - that with servewaf stopped, gitea still answers through rproxy.
|
||||
# It publishes no port of traefik's, since this host's ports 80 and 443 may be
|
||||
# taken. It reaches traefik at its address on the shared network instead, so
|
||||
# its own requests come from the network's gateway, which servewaf checks like
|
||||
# any visitor. Two runs at once on one docker host cannot both start, since
|
||||
# both would use the subnet compose.yaml names. The containers, the network,
|
||||
# the volumes, the image and the files the script makes are removed however it
|
||||
# ends; the gitea and traefik images stay, as the base images of a build do.
|
||||
# It needs network access. script/check does not run this.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
# Named after this run, so that runs in other clones on the same host
|
||||
# never touch each other's.
|
||||
NAME="$("$SCRIPT_DIR/projectname")-gitea-setup-$$"
|
||||
IMAGE="$NAME-image"
|
||||
NETWORK="${NAME}_shared"
|
||||
VISITOR="$NAME-visitor"
|
||||
|
||||
# The site's host name and gitea's image, as deploy/gitea/compose.yaml names
|
||||
# them. gitea's image also runs the container on the shared network.
|
||||
SITE="git.example.org"
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
|
||||
|
||||
# gitea's one user, who owns the repository.
|
||||
PASSWORD="owner-password"
|
||||
|
||||
# The files the script makes, gitea's secret key for this run, and traefik's
|
||||
# and the gateway's addresses on the shared network once the setup runs.
|
||||
WORK=""
|
||||
secret_key=""
|
||||
traefik=""
|
||||
gateway=""
|
||||
|
||||
cleanup() {
|
||||
# So that a signal cannot stop it partway through.
|
||||
trap '' HUP INT TERM
|
||||
docker rm --force "$VISITOR" >/dev/null 2>&1 || true
|
||||
if [ -n "$WORK" ]; then
|
||||
compose down --volumes --remove-orphans --timeout 5 >/dev/null 2>&1 || true
|
||||
rm -rf "$WORK"
|
||||
fi
|
||||
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# compose <argument>...: docker compose on the setup, under this run's project
|
||||
# name, with the image built here, this run's secret key and no port of
|
||||
# traefik's published.
|
||||
compose() {
|
||||
SMALLWEBWAF_IMAGE="$IMAGE" GITEA_SECRET_KEY="$secret_key" \
|
||||
docker compose --project-name "$NAME" \
|
||||
--file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "gitea-setup: $*; the last lines of each container's output:" >&2
|
||||
compose logs --no-color --tail 30 >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# wait_for <what fails> <command>...: run the command every second until
|
||||
# it succeeds, for at most a minute.
|
||||
wait_for() {
|
||||
failure="$1"
|
||||
shift
|
||||
tries=0
|
||||
until "$@"; do
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -lt 60 ] || fail "$failure"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# logged <service> <text>...: a line of the service's output holds every text,
|
||||
# in any order.
|
||||
logged() {
|
||||
lines="$(compose logs --no-color --no-log-prefix "$1" 2>&1)"
|
||||
shift
|
||||
for text in "$@"; do
|
||||
lines="$(printf '%s\n' "$lines" | grep -F -e "$text")" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# send <path> <curl option>...: send a request for path to the site, at
|
||||
# traefik's address, and print the status of the answer, 000 for none. traefik
|
||||
# answers with a certificate it made itself, since the setup names no
|
||||
# certificate resolver.
|
||||
send() {
|
||||
path="$1"
|
||||
shift
|
||||
curl --silent --show-error --insecure --max-time 30 --output /dev/null \
|
||||
--write-out '%{http_code}' --resolve "$SITE:443:$traefik" "$@" \
|
||||
"https://$SITE$path" || true
|
||||
}
|
||||
|
||||
# routed: traefik passes a request for the site's front page on to gitea.
|
||||
routed() {
|
||||
[ "$(send / 2>/dev/null)" = 200 ]
|
||||
}
|
||||
|
||||
# site_git <git argument>...: run git as the owner, without the host's own git
|
||||
# settings, reaching the site at traefik's address. git gives up on a transfer
|
||||
# slower than 1000 bytes a second for 30 seconds.
|
||||
site_git() {
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
|
||||
-c user.name=owner -c user.email=owner@example.invalid \
|
||||
-c http.sslVerify=false -c "http.curloptResolve=$SITE:443:$traefik" \
|
||||
-c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 "$@"
|
||||
}
|
||||
|
||||
# address_of <service>: print the service's address on the shared network.
|
||||
address_of() {
|
||||
docker inspect --format "{{(index .NetworkSettings.Networks \"$NETWORK\").IPAddress}}" \
|
||||
"$(compose ps --quiet "$1")"
|
||||
}
|
||||
|
||||
# passed_through <method> <path>: rproxy passed a request from the gateway for
|
||||
# path to gitea on servewaf's allow, and servewaf checked it: its report line
|
||||
# gives the client's counts and the Core Rule Set's score.
|
||||
passed_through() {
|
||||
wait_for "rproxy logged no $1 $2 that servewaf allowed" logged rproxy \
|
||||
"\"method\":\"$1\"" "\"path\":\"$2\"" "\"client_ip\":\"$gateway\"" \
|
||||
'"disposition":"allow"'
|
||||
wait_for "servewaf logged no check of $1 $2" logged servewaf \
|
||||
'"servewaf_line":"report"' "\"method\":\"$1\"" "\"path\":\"$2\"" \
|
||||
"\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":'
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
trap cleanup EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
WORK="$(mktemp -d)"
|
||||
printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml"
|
||||
|
||||
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.
|
||||
compose up --detach --quiet-pull --wait --wait-timeout 120 ||
|
||||
fail "the setup did not start"
|
||||
traefik="$(address_of traefik)"
|
||||
gateway="$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "$NETWORK")"
|
||||
wait_for "traefik did not route the site to rproxy" routed
|
||||
|
||||
compose exec --user git gitea gitea admin user create --admin \
|
||||
--username owner --password "$PASSWORD" --email owner@example.invalid \
|
||||
--must-change-password=false >/dev/null || fail "creating gitea's user failed"
|
||||
status="$(send /api/v1/user/repos --user "owner:$PASSWORD" \
|
||||
--header "Content-Type: application/json" --data '{"name":"repo"}')"
|
||||
[ "$status" = 201 ] || fail "creating the repository was answered $status"
|
||||
|
||||
status="$(send /owner/repo)"
|
||||
[ "$status" = 200 ] || fail "the repository's page was answered $status"
|
||||
passed_through GET /owner/repo
|
||||
echo "gitea-setup: a page load passes through rproxy, and servewaf checks it"
|
||||
|
||||
wait_for "gitea did not log the visitor's address" logged gitea \
|
||||
"GET /owner/repo for $gateway:"
|
||||
echo "gitea-setup: gitea logs the visitor's address, not rproxy's"
|
||||
|
||||
site_git init --quiet --initial-branch=main "$WORK/repo"
|
||||
echo "hello" >"$WORK/repo/README.md"
|
||||
site_git -C "$WORK/repo" add README.md
|
||||
site_git -C "$WORK/repo" commit --quiet --message "Add a README"
|
||||
site_git -C "$WORK/repo" push --quiet \
|
||||
"https://owner:$PASSWORD@$SITE/owner/repo.git" main ||
|
||||
fail "the push failed"
|
||||
passed_through POST /owner/repo.git/git-receive-pack
|
||||
echo "gitea-setup: a push passes through rproxy, and servewaf checks it"
|
||||
|
||||
site_git clone --quiet "https://owner:$PASSWORD@$SITE/owner/repo.git" \
|
||||
"$WORK/clone" || fail "the clone failed"
|
||||
[ "$(cat "$WORK/clone/README.md")" = hello ] || fail "the clone lacks the README"
|
||||
passed_through POST /owner/repo.git/git-upload-pack
|
||||
echo "gitea-setup: a clone passes through rproxy, and servewaf checks it"
|
||||
|
||||
# A container on the shared network, as sandcastle is, asks for the site
|
||||
# by its name, and so reaches traefik from its own address.
|
||||
status="$(docker run --rm --name "$VISITOR" --network "$NETWORK" \
|
||||
--entrypoint curl "$GITEA_IMAGE" --silent --insecure --max-time 30 \
|
||||
--output /dev/null --write-out '%{http_code}' "https://$SITE/api/v1/version" ||
|
||||
true)"
|
||||
[ "$status" = 200 ] || fail "the request from the shared network was answered $status"
|
||||
wait_for "servewaf logged no request from the shared network" logged servewaf \
|
||||
'"servewaf_line":"report"' '"path":"/api/v1/version"' '"action":"forward"'
|
||||
line="$(compose logs --no-color --no-log-prefix servewaf |
|
||||
grep -F '"servewaf_line":"report"' | grep -F '"path":"/api/v1/version"')"
|
||||
case "$line" in
|
||||
*"\"client_ip\":\"$gateway\""*)
|
||||
fail "the request from the shared network came from the gateway" ;;
|
||||
*'"counts":'* | *'"waf_score":'*)
|
||||
fail "servewaf checked the request from the shared network" ;;
|
||||
esac
|
||||
echo "gitea-setup: a request from the shared network skips the checks"
|
||||
|
||||
compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop"
|
||||
status="$(send /owner/repo)"
|
||||
[ "$status" = 200 ] ||
|
||||
fail "with servewaf stopped, the repository's page was answered $status"
|
||||
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
|
||||
logged rproxy '"path":"/owner/repo"' '"default_disposition":true'
|
||||
echo "gitea-setup: with servewaf stopped, gitea answers through rproxy"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+12
-5
@@ -3,14 +3,21 @@ set -euo pipefail
|
||||
|
||||
# runit's run script for smallwebwaf, run again whenever smallwebwaf
|
||||
# exits; the wait spaces out the restarts. SMALLWEBWAF_COMMAND names the
|
||||
# command to run: rproxy when it is unset, or servewaf. exec, so that the
|
||||
# signal `sv stop` sends reaches smallwebwaf itself.
|
||||
# command to run: rproxy when it is unset or empty, or servewaf. With any
|
||||
# other value the script starts nothing and stops with a message naming it.
|
||||
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
|
||||
main() {
|
||||
sleep 1
|
||||
local command="${SMALLWEBWAF_COMMAND:-rproxy}"
|
||||
if [[ "$command" == servewaf ]]; then
|
||||
give_state_dir_to_smallwebwaf
|
||||
fi
|
||||
case "$command" in
|
||||
rproxy) ;;
|
||||
servewaf) give_state_dir_to_smallwebwaf ;;
|
||||
*)
|
||||
printf 'SMALLWEBWAF_COMMAND: "%s" is not rproxy or servewaf\n' \
|
||||
"$command" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf "$command"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user