rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Successful in 7m37s
check / check (push) Successful in 7m37s
`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 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. A method SPEC.md does not list gets 405. Duplication: internal/rproxy copies passing-on code from internal/proxy until #172 removes the originals. Rule suppressed: gosec G704 and G705; gosec at internal/smallwebwaf/rproxy_test.go:431; tagliatelle at internal/requestlog/requestlog.go:201; paralleltest on one test. Judgement call: an answer over 1 MiB, or with a status below 200, gets 502. Judgement call: every target not starting with one / gets 400. Model: opus-5-5
This commit is contained in:
@@ -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,27 @@ 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.
|
||||
|
||||
Go's HTTP code changes some of the visitor's headers, such as:
|
||||
|
||||
- Go's client does not send an empty `User-Agent`.
|
||||
- Go's server adds `Cache-Control: no-cache` to a request with
|
||||
`Pragma: no-cache` and no `Cache-Control`, before `rproxy` sees it.
|
||||
|
||||
`rproxy` itself refuses 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
|
||||
@@ -1122,7 +1143,8 @@ 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
|
||||
@@ -1201,9 +1223,14 @@ such requests at once can overload `servewaf` past `SWRP_TIMEOUT_MS`.
|
||||
|
||||
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`;
|
||||
@@ -1289,6 +1316,201 @@ 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
|
||||
|
||||
`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 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`. With `deny`, such a request gets `503` instead of
|
||||
reaching the app.
|
||||
|
||||
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 `200`. 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. Apart from what Go's HTTP code changes, 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`;
|
||||
- `Upgrade`.
|
||||
|
||||
A chunked body's trailers reach the app, but not `servewaf`.
|
||||
|
||||
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
|
||||
@@ -1456,7 +1678,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
|
||||
|
||||
Reference in New Issue
Block a user