rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Successful in 4m26s

`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over
HTTP/1.1, and passes what servewaf allows to the app within its own limits. Any
other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
decides only when servewaf cannot be connected to, does not answer at all, or
does not answer within SWRP_TIMEOUT_MS. Only the methods SPEC.md lists pass on;
any other gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 100, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
This commit is contained in:
2026-11-05 03:07:45 +00:00
parent 2eeea136e8
commit 33b8d81be1
23 changed files with 5869 additions and 59 deletions
+238 -22
View File
@@ -12,9 +12,9 @@ to them.
- It protects one self-hosted app on the open internet from scrapers, scanners
and other abusers. It does so from the app's first request, with no setting at
all. Every setting has a default chosen for a service facing the internet
in 2026. A setting that needs an alert destination, an account key or a token
stays off until the operator supplies it.
all. Every `SWWAF_` setting has a default chosen for a service facing the
internet in 2026. A setting that needs an alert destination, an account key or
a token stays off until the operator supplies it.
- It limits each client's requests and bytes per minute, hour and day. It bounds
the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
@@ -24,8 +24,9 @@ to them.
reputation sources, and send alerts.
- Everything is set by environment variables whose names start with `SWWAF_`,
since `smallwebwaf` shares its container, and so its environment, with the
app. The only other inputs are files: the rule files, the state files, a
lookup database, and a setting given as a file.
app. `smallwebwaf rproxy` uses `SWRP_` in their place. The only other inputs
are files: the rule files, the state files, a lookup database, and a `SWWAF_`
setting given as a file.
## Non-goals
@@ -48,8 +49,10 @@ to them.
files, the lookup database and the state files, listens, and serves until
`SIGTERM` or `SIGINT`. Then it stops, writing the state files. Run as
`smallwebwaf servewaf`, it answers `rproxy` instead of passing requests to
an app (see "`servewaf`"). Run as `smallwebwaf healthcheck`, it is the
image's health check instead.
an app (see "`servewaf`"). Run as `smallwebwaf rproxy`, it reads only that
command's settings, and runs `internal/rproxy` until `SIGTERM` or `SIGINT`
(see "`rproxy`"). Run as `smallwebwaf healthcheck`, it is the image's
health check instead.
- `internal/config`: reads the settings, the one place they are read.
- `internal/proxy`: what happens to each request. It works out the client
and runs the checks in `check`. It passes the request to the app with the
@@ -57,6 +60,8 @@ to them.
limits, and answers its own endpoints in `answerAdmin`. Once the request
has ended, it counts bytes, refusals and anomalies, and writes the log
line. It also holds `servewaf`'s question, answer and report.
- `internal/rproxy`: the reverse proxy in front of one app that asks
`servewaf` about each request.
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
refusals, and keeps each client's history.
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
@@ -291,17 +296,20 @@ Conventions:
- `SWWAF_WAF_ANOMALY_THRESHOLD`, `SWWAF_WAF_BODY_LIMIT` and
`SWWAF_ERROR_BURST_THRESHOLD`;
- `SWWAF_ALERT_COOLDOWN` and `SWWAF_ALERT_MAX_PER_HOUR`;
- every anomaly threshold.
- every anomaly threshold;
- `SWRP_MAX_REQUEST_SIZE`, `SWRP_MAX_RESPONSE_SIZE`,
`SWRP_MAX_REQUEST_TIME_MS` and `SWRP_MAX_RESPONSE_TIME_MS`.
No other setting can be switched off with `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Any setting can be read from 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, and are checked as
the value itself would be. Setting both forms stops the start, and so does
naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already
holds a file name, so it has no `_FILE` form.
- Any `SWWAF_` setting can be read from 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, and are
checked as the value itself would be. Setting both forms stops the start, and
so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE`
already holds a file name, so it has no `_FILE` form. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- Settings, files included, are read once at start. Changing one means
restarting the container. The files `smallwebwaf` watches while it runs are
its state files, its rule files and the lookup database.
@@ -1051,14 +1059,21 @@ request. It is the visitor's request as the visitor sent it:
- every header the visitor sent, including its own `X-Forwarded-For`;
- the first 64 KiB of the body, or all of it when shorter.
`rproxy` refuses itself any request it cannot send this way (see "Requests
`rproxy` refuses itself"). So a question always has:
- a path starting with one `/`;
- a `Host` that is not empty and holds no IPv6 zone;
- at most one `User-Agent`.
A body whose length the visitor did not announce comes chunked. Any other body
comes with a `Content-Length` of the part it carries.
`rproxy` sets these headers, replacing any the visitor sent, and adds no others:
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out.
`servewaf` takes it as the client, and groups an IPv6 address as "What happens
to a request" says.
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out,
without any IPv6 zone. `servewaf` takes it as the client, and groups an IPv6
address as "What happens to a request" says.
- `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log
line's `peer_ip`.
- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is
@@ -1120,7 +1135,8 @@ Every answer but the last below carries `X-Smallwebwaf-Disposition`.
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
visitor's connection without an answer.
- No such header, status `400`, for a question `servewaf` refuses. `rproxy`
treats this answer as it treats no answer at all.
gives the visitor its status, headers and body, as it does for any answer
without a valid `X-Smallwebwaf-Disposition`.
In `observe` mode a request that `enforce` mode would refuse is answered
`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
@@ -1135,9 +1151,14 @@ short.
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
for each request `servewaf` answered with `allow`, `deny` or `close`. It sends
none after a `400` or no answer. The report carries no `X-Smallwebwaf-Client`.
Its body is one JSON object with all of these fields, named as the request log's
fields of the same meaning:
none after:
- an answer without a valid `X-Smallwebwaf-Disposition`, such as a `400`;
- an answer it cannot pass on (see "Without an answer from `servewaf`");
- no answer.
The report carries no `X-Smallwebwaf-Client`. Its body is one JSON object with
all of these fields, named as the request log's fields of the same meaning:
- `request_id`: the question's `X-Request-ID`;
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
@@ -1223,6 +1244,202 @@ by a request and ban notes name the site by its host.
`docs/metrics.md` says how its metrics count.
## `rproxy`
`smallwebwaf rproxy` is the reverse proxy in front of one app. It asks
`servewaf` about each request, and passes on what `servewaf` allows. It keeps
nothing between requests and never touches disk. When `servewaf` cannot be
connected to, does not answer at all, or does not answer within
`SWRP_TIMEOUT_MS`, `SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`,
keeps the site answering.
`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of
them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no
rule files and no state files, and it writes its lines to stdout only.
The image's run script and health check are those of `smallwebwaf` with no
command (see "Deployment"). A container that runs `rproxy` names its own command
and health check.
### What `rproxy` does with a request
1. `GET /_smallwebwaf/healthz` is answered `ok` at once, without asking
`servewaf`.
2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only
from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a
request" says. It removes any IPv6 zone.
3. It makes a fresh request id, whatever id the request came with. `servewaf`
and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it.
4. It refuses a request with a method it does not pass on, or one it cannot put
to `servewaf` as the visitor sent it (see "Requests `rproxy` refuses
itself").
5. It reads up to the first 64 KiB of the body into memory.
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to
send a visitor's `Upgrade` header, and most `Connection` headers. The
question never carries the visitor's `Trailer`.
7. It does what the answer says (see "The answer"):
- `allow`: it passes the request to the app.
- `close`: it closes the visitor's connection without an answer.
- Any other answer: the visitor gets its status, headers and body, less
`X-Smallwebwaf-Disposition`. That holds for a `deny`, for an error such as
`servewaf`'s `400` or a `5xx`, and for an answer without a valid
`X-Smallwebwaf-Disposition`.
8. Once the answer has ended, it writes its log line. `docs/request-log.md`
gives the fields. An app's answer can end just after the line: Go's server
sends the closing chunk of a chunked answer, and may send all of an empty
one, only once `rproxy` has finished with the request.
9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close`
(see "The report"). The visitor does not wait for it. A report that fails is
dropped, with a process warning. A status above `599` from the app goes to
the visitor as it is, and into the report as `599`, the highest `servewaf`
takes.
### Requests `rproxy` refuses itself
`SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` cannot be connected to,
does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`. A visitor
can make `servewaf` slow. A request that keeps `servewaf` busy past
`SWRP_TIMEOUT_MS`, like any request while `servewaf` is overloaded, gets
`SWRP_DEFAULT_DISPOSITION`. `deny` closes this, at the cost of answering `503`
whenever `servewaf` is slow. Making such requests hard to send is
https://git.eeqj.de/sneak/smallwebwaf/issues/182.
`rproxy` passes on only these methods:
- `GET`
- `HEAD`
- `POST`
- `OPTIONS`
- `PUT`
- `DELETE`
- `PATCH`
- `QUERY`
It answers any other method `405`, with no question and no report. The answer's
`Allow` header names these methods.
`rproxy` answers `400`, with no question and no report, to a request with:
- a target that does not start with `/`, such as `x:admin`,
`http://site.example/` or `site.example:443`;
- a target that starts with `//`;
- no `Host`, or an empty one;
- a `Host` holding an IPv6 zone: one that starts with `[` and holds a `%`, such
as `[fe80::1%25eth0]:80`;
- more than one `User-Agent`.
Go's HTTP client could not send any of these as the visitor sent it.
Go's HTTP server answers `OPTIONS *` itself, before `rproxy` sees it.
`rproxy` closes the connection without an answer when it cannot read the first
64 KiB of the body. That happens for a malformed chunk, and for a visitor that
goes away or stops its sending side. There is no question and no report, and the
log line has status `0`.
### Without an answer from `servewaf`
`SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when
`servewaf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
question and reading the whole answer.
An answer whose body breaks off counts as no answer at all.
`allow`, the default, passes the request to the app. `deny` answers `503`.
Either way `rproxy` writes a process warning, its log line has
`default_disposition`, and it sends no report.
A path under `/_smallwebwaf/` that `allow` would pass on is answered `404`
instead, so that the app never sees that prefix.
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
below `100`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
writes a process warning, its log line has no `disposition`, and it sends no
report.
A visitor that goes away while `servewaf` is being asked is no failure of
`servewaf`. `rproxy` closes the connection, with no warning, no default and no
report. Its log line has status `0` and no `disposition`.
### Passing a request to the app
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
the body it holds, then the rest, streaming. The app's answer comes back the
same way. The request reaches the app unchanged but for these headers:
- `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that
peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass
on.
- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own
claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host`
the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers
in which traefik tells an app about a visitor are removed.
- `X-Request-ID`, which carries the fresh id.
- `X-Client-ASN` and `X-Client-Country`, which carry what `servewaf` answered. A
header it did not give is left out. Any of the two the visitor sent is
removed.
- The headers that concern only the connection to `rproxy` never reach the app:
- `Connection`, and any header it names;
- `Keep-Alive`;
- `Proxy-Authenticate`;
- `Proxy-Authorization`;
- `Proxy-Connection`;
- `Te`, though one holding `trailers` goes on as `Te: trailers`;
- `Trailer`;
- `Upgrade`.
A WebSocket upgrade passes through, with `Connection: Upgrade` and its
`Upgrade`. The report of a WebSocket comes when it closes, with what it carried
each way.
### `rproxy`'s limits
The limits work as "Size and time limits" says, with these settings in place of
those:
- `SWRP_MAX_REQUEST_SIZE` for `SWWAF_REQUEST_MAX_BYTES`.
- `SWRP_MAX_RESPONSE_SIZE` for `SWWAF_RESPONSE_MAX_BYTES`.
- `SWRP_MAX_REQUEST_TIME_MS` for `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`.
- `SWRP_MAX_RESPONSE_TIME_MS` for `SWWAF_CLIENT_RESPONSE_TIMEOUT` and
`SWWAF_UPSTREAM_RESPONSE_TIMEOUT`.
The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting.
`SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their end,
it then bounds the time until the app has been sent the whole request. The wait
for `servewaf` counts toward it. A visitor too slow to send the first 64 KiB is
answered `408`, with no question and no report.
`SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte of
the answer reaching the visitor. The request ends when the app has been sent all
of it, or when the app's answer arrives if that is sooner. An answer `rproxy`
gives itself has the whole time from when it starts.
`rproxy` applies the request size limit only after `servewaf` has allowed the
request. So a request `servewaf` denies gets `servewaf`'s answer, whatever its
size. A request `servewaf` allows is answered `413` when its body is over the
limit: at once for a length it announced, otherwise when the limit is reached.
If part of the body has gone to the app by then, the request to the app is cut
off.
### Starting and stopping
An invalid setting, or an address it cannot listen on, stops the start with a
process line. The process lines give the host's name as `instance`, and only
those at `info` or more severe are written.
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
progress five seconds to finish. It then waits for the reports on their way,
each for up to `SWRP_TIMEOUT_MS`. A report that comes due after that is dropped,
such as that of a WebSocket that closes later.
## Deployment
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
@@ -1390,7 +1607,6 @@ and `script/gitea-requests` checks those notes.
These points are specified as later work, and nothing in the code does them yet:
- Planned: `smallwebwaf rproxy`, the reverse proxy in front of each app.
- Planned: sharing bans between the instances of several apps, by running one
CrowdSec engine per host that every instance reports its bans to. It is
reconsidered once real ban volumes are known. Until then each instance keeps