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

`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 size and
time limits. It reports each request servewaf answered. SWRP_DEFAULT_DISPOSITION
decides when servewaf gives no answer. A request it cannot put to servewaf as
sent, it refuses itself.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 on servewaf's URL, a setting.
Rule suppressed: gosec G705 on the answer written to the visitor.
Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not.
Judgement call: every target not starting with / gets 400, not only x:admin.

Model: opus-5-5
This commit is contained in:
2026-11-05 01:12:42 +00:00
parent 2eeea136e8
commit 3490284e16
23 changed files with 5005 additions and 57 deletions
+181 -20
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,17 @@ 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.
A body whose length the visitor did not announce comes chunked. Any other body
comes with a `Content-Length` of the part it carries.
The path always starts with `/`, and `Host` is never empty. `rproxy` refuses any
other request itself (see "Requests `rproxy` refuses itself").
A body whose length the visitor did not announce comes chunked, whatever the
method. 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
@@ -1223,6 +1234,157 @@ 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` is down, slow
or overloaded, the site keeps 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 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 `Transfer-Encoding` or `Trailer`, which
Go's HTTP server and client handle themselves.
7. It does what the answer says (see "The answer"):
- `allow`: it passes the request to the app.
- `deny`: the visitor gets the answer's status, headers and body, less
`X-Smallwebwaf-Disposition`.
- `close`: it closes the visitor's connection without an answer.
8. Once the answer has ended, it writes its log line. `docs/request-log.md`
gives the fields.
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
How a visitor sends a request never lets it past `servewaf`.
`SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` itself is down, slow or
answers badly.
`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`;
- no `Host`, or an empty one.
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 when
`servewaf`:
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
question and reading the whole answer;
- cannot be reached;
- answers without a valid `X-Smallwebwaf-Disposition`, as with the `400` for a
question it refuses;
- sends an answer whose body breaks off or is over 1 MiB.
`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.
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.
A WebSocket upgrade passes through. 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 +1552,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