From eabac8524318c9815a8f343c0ac98b093025e2e6 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 5 Nov 2026 00:08:53 +0000 Subject: [PATCH] rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171) `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 https://git.eeqj.de/sneak/smallwebwaf/issues/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 --- README.md | 21 +- SPEC.md | 265 +++- cmd/smallwebwaf/main.go | 3 +- docs/admin-endpoints.md | 6 + docs/request-log.md | 39 +- docs/settings.md | 44 +- internal/config/config.go | 10 +- internal/config/rproxy.go | 167 ++ internal/config/rproxy_test.go | 206 +++ internal/requestlog/requestlog.go | 46 +- internal/requestlog/requestlog_test.go | 22 + internal/rproxy/bodies.go | 211 +++ internal/rproxy/client.go | 122 ++ internal/rproxy/limits_test.go | 811 ++++++++++ internal/rproxy/question_test.go | 477 ++++++ internal/rproxy/request.go | 593 ++++++++ internal/rproxy/rproxy.go | 228 +++ internal/rproxy/rproxy_test.go | 1758 ++++++++++++++++++++++ internal/rproxy/servewaf.go | 266 ++++ internal/smallwebwaf/rproxy.go | 99 ++ internal/smallwebwaf/rproxy_test.go | 787 ++++++++++ internal/smallwebwaf/smallwebwaf.go | 18 +- internal/smallwebwaf/smallwebwaf_test.go | 43 +- 23 files changed, 6183 insertions(+), 59 deletions(-) create mode 100644 internal/config/rproxy.go create mode 100644 internal/config/rproxy_test.go create mode 100644 internal/rproxy/bodies.go create mode 100644 internal/rproxy/client.go create mode 100644 internal/rproxy/limits_test.go create mode 100644 internal/rproxy/question_test.go create mode 100644 internal/rproxy/request.go create mode 100644 internal/rproxy/rproxy.go create mode 100644 internal/rproxy/rproxy_test.go create mode 100644 internal/rproxy/servewaf.go create mode 100644 internal/smallwebwaf/rproxy.go create mode 100644 internal/smallwebwaf/rproxy_test.go diff --git a/README.md b/README.md index 504e4be..3bc03b1 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it accepts requests on the app's behalf, passes them to the app, and passes the app's answers back. -It needs no setting. Every 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 needs no setting. Every `SWWAF_` setting has a default chosen for a service +on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and +why. The reference documents under [`docs/`](docs/) are listed under "Documents" +below. ## What it protects against @@ -342,15 +343,21 @@ pinned. `127.0.0.1` and `::1` for forwarded headers, besides the private address ranges. - Both processes start with the container's environment variables, so every - setting of `smallwebwaf` starts with `SWWAF_`, and the app can read a token - given as one. A token given as a file that only the `smallwebwaf` user can - read is out of its reach (see "Settings given as files" in + setting of this `smallwebwaf` process starts with `SWWAF_`, and the app can + read a token given as one. A token given as a file that only the `smallwebwaf` + user can read is out of its reach (see "Settings given as files" in [`docs/settings.md`](docs/settings.md)). - The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps them across a deploy. - `smallwebwaf servewaf` runs the same checks for the sites that ask it about each request, and passes nothing to an app (see "`servewaf`" in [`SPEC.md`](SPEC.md)). +- `smallwebwaf rproxy` is the reverse proxy in front of one app that asks + `smallwebwaf servewaf` about each request. 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. The settings of `rproxy` start with `SWRP_`, and it needs + `SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)). - `docker stop` stops both processes at once. `smallwebwaf` gives the requests in progress five seconds to finish, so the app must keep answering for five seconds after `SIGTERM`. @@ -363,8 +370,6 @@ what each does. ## TODO - Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: - - `smallwebwaf rproxy`, the reverse proxy in front of each app that asks - `smallwebwaf servewaf` about each request; - sharing bans between the instances of several apps; - sending the log over RELP. diff --git a/SPEC.md b/SPEC.md index 6843758..4980d1f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/cmd/smallwebwaf/main.go b/cmd/smallwebwaf/main.go index 9370da4..090fb90 100644 --- a/cmd/smallwebwaf/main.go +++ b/cmd/smallwebwaf/main.go @@ -1,6 +1,7 @@ // Command smallwebwaf is a web application firewall for one app: it runs // between traefik and the app and passes requests through within its limits, -// or, as `smallwebwaf servewaf`, answers rproxy's questions about them. +// or, as `smallwebwaf servewaf`, answers rproxy's questions about them, or, as +// `smallwebwaf rproxy`, is the reverse proxy that asks those questions. // Everything it does is in internal/smallwebwaf. package main diff --git a/docs/admin-endpoints.md b/docs/admin-endpoints.md index 2f856bd..6e7e6f4 100644 --- a/docs/admin-endpoints.md +++ b/docs/admin-endpoints.md @@ -51,3 +51,9 @@ curl -H "Authorization: Bearer $TOKEN" \ `smallwebwaf servewaf` answers these endpoints at its own address. No check runs on those requests (see "`servewaf`" in [`SPEC.md`](../SPEC.md)). + +## `rproxy` + +`smallwebwaf rproxy` answers only the health check, at its own address. It asks +`servewaf` about any other request under `/_smallwebwaf/` (see "`rproxy`" in +[`SPEC.md`](../SPEC.md)). diff --git a/docs/request-log.md b/docs/request-log.md index 1689319..bccd93c 100644 --- a/docs/request-log.md +++ b/docs/request-log.md @@ -3,6 +3,7 @@ The fields of the JSON object `smallwebwaf` writes for each request. "Request log" in [`SPEC.md`](../SPEC.md) says when a line is written and how the log is sent to a syslog server. A field that does not apply to a request is left out. +`smallwebwaf rproxy` writes a shorter line, which "`rproxy`'s line" below gives. ## Example @@ -12,7 +13,7 @@ sent to a syslog server. A field that does not apply to a request is left out. ## Every line -Every line has: +Every line but `rproxy`'s has: - `type`: `request` (the process's own messages have `process`); - the standard web log fields; @@ -181,3 +182,39 @@ For an allowed request, the report's counting also changes these fields: - `counts`: the byte totals now include this request's; - `limit_hit`, `offence` and `ban_expires`: set when the report's bytes broke a byte limit. + +## `rproxy`'s line + +`smallwebwaf rproxy` writes one short line for each request. `request_id` joins +it to `servewaf`'s two. It has these fields and no others: + +- `type`: `request`. +- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC. +- `request_id`: the id `rproxy` made for the request when it arrived. +- `client_ip`: the visitor's address. +- `host` and `method`. +- `path` and `query`: as the visitor sent them. +- `status`: what the visitor got, `0` if nothing. +- `request_bytes`: the bytes of the visitor's body that `rproxy` read, with what + a WebSocket carried from the visitor. +- `response_bytes`: the bytes of the answer's body, with what a WebSocket + carried from the app. +- `duration_request`: milliseconds from the request's headers being read until + the app had been sent the whole request, or the answer started if that was + sooner. It includes the wait for `servewaf`. +- `duration_response`: milliseconds from then until the end of the answer. +- `disposition`: what was done with the request: `allow`, `deny` or `close`. It + is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left + out when: + - `servewaf` was not asked, as for the health check; + - the visitor went away while `servewaf` was being asked; + - `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`; + - `rproxy` could not pass on `servewaf`'s answer, and answered `502`. +- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave + `disposition`, and otherwise left out. + +For example: + +``` +{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"} +``` diff --git a/docs/settings.md b/docs/settings.md index 9c39e58..9b5855d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -343,11 +343,15 @@ A few limits are fixed rather than settings: - `servewaf` holds at most 10,000 answered requests awaiting their reports; - `servewaf` refuses a request with more than 50 cookies or more than 100 header fields (see "Answering in time" under "`servewaf`" in - [`SPEC.md`](../SPEC.md)). + [`SPEC.md`](../SPEC.md)); +- `rproxy` takes a request line and headers of at most 32 KiB, and closes a + kept-open connection idle for 120 seconds; +- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers + `502` to a longer one. ## `servewaf` -`smallwebwaf servewaf` uses every setting here but these, which it ignores: +`smallwebwaf servewaf` uses every `SWWAF_` setting but these, which it ignores: - `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`; - the five timeouts under "Size and time limits"; @@ -367,3 +371,39 @@ Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked in the background (see "Answering in time" under "`servewaf`" in [`SPEC.md`](../SPEC.md)). + +## `rproxy` + +`smallwebwaf rproxy` reads the settings below and no others, and nothing else +reads them. They are read once at start, and an invalid value stops the start. +None can be given as a file, since `rproxy` never touches disk. "`rproxy`" in +[`SPEC.md`](../SPEC.md) says what they do. + +A time here is a whole number of milliseconds above zero, such as `2000`. + +- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or + `https` URL with a host and an optional port, and nothing more. `rproxy` + speaks HTTP/1.1 to it. +- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s + answer, to a question and to a report. A time; never `off`. +- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when + `servewaf` cannot be connected to, does not answer at all, or does not answer + within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers + `503`. It does not apply to an answer `servewaf` gives, such as an error. +- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or + `off`. +- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or + `off`. +- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to + send its request line and headers. It then also bounds, from the end of the + headers, the time until the app has been sent the whole request. A time or + `off`. +- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take, + from the end of the request to its last byte reaching the visitor. A time or + `off`. +- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`. +- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or + `https` URL with a host and an optional port, and nothing more. +- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): + the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts + nothing. diff --git a/internal/config/config.go b/internal/config/config.go index 914e3de..926483c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ -// Package config reads smallwebwaf's settings. Every setting is an -// environment variable whose name starts with SWWAF_, or a file such a -// variable names, every setting has a default, and this package is the -// one place they are read. +// Package config reads smallwebwaf's settings, and is the one place they are +// read. Every setting is an environment variable. The settings of +// `smallwebwaf` and `smallwebwaf 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. package config import ( diff --git a/internal/config/rproxy.go b/internal/config/rproxy.go new file mode 100644 index 0000000..8e5f189 --- /dev/null +++ b/internal/config/rproxy.go @@ -0,0 +1,167 @@ +package config + +import ( + "errors" + "fmt" + "log/slog" + "math" + "net/netip" + "net/url" + "strconv" + "strings" + "time" +) + +// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero +// is off. +type RProxy struct { + // ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). + ListenAddr string + // UpstreamURL is the app (SWRP_UPSTREAM_URL). + UpstreamURL *url.URL + // TrustedProxies are the netblocks whose X-Forwarded-For is believed + // (SWRP_TRUSTED_PROXIES). + TrustedProxies []netip.Prefix + // WAFBackend is servewaf's URL (SWRP_WAF_BACKEND). + WAFBackend *url.URL + // Timeout is how long rproxy waits for servewaf's answer to a question, + // and to a report (SWRP_TIMEOUT_MS). It cannot be off. + Timeout time.Duration + // DefaultDisposition is what is done with a request when servewaf cannot be + // connected to, does not answer at all, or does not answer within Timeout: + // allow or deny (SWRP_DEFAULT_DISPOSITION). + DefaultDisposition string + // MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and + // MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE). + MaxRequestSize int64 + MaxResponseSize int64 + // MaxRequestTime bounds sending the whole request: the visitor's request + // line and headers, and then, from their end, its body on its way to the + // app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from + // the end of the request to its last byte reaching the visitor + // (SWRP_MAX_RESPONSE_TIME_MS). + MaxRequestTime time.Duration + MaxResponseTime time.Duration + + // settings are the values read, as given or by default, for the log line + // at start. + settings []slog.Attr +} + +var ( + errNeedsWAFBackend = errors.New( + "is required: it is servewaf's URL, such as http://servewaf:8080") + errNotDisposition = errors.New("is not allow or deny") + errNotMilliseconds = errors.New( + "is not a whole number of milliseconds above zero, such as 2000") + errNotMillisecondsOrOff = errors.New( + "is not a whole number of milliseconds above zero, such as 60000, or off") +) + +// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally +// os.LookupEnv. A setting that is not set takes its default, and +// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but +// invalid is an error that names it. Since rproxy never touches disk, its +// settings have no _FILE form. +func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) { + env := &environment{lookupEnv: func(name string) (string, bool) { + if strings.HasSuffix(name, "_FILE") { + return "", false + } + + return lookupEnv(name) + }} + cfg := &RProxy{ + ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr), + UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL), + TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges), + WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"), + Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"), + DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"), + MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"), + MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"), + MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"), + MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"), + } + + if env.err != nil { + return nil, env.err + } + + cfg.settings = env.settings + + return cfg, nil +} + +// LogValue makes an RProxy log as each setting's name with the value it was +// given, or its default. +func (c *RProxy) LogValue() slog.Value { + return slog.GroupValue(c.settings...) +} + +// wafBackend reads the setting that is servewaf's URL, which must be set. +func (e *environment) wafBackend(name string) *url.URL { + value := e.value(name, "") + if value == "" { + e.check(name, errNeedsWAFBackend) + + return nil + } + + backend, err := parseUpstreamURL(value) + e.check(name, err) + + return backend +} + +// disposition reads the setting that is what is done with a request when servewaf +// cannot be connected to, does not answer at all, or does not answer in time: +// allow or deny. +func (e *environment) disposition(name, defaultValue string) string { + value := e.value(name, defaultValue) + if value != "allow" && value != "deny" { + e.check(name, fmt.Errorf("%q %w", value, errNotDisposition)) + } + + return value +} + +// milliseconds reads a setting that is a whole number of milliseconds above +// zero. +func (e *environment) milliseconds(name, defaultValue string) time.Duration { + value := e.value(name, defaultValue) + + duration, valid := parseMilliseconds(value) + if !valid { + e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds)) + } + + return duration +} + +// millisecondsOrOff reads a setting that is a whole number of milliseconds +// above zero, or off, which is 0. +func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration { + value := e.value(name, defaultValue) + if value == off { + return 0 + } + + duration, valid := parseMilliseconds(value) + if !valid { + e.check(name, fmt.Errorf("%q %w", value, errNotMillisecondsOrOff)) + } + + return duration +} + +// parseMilliseconds reads a whole number of milliseconds above zero, and +// reports whether it is one. +func parseMilliseconds(value string) (time.Duration, bool) { + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) { + return 0, false + } + + return time.Duration(n) * time.Millisecond, true +} diff --git a/internal/config/rproxy_test.go b/internal/config/rproxy_test.go new file mode 100644 index 0000000..8372292 --- /dev/null +++ b/internal/config/rproxy_test.go @@ -0,0 +1,206 @@ +package config_test + +import ( + "net/netip" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/config" +) + +// rproxy's settings, by name, and their values in these tests. +const ( + wafBackend = "SWRP_WAF_BACKEND" + rproxyTimeout = "SWRP_TIMEOUT_MS" + rproxyDefault = "SWRP_DEFAULT_DISPOSITION" + rproxyListen = "SWRP_LISTEN_ADDR" + rproxyUpstream = "SWRP_UPSTREAM_URL" + rproxyTrusted = "SWRP_TRUSTED_PROXIES" + rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE" + rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" + rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" + rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" + backendURL = "http://servewaf:8080" + rproxyDefaultAddr = ":8080" + rproxyDefaultApp = "http://127.0.0.1:8081" + notASize = "a lot" +) + +// rproxyConfig reads rproxy's settings from env. +func rproxyConfig(env map[string]string) (*config.RProxy, error) { + return config.RProxyFromEnvironment(func(name string) (string, bool) { + value, ok := env[name] + + return value, ok + }) +} + +func TestRProxyDefaults(t *testing.T) { + t.Parallel() + + cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL}) + if err != nil { + t.Fatalf("settings: %v", err) + } + + if cfg.ListenAddr != rproxyDefaultAddr || + cfg.UpstreamURL.String() != rproxyDefaultApp || + cfg.WAFBackend.String() != backendURL { + t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend) + } + + if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" { + t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition) + } + + if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 { + t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize) + } + + if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute { + t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime) + } + + want := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + } + if !slices.Equal(cfg.TrustedProxies, want) { + t.Errorf("trusted proxies %v, want %v", cfg.TrustedProxies, want) + } +} + +func TestRProxySettingsAreRead(t *testing.T) { + t.Parallel() + + cfg, err := rproxyConfig(map[string]string{ + wafBackend: "https://waf.example:8443", + rproxyListen: ":9000", + rproxyUpstream: "http://app:3000", + rproxyTrusted: "192.0.2.0/24", + rproxyTimeout: "150", + rproxyDefault: "deny", + rproxyMaxRequest: "1K", + rproxyMaxResponse: off, + rproxyRequestTime: off, + rproxyAnswerTime: "250", + }) + if err != nil { + t.Fatalf("settings: %v", err) + } + + if cfg.ListenAddr != ":9000" || cfg.UpstreamURL.Host != "app:3000" || + cfg.WAFBackend.Host != "waf.example:8443" || + cfg.TrustedProxies[0] != netip.MustParsePrefix("192.0.2.0/24") { + t.Errorf("addresses %+v", cfg) + } + + if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" || + cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 || + cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond { + t.Errorf("limits %+v", cfg) + } +} + +func TestRProxyInvalidSettingsAreRefused(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, value string + }{ + {wafBackend, ""}, + {wafBackend, "servewaf:8080"}, + {wafBackend, "http://servewaf:8080/path"}, + {rproxyTimeout, "0"}, + {rproxyTimeout, "-1"}, + {rproxyTimeout, off}, + {rproxyTimeout, "2s"}, + {rproxyTimeout, "1.25"}, + {rproxyTimeout, "99999999999999999"}, + {rproxyDefault, "maybe"}, + {rproxyListen, "8080"}, + {rproxyUpstream, "app"}, + {rproxyTrusted, "not a netblock"}, + {rproxyMaxRequest, notASize}, + {rproxyMaxResponse, notASize}, + {rproxyRequestTime, "0"}, + {rproxyRequestTime, "1m"}, + {rproxyAnswerTime, "0"}, + } { + t.Run(tc.name+"="+tc.value, func(t *testing.T) { + t.Parallel() + + _, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value}) + if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") { + t.Errorf("error %v, want one naming %s", err, tc.name) + } + }) + } +} + +func TestRProxyNeedsItsBackend(t *testing.T) { + t.Parallel() + + _, err := rproxyConfig(nil) + if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") { + t.Errorf("error %v, want one saying %s is required", err, wafBackend) + } +} + +func TestRProxyReadsNoFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "backend") + + err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600) + if err != nil { + t.Fatalf("write: %v", err) + } + + _, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path}) + if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") { + t.Errorf("error %v, want %s still required", err, wafBackend) + } + + cfg, err := rproxyConfig(map[string]string{ + wafBackend: backendURL, + rproxyTimeout + "_FILE": "/nonexistent/timeout", + }) + if err != nil { + t.Fatalf("settings: %v, with the file left unread", err) + } + + if cfg.Timeout != 2*time.Second { + t.Errorf("timeout %v, want the default", cfg.Timeout) + } +} + +func TestRProxySettingsAreLoggedByName(t *testing.T) { + t.Parallel() + + cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL}) + if err != nil { + t.Fatalf("settings: %v", err) + } + + group := cfg.LogValue().Group() + + names := make([]string, 0, len(group)) + for _, attr := range group { + names = append(names, attr.Key) + } + + for _, name := range []string{ + rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault, + rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime, + } { + if !slices.Contains(names, name) { + t.Errorf("%s is not logged, only %v", name, names) + } + } +} diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index 6ddf8e7..a4221cf 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -1,6 +1,7 @@ // Package requestlog writes the lines smallwebwaf prints on stdout: one -// JSON object per request, or two from servewaf, marked "type":"request", and -// the process's own messages as JSON lines marked "type":"process". +// JSON object per request, two from servewaf and a short one from rproxy, marked +// "type":"request", and the process's own messages as JSON lines marked +// "type":"process". package requestlog import ( @@ -195,10 +196,51 @@ type Line struct { DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"` } +// RProxyLine is rproxy's short line in the request log about one request. The +// field names, and their order, are those of docs/request-log.md. A field that +// does not apply to a request is left out of its line. +// +//nolint:tagliatelle // docs/request-log.md names the fields in snake_case +type RProxyLine struct { + Type string `json:"type"` + Time string `json:"time"` + RequestID string `json:"request_id"` + ClientIP string `json:"client_ip"` + Host string `json:"host"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Status int `json:"status"` + RequestBytes int64 `json:"request_bytes"` + ResponseBytes int64 `json:"response_bytes"` + // DurationRequest runs in milliseconds from the request's headers being read + // until the app has been sent the whole request, or the answer has started + // if that is sooner. DurationResponse runs from then until the answer ends. + DurationRequest float64 `json:"duration_request"` + DurationResponse float64 `json:"duration_response"` + // Disposition is the valid disposition servewaf answered or, while + // DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is "" + // when neither gave one. + Disposition string `json:"disposition,omitempty"` + DefaultDisposition bool `json:"default_disposition,omitempty"` +} + // Write writes line to w as one JSON line marked "type":"request". func Write(w io.Writer, line *Line) error { line.Type = "request" + return writeJSONLine(w, line) +} + +// WriteRProxyLine writes line to w as one JSON line marked "type":"request". +func WriteRProxyLine(w io.Writer, line *RProxyLine) error { + line.Type = "request" + + return writeJSONLine(w, line) +} + +// writeJSONLine writes line to w as one JSON line. +func writeJSONLine(w io.Writer, line any) error { encoded, err := json.Marshal(line) if err != nil { return fmt.Errorf("encode the request log line: %w", err) diff --git a/internal/requestlog/requestlog_test.go b/internal/requestlog/requestlog_test.go index 9824b67..3f959f8 100644 --- a/internal/requestlog/requestlog_test.go +++ b/internal/requestlog/requestlog_test.go @@ -67,6 +67,28 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) { } } +func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + err := requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{ + RequestID: "an-id", + Disposition: "allow", + }) + if err != nil { + t.Fatalf("write: %v", err) + } + + const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` + + `"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` + + `"response_bytes":0,"duration_request":0,"duration_response":0,` + + `"disposition":"allow"}` + "\n" + if out.String() != want { + t.Errorf("wrote %s, want %s", out.String(), want) + } +} + func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) { t.Parallel() diff --git a/internal/rproxy/bodies.go b/internal/rproxy/bodies.go new file mode 100644 index 0000000..4bd20d1 --- /dev/null +++ b/internal/rproxy/bodies.go @@ -0,0 +1,211 @@ +package rproxy + +import ( + "bufio" + "errors" + "io" + "net" + "net/http" + "sync/atomic" +) + +// errResponseTooLarge ends an app's response body that is longer than +// SWRP_MAX_RESPONSE_SIZE. +var errResponseTooLarge = errors.New( + "the response body is over SWRP_MAX_RESPONSE_SIZE") + +// requestBody is the visitor's request body. rproxy first reads up to the first +// 64 KiB of it into held, and then, on its way to the app, the transport reads +// it on a goroutine of its own. +type requestBody struct { + // body is the visitor's body, ending in an *http.MaxBytesError past + // SWRP_MAX_REQUEST_SIZE. + body io.ReadCloser + rq *request + // held is the part read before the request goes to the app, which Read gives + // first. + held []byte + // waiting is true while a Read waits for the visitor to send more. + waiting atomic.Bool + // received is true once the visitor has sent the whole body. + received atomic.Bool + // bytes is how much of the body has been read. + bytes atomic.Int64 +} + +// Read reads from the visitor's body, after the part held, which has been +// counted already. +func (b *requestBody) Read(p []byte) (int, error) { + if len(b.held) > 0 { + n := copy(p, b.held) + b.held = b.held[n:] + + return n, nil + } + + b.waiting.Store(true) + n, err := b.body.Read(p) + b.waiting.Store(false) + b.bytes.Add(int64(n)) + + var tooLarge *http.MaxBytesError + + switch { + case errors.Is(err, io.EOF): + b.received.Store(true) + case errors.As(err, &tooLarge): + b.rq.refuse(http.StatusRequestEntityTooLarge) + } + + return n, err +} + +// Close closes the visitor's body. +func (b *requestBody) Close() error { + return b.body.Close() +} + +// responseBody is the app's response body on its way to the visitor. +type responseBody struct { + // body is the app's body, ending in an *http.MaxBytesError past + // SWRP_MAX_RESPONSE_SIZE. + body io.ReadCloser + rq *request +} + +// Read reads from the app's body. +func (b *responseBody) Read(p []byte) (int, error) { + n, err := b.body.Read(p) + if err == nil { + return n, nil + } + + var tooLarge *http.MaxBytesError + + switch { + case errors.Is(err, io.EOF): + b.rq.responseReceived() + case errors.As(err, &tooLarge): + b.rq.refuse(http.StatusBadGateway) + + return n, errResponseTooLarge + case b.rq.in.Context().Err() == nil: + // The answer broke off, not because the visitor went away. If a time + // limit cut it, that refusal came first and is the one kept. + b.rq.refuse(http.StatusBadGateway) + } + + return n, err +} + +// Close closes the app's body. +func (b *responseBody) Close() error { + return b.body.Close() +} + +// upgradedConn is the connection to the app once the app has switched +// protocols, as for a WebSocket. ReverseProxy writes to it what the visitor +// sends and reads from it what the app sends, on goroutines of its own, until +// the connection closes; it counts the bytes each way, for the report. +type upgradedConn struct { + io.ReadWriteCloser + + // fromApp is how many bytes the app has sent, and toApp how many the + // visitor has. + fromApp atomic.Int64 + toApp atomic.Int64 +} + +// Read reads what the app sends. +func (c *upgradedConn) Read(p []byte) (int, error) { + n, err := c.ReadWriteCloser.Read(p) + c.fromApp.Add(int64(n)) + + return n, err +} + +// Write sends the app what the visitor sent. +func (c *upgradedConn) Write(p []byte) (int, error) { + n, err := c.ReadWriteCloser.Write(p) + c.toApp.Add(int64(n)) + + return n, err +} + +// CloseWrite tells the app that the visitor sends no more, while what the app +// sends still passes. ReverseProxy calls it once the visitor has stopped +// sending, and closes the connection there if it is not supported. +func (c *upgradedConn) CloseWrite() error { + conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error }) + if !ok { + return http.ErrNotSupported + } + + return conn.CloseWrite() +} + +// limitBody returns body, cut off with an *http.MaxBytesError after maxBytes, or +// unchanged if maxBytes is zero, which is off. +func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser { + if maxBytes == 0 { + return body + } + + // Without a ResponseWriter, MaxBytesReader only counts and cuts off. + return http.MaxBytesReader(nil, body, maxBytes) +} + +// responseWriter is the response to the visitor. It notes the status and size +// for the log line and the report. +type responseWriter struct { + http.ResponseWriter + + // status is the final status sent, or zero before one is. + status int + bytes int64 +} + +// WriteHeader sends the status and headers. An informational 1xx status is +// passed on and the final status still comes later. +func (w *responseWriter) WriteHeader(status int) { + if status >= http.StatusOK && w.status == 0 { + w.status = status + } + + w.ResponseWriter.WriteHeader(status) +} + +// Write sends part of the body. +func (w *responseWriter) Write(p []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + + n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given + w.bytes += int64(n) + + return n, err +} + +// FlushError sends what has been written so far. http.ResponseController calls +// it, as ReverseProxy does after each write. +func (w *responseWriter) FlushError() error { + return http.NewResponseController(w.ResponseWriter).Flush() +} + +// Hijack takes over the connection to the visitor, which ReverseProxy does only +// to pass on the app's 101 Switching Protocols: that is then the status sent. +func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack() + if err == nil { + w.status = http.StatusSwitchingProtocols + } + + return conn, buffered, err +} + +// Unwrap lets http.ResponseController reach net/http's own ResponseWriter for +// what responseWriter does not do itself. +func (w *responseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/internal/rproxy/client.go b/internal/rproxy/client.go new file mode 100644 index 0000000..a79f3e9 --- /dev/null +++ b/internal/rproxy/client.go @@ -0,0 +1,122 @@ +package rproxy + +import ( + "net/http" + "net/netip" + "slices" + "strings" +) + +// The headers rproxy sets on the requests it passes to the app. Go writes every +// header name in this form, as it sends it and as it receives it, so +// X-Client-ASN arrives as X-Client-Asn, and Del removes a visitor's own whatever +// their case; header names are not case-sensitive. +const ( + requestIDHeader = "X-Request-ID" + asnHeader = "X-Client-Asn" + countryHeader = "X-Client-Country" +) + +// peerAddress is the address of the request's TCP peer, normally traefik, without +// any IPv6 zone, which servewaf refuses. +func peerAddress(r *http.Request) netip.Addr { + addrPort, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return netip.Addr{} + } + + return addrPort.Addr().Unmap().WithZone("") +} + +// clientAddress works out who the visitor is. A peer outside the trusted +// proxies is the visitor, and what it says in X-Forwarded-For is ignored. For a +// peer inside them, X-Forwarded-For is read from the right, and the first +// address outside them is the visitor; if every address in it is inside, the +// leftmost is, and with no header, the peer. An entry that is not an address +// ends the reading, since nothing to its left can be believed. The visitor's +// address never has an IPv6 zone, which servewaf refuses. +func clientAddress( + peer netip.Addr, forwardedFor []string, trusted []netip.Prefix, +) netip.Addr { + client := peer + if !isInside(peer, trusted) { + return client + } + + entries := strings.Split(strings.Join(forwardedFor, ","), ",") + for _, entry := range slices.Backward(entries) { + addr, err := netip.ParseAddr(strings.TrimSpace(entry)) + if err != nil { + break + } + + client = addr.Unmap().WithZone("") + if !isInside(client, trusted) { + break + } + } + + return client +} + +// scheme is how the visitor reached traefik, as a trusted proxy says in +// X-Forwarded-Proto, or otherwise http, the only scheme rproxy serves. +func scheme(r *http.Request, peerTrusted bool) string { + proto := r.Header.Get("X-Forwarded-Proto") + if !peerTrusted || proto == "" { + return "http" + } + + return proto +} + +// isInside reports whether addr is in one of the netblocks. +func isInside(addr netip.Addr, netblocks []netip.Prefix) bool { + return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool { + return netblock.Contains(addr) + }) +} + +// setForwardedHeaders sets the headers in which the app learns about the +// visitor, so that it sees what it would see from traefik directly. A trusted +// proxy's forwarded headers pass on, with the proxy's own address added to +// X-Forwarded-For. Those of any other peer 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, which is how it reached rproxy. +func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) { + forwardedFor := peer.String() + + if trusted { + // ReverseProxy removes these from out before Rewrite. + for _, name := range []string{"Forwarded", "X-Forwarded-Host", "X-Forwarded-Proto"} { + values, ok := in.Header[name] + if ok { + out.Header[name] = values + } + } + + prior := in.Header.Values("X-Forwarded-For") + if len(prior) > 0 { + forwardedFor = strings.Join(prior, ", ") + ", " + forwardedFor + } + + out.Header.Set("X-Forwarded-For", forwardedFor) + + return + } + + // ReverseProxy has removed Forwarded and the three set below; these are the + // other headers in which traefik tells the app about the visitor and its + // request. + for _, name := range []string{ + "X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri", + "X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert", + "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", + } { + out.Header.Del(name) + } + + out.Header.Set("X-Forwarded-For", forwardedFor) + out.Header.Set("X-Forwarded-Host", in.Host) + out.Header.Set("X-Forwarded-Proto", "http") +} diff --git a/internal/rproxy/limits_test.go b/internal/rproxy/limits_test.go new file mode 100644 index 0000000..638a085 --- /dev/null +++ b/internal/rproxy/limits_test.go @@ -0,0 +1,811 @@ +package rproxy_test + +import ( + "bytes" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "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" +) + +// post is the start of a POST whose headers end with what follows it. +const post = "POST / HTTP/1.1\r\nHost: site.example\r\n" + +// announcedLength is the header that announces a body of size bytes, and the +// end of the headers. +func announcedLength(size int) string { + return "Content-Length: " + strconv.Itoa(size) + "\r\n\r\n" +} + +func TestServerHasTheFixedLimits(t *testing.T) { + t.Parallel() + + cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { + if name == "SWRP_WAF_BACKEND" { + return nowhere, true + } + + return "", false + }) + if err != nil { + t.Fatalf("settings: %v", err) + } + + server := rproxy.New(rproxy.Params{ + Config: cfg, + RequestLog: io.Discard, + ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo), + }) + + if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 || + server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute { + t.Errorf("server listens on %q with header limit %d, idle time %s and "+ + "header timeout %s", server.Addr, server.MaxHeaderBytes, + server.IdleTimeout, server.ReadHeaderTimeout) + } +} + +func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, limit string + size, asked int + }{ + {"with the limit within the first part", "1K", 1025, 1024}, + {"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := 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{ + maxRequestSize: tc.limit, + }) + + got := visit(t, newRequest(t, http.MethodPost, addr, "/", + bytes.NewReader(make([]byte, tc.size)))) + if got.status != http.StatusRequestEntityTooLarge { + t.Errorf("answered %+v, want 413", got) + } + + if asked := servewaf.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) + if report.Status != http.StatusRequestEntityTooLarge { + t.Errorf("report %+v, want status 413", report) + } + }) + } +} + +func TestADenyOutranksTheBodySizeLimit(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, deny) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestSize: "1K", + }) + + got := visit(t, newRequest(t, http.MethodPost, addr, "/", + bytes.NewReader(make([]byte, 1025)))) + if got.status != http.StatusForbidden { + t.Errorf("answered %+v, want servewaf's 403", got) + } +} + +func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, limit string + size int + // mayReachApp is true when the part of the body the limit lets through + // goes on to the app before the limit is met. The app may still not have + // started on the request when it is cut off. + mayReachApp bool + }{ + {"within the first part", "1K", 2 << 10, false}, + {"past the first part", "128K", 129 << 10, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var asked atomic.Bool + + servewaf := 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{ + maxRequestSize: tc.limit, + }) + + conn := dial(t, addr) + send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+ + strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+ + "\r\n0\r\n\r\n") + + if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge { + t.Errorf("status %d, want 413", status) + } + + if asked.Load() && !tc.mayReachApp { + t.Error("the app was asked") + } + }) + } +} + +func TestAResponseOverItsLimit(t *testing.T) { + t.Parallel() + + large := strings.Repeat("x", 2<<10) + + t.Run("announced, is refused", func(t *testing.T) { + t.Parallel() + + servewaf := 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{ + maxResponseSize: "1K", + }) + + 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 { + t.Errorf("report %+v, want status 502", report) + } + }) + + t.Run("not announced, is cut off", func(t *testing.T) { + t.Parallel() + + servewaf := 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{ + maxResponseSize: "1K", + }) + + conn := dial(t, addr) + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + text, err := io.ReadAll(conn) + if err != nil || bytes.Contains(text, []byte(large)) { + t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err) + } + + report := servewaf.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) + } + }) +} + +func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) { + t.Parallel() + + const announced = "2048" + + servewaf := 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{ + maxResponseSize: "1K", + }) + + got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) + if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced { + t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced) + } +} + +func TestASettingOfOffSetsNoLimit(t *testing.T) { + t.Parallel() + + for _, setting := range []string{ + maxRequestSize, maxResponseSize, maxRequestTime, maxResponseTime, + } { + t.Run(setting, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + setting: "off", + }) + + // A body and an answer that a limit of zero would cut off, as it would the + // next answer, which rproxy gives itself. + wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", + strings.NewReader("a body")))) + + if got := get(t, addr, proxy.HealthPath); got.status != http.StatusOK { + t.Errorf("answered %+v, want rproxy's own 200", got) + } + }) + } +} + +func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, rest string + }{ + {"with its length announced", announcedLength(100) + "some of it"}, + {"with no length announced", "Transfer-Encoding: chunked\r\n\r\na\r\nsome of it\r\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestTime: "500", + }) + + conn := dial(t, addr) + send(t, conn, post+tc.rest) + + if status := readStatus(t, conn); status != http.StatusRequestTimeout { + t.Errorf("status %d, want 408", status) + } + + line := out.requestLine(t) + if line["disposition"] != nil || line["request_bytes"] != 10.0 { + t.Errorf("line %v, want no disposition and 10 bytes", line) + } + + if len(servewaf.questions) != 0 { + t.Error("servewaf was asked") + } + + servewaf.wantNoReport(t) + }) + } +} + +func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, servewaf.URL, app.URL, nil) + + conn := dial(t, addr) + send(t, conn, post+announcedLength(100)+"some of it") + _ = conn.Close() + + line := out.requestLine(t) + if line["status"] != 0.0 || line["disposition"] != nil { + t.Errorf("line %v, want no answer and no disposition", line) + } + + if len(servewaf.questions) != 0 { + t.Error("servewaf was asked") + } +} + +func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, request string + stopsSending bool + }{ + { + "a malformed chunk", post + "Transfer-Encoding: chunked\r\n\r\nnot a chunk\r\n", + false, + }, + {"a visitor that stops sending", post + announcedLength(100) + "some of it", true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, servewaf.URL, app.URL, nil) + + conn := dial(t, addr) + send(t, conn, tc.request) + + if tc.stopsSending { + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + t.Fatalf("the connection is a %T", conn) + } + + err := tcpConn.CloseWrite() + if err != nil { + t.Fatalf("stop sending: %v", err) + } + } + + text, err := io.ReadAll(conn) + if err != nil || len(text) != 0 { + t.Errorf("read %q (%v), want the connection closed without an answer", + text, err) + } + + line := out.requestLine(t) + if line["status"] != 0.0 || line["disposition"] != nil { + t.Errorf("line %v, want no answer and no disposition", line) + } + + if len(servewaf.questions) != 0 { + t.Error("servewaf was asked") + } + }) + } +} + +// deadlineRecorder notes the time limits rproxy sets on reading the visitor's body +// and on sending its answer. +type deadlineRecorder struct { + *httptest.ResponseRecorder + + readDeadline time.Time + writeDeadline time.Time +} + +// SetReadDeadline notes deadline. +func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error { + w.readDeadline = deadline + + return nil +} + +// SetWriteDeadline notes deadline. +func (w *deadlineRecorder) SetWriteDeadline(deadline time.Time) error { + w.writeDeadline = deadline + + return nil +} + +func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) { + t.Parallel() + + const ( + limit = 5 * time.Minute + // announced is over what Go's server reads of a body left unread, so that + // the app's own server answers without waiting for the rest. + announced = 100 * questionBodyMaxBytes + ) + + servewaf := 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{ + maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10), + }) + + // The visitor sends the first part and a little more, and then nothing. + stalled, stop := io.Pipe() + + t.Cleanup(func() { _ = stop.Close() }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", + io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled)) + req.ContentLength = announced + req.Header.Set(contentLengthHeader, strconv.Itoa(announced)) + + w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} + + before := time.Now() + + server.Handler.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status %d, want the app's 204", w.Code) + } + + if w.readDeadline.Before(before.Add(limit)) || + w.readDeadline.After(time.Now().Add(limit)) { + t.Errorf("the reading of the body ends at %s, want %s from the request", + w.readDeadline, limit) + } +} + +func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) { + t.Parallel() + + const limit = 5 * time.Minute + + for _, tc := range []struct { + name, target string + status int + }{ + {"servewaf's deny", "/", http.StatusForbidden}, + {"rproxy's own 400", "x:admin", http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, deny) + app := startApp(t, greet) + _, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{ + maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10), + }) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target, + http.NoBody) + w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} + + before := time.Now() + + server.Handler.ServeHTTP(w, req) + + if w.Code != tc.status { + t.Errorf("status %d, want %d", w.Code, tc.status) + } + + if w.writeDeadline.Before(before.Add(limit)) || + w.writeDeadline.After(time.Now().Add(limit)) { + t.Errorf("the time limit on sending ends at %s, want %s from the answer", + w.writeDeadline, limit) + } + }) + } +} + +func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestTime: "3000", + }) + + conn := dial(t, addr) + send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+ + strings.Repeat("x", questionBodyMaxBytes+1)) + + if status := readStatus(t, conn); status != http.StatusRequestTimeout { + t.Errorf("status %d, want 408", status) + } + + if asked := servewaf.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 { + t.Errorf("report %+v, want status 408", report) + } +} + +// startAppThatTakesNothing starts an app that accepts connections and never +// reads from them, and returns its URL. +func startAppThatTakesNothing(t *testing.T) string { + t.Helper() + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + stopped := make(chan struct{}) + + t.Cleanup(func() { + close(stopped) + + _ = listener.Close() + }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + + go func() { + <-stopped + + _ = conn.Close() + }() + } + }() + + return "http://" + listener.Addr().String() +} + +func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) { + t.Parallel() + + // More than the connections hold while nobody reads, written a little at a + // time, so that rproxy soon waits for the app. + const ( + bodySize = 64 << 20 + writeSize = 32 << 10 + ) + + servewaf := startServeWAF(t, allow) + addr, _ := startRProxy(t, servewaf.URL, startAppThatTakesNothing(t), + map[string]string{maxRequestTime: "3000"}) + + conn := dial(t, addr) + send(t, conn, post+announcedLength(bodySize)) + + go func() { + part := make([]byte, writeSize) + + for range bodySize / writeSize { + _, err := conn.Write(part) + if err != nil { + return + } + } + }() + + if status := readStatus(t, conn); status != http.StatusGatewayTimeout { + t.Errorf("status %d, want 504", status) + } +} + +func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + servewaf := 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{ + maxResponseTime: "300", + }) + + got := get(t, addr, "/") + if got.status != http.StatusGatewayTimeout { + t.Errorf("answered %d, want 504", got.status) + } + + if report := servewaf.report(t); report.Status != http.StatusGatewayTimeout { + t.Errorf("report %+v, want status 504", report) + } +} + +func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "the start") + _ = http.NewResponseController(w).Flush() + + <-release + }) + + t.Cleanup(func() { close(release) }) + + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxResponseTime: "300", + }) + + 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) + if err != nil { + t.Errorf("read: %v, want the connection closed", err) + } + + if report := servewaf.report(t); report.Status != http.StatusOK { + t.Errorf("report %+v, want the status the visitor got", report) + } +} + +func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) { + t.Parallel() + + // More than the connections hold while nobody reads. + const ( + bodySize = 64 << 20 + writeSize = 32 << 10 + ) + + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { + part := make([]byte, writeSize) + + for range bodySize / writeSize { + _, err := w.Write(part) + if err != nil { + return + } + } + }) + addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxResponseTime: "500", + }) + + // The visitor asks, and never reads the answer. + conn := dial(t, addr) + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + line := out.requestLine(t) + + responseBytes, _ := line["response_bytes"].(float64) + if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize { + t.Errorf("line %v, want status 200 and the answer cut off", line) + } + + report := servewaf.report(t) + if report.Status != http.StatusOK || report.ResponseBytes >= bodySize { + t.Errorf("report %+v, want status 200 and the answer cut off", report) + } +} + +func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + addr, _ := startRProxy(t, servewaf.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 { + t.Errorf("report %+v, want status 502", report) + } +} + +func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) { + t.Parallel() + + servewaf := 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{ + maxRequestTime: "3000", + }) + + // Far more announced than sent, so that Go's server answers for the app + // without waiting for the rest. + conn := dial(t, addr) + send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+ + strings.Repeat("x", questionBodyMaxBytes)) + + if status := readStatus(t, conn); status != http.StatusOK { + t.Errorf("status %d, want the app's 200", status) + } +} + +func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) { + t.Parallel() + + working := make(chan struct{}) + servewaf := 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) + + conn := dial(t, addr) + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + select { + case <-working: + case <-time.After(waitLimit): + t.Fatal("the app was not asked") + } + + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + t.Fatalf("the connection is a %T", conn) + } + + err := tcpConn.CloseWrite() + if err != nil { + t.Fatalf("stop sending: %v", err) + } + + text, err := io.ReadAll(conn) + if err != nil || len(text) != 0 { + t.Errorf("read %q (%v), want the connection closed without an answer", text, err) + } + + if line := out.requestLine(t); line["status"] != 0.0 { + t.Errorf("line %v, want status 0", line) + } +} + +func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) { + t.Parallel() + + const answer = "the end of a long answer" + + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _ = http.NewResponseController(w).Flush() + + time.Sleep(2100 * time.Millisecond) + + _, _ = io.WriteString(w, answer) + }) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestTime: "2000", + }) + + got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))) + if got.status != http.StatusOK || got.body != answer { + t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body) + } +} + +func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, start string + status int + }{ + {"a deny", post, http.StatusForbidden}, + { + "the health check", + "GET " + proxy.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) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestTime: "500", + }) + + // Go's server waits for a little of the body left before it sends an answer, + // and the time for sending the request ends that wait. + conn := dial(t, addr) + send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+ + strings.Repeat("x", questionBodyMaxBytes)) + + if status := readStatus(t, conn); status != tc.status { + t.Errorf("status %d, want %d", status, tc.status) + } + }) + } +} diff --git a/internal/rproxy/question_test.go b/internal/rproxy/question_test.go new file mode 100644 index 0000000..0005f2a --- /dev/null +++ b/internal/rproxy/question_test.go @@ -0,0 +1,477 @@ +package rproxy_test + +import ( + "bytes" + "crypto/rand" + "io" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + "sneak.berlin/go/smallwebwaf/internal/proxy" +) + +// questionBodyMaxBytes is the most of a body a question carries. +const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes + +// chunked is the encoding of a body whose length the visitor did not announce. +const chunked = "chunked" + +// pathGoReencodes is a path Go's client would decode and escape again, %2F turned +// into / and | into %7C, unless told to send it as it is. A test sets it as the +// visitor's URL.Opaque, so that the visitor sends it unchanged too. +const pathGoReencodes = "/some%2Fpath/with%20space|pipe" + +func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" + + req := newRequest(t, http.MethodDelete, addr, target, http.NoBody) + req.URL.Opaque = pathGoReencodes + req.Host = site + req.Header.Set("X-Test", "from the visitor") + 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") + // 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) + 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) + } + + if asked.header.Get(requestIDHeader) == "the-visitor's" { + t.Error("the question carries the visitor's id") + } + + // Exactly these: visit's connection has the visitor send Connection: close. + want := http.Header{} + want.Set("Connection", "close") + want.Set("X-Test", "from the visitor") + 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) + + if !maps.EqualFunc(asked.header, want, slices.Equal) { + t.Errorf("the question's headers are %v, want %v", asked.header, want) + } +} + +func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { + t.Parallel() + + const visitor = "203.0.113.9" + + for _, tc := range []struct { + name, trusted, client, proto, forwarded string + }{ + { + "from a trusted proxy", localhost + "/32", visitor, "https", + visitor + ", " + localhost, + }, + {"from a peer not trusted", "10.0.0.0/8", localhost, "http", localhost}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := 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{ + trustedProxies: tc.trusted, + }) + + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Header.Set(forwardedForHeader, visitor) + 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.header.Get(forwardedForHeader) != visitor || + asked.header.Get(forwardedProtoHeader) != tc.proto { + t.Errorf("the question's headers are %v", asked.header) + } + + if line := out.requestLine(t); line["client_ip"] != tc.client { + t.Errorf("line %v, want client_ip %s", line, tc.client) + } + + if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded { + t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded) + } + }) + } +} + +// randomBytes returns n random bytes. +func randomBytes(n int) []byte { + body := make([]byte, n) + _, _ = rand.Read(body) // it never returns an error + + return body +} + +func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) { + t.Parallel() + + for _, size := range []int{ + 1, questionBodyMaxBytes - 1, questionBodyMaxBytes, questionBodyMaxBytes + 1, + 5 * questionBodyMaxBytes, + } { + for _, announced := range []bool{true, false} { + name := strconv.Itoa(size) + " bytes, length announced: " + + strconv.FormatBool(announced) + + t.Run(name, func(t *testing.T) { + t.Parallel() + + body := randomBytes(size) + servewaf := startServeWAF(t, allow) + received := make(chan []byte, 1) + app := startApp(t, func(w http.ResponseWriter, r *http.Request) { + whole, _ := io.ReadAll(r.Body) + received <- whole + + greet(w, r) + }) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body)) + if !announced { + // Go's client sends a body of unknown length in chunks. + req.ContentLength = -1 + } + + wantGreeting(t, visit(t, req)) + + asked := servewaf.question(t) + want := body[:min(size, questionBodyMaxBytes)] + wantQuestionBody(t, asked, want, size, announced) + + if !bytes.Equal(<-received, body) { + t.Error("the app did not get the whole body") + } + + if report := servewaf.report(t); report.RequestBytes != int64(size) { + t.Errorf("report %+v, of %d bytes", report, size) + } + }) + } + } +} + +// wantQuestionBody checks the body a question carries, want, and how it gives +// the length of the visitor's body, of size bytes. +func wantQuestionBody( + t *testing.T, asked question, want []byte, size int, announced bool, +) { + t.Helper() + + if !bytes.Equal(asked.body, want) { + t.Errorf("the question carries %d bytes, want the first %d", len(asked.body), + len(want)) + } + + values := asked.header.Values(proxy.ContentLengthHeader) + + if announced { + if !slices.Equal(values, []string{strconv.Itoa(size)}) || + asked.contentLength != int64(len(want)) { + t.Errorf("announced %v and length %d, want %d and %d", values, + asked.contentLength, size, len(want)) + } + + return + } + + if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) { + t.Errorf("announced %v and encoded %v, want none and chunked", values, + asked.transferEncoding) + } +} + +func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, deny) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + // Far more announced than sent. Go's server does not wait for that much, so + // the answer comes at once. + conn := dial(t, addr) + send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+ + strings.Repeat("x", questionBodyMaxBytes)) + + if status := readStatus(t, conn); status != http.StatusForbidden { + t.Errorf("status %d, want servewaf's 403", status) + } + + if report := servewaf.report(t); report.RequestBytes != questionBodyMaxBytes { + t.Errorf("report %+v, want the first part's bytes", report) + } +} + +func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, framing, body string + encoding []string + }{ + { + chunked, "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", "body", + []string{chunked}, + }, + { + "chunked and empty", "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "", + []string{chunked}, + }, + {"with its length", announcedLength(4) + "body", "body", nil}, + } { + for _, method := range []string{ + http.MethodGet, http.MethodDelete, http.MethodPost, + } { + t.Run(method+" "+tc.name, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, deny) + addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) + + conn := dial(t, addr) + send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) + + if status := readStatus(t, conn); status != http.StatusForbidden { + t.Errorf("status %d, want servewaf's 403", status) + } + + asked := servewaf.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", + asked.body, asked.transferEncoding, tc.body, tc.encoding) + } + }) + } + } +} + +func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { + t.Parallel() + + for _, method := range []string{http.MethodTrace, "PROPFIND", "LOCK", "BREW"} { + t.Run(method, func(t *testing.T) { + t.Parallel() + + servewaf := 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) + + got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) + if got.status != http.StatusMethodNotAllowed || + got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" { + t.Errorf("answered %+v, want 405 naming the methods passed on", got) + } + + line := out.requestLine(t) + if line["status"] != float64(http.StatusMethodNotAllowed) || + line["disposition"] != nil || line["default_disposition"] != nil { + t.Errorf("line %v, want status 405 and no disposition", line) + } + + if len(servewaf.questions) != 0 { + t.Error("servewaf was asked") + } + + servewaf.wantNoReport(t) + }) + } +} + +func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { + t.Parallel() + + for _, method := range []string{ + http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions, + http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY", + } { + t.Run(method, func(t *testing.T) { + t.Parallel() + + reached := make(chan string, 1) + servewaf := startServeWAF(t, allow) + app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { + reached <- r.Method + }) + addr, _ := startRProxy(t, servewaf.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 { + t.Errorf("servewaf was asked about %s", asked.method) + } + + if atApp := <-reached; atApp != method { + t.Errorf("the app was asked %s", atApp) + } + }) + } +} + +func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { + t.Parallel() + + const host = "Host: site.example\r\n\r\n" + + for name, request := range map[string]string{ + "a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host, + "a target of a URL": "GET http://site.example/ HTTP/1.1\r\n" + host, + "a host Go will not send": "GET http://a 0 && res.Body != http.NoBody && res.ContentLength > maxBytes { + rq.refuse(http.StatusBadGateway) + + return errResponseTooLarge + } + + // 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, + // as the app sent none. + if res.Header.Get("Content-Type") == "" { + rq.out.Header()["Content-Type"] = nil + } + + res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq} + rq.setResponseDeadline() + + return nil +} + +// answerError is ReverseProxy's ErrorHandler: the request could not be passed to +// the app, or the app's answer cannot be passed on. +func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) { + if rq.upgraded != nil { + // The app switched protocols, but the switch was not passed on, and + // ReverseProxy leaves the app's connection open when it refuses it. + _ = rq.upgraded.Close() + } + + status := int(rq.refused.Load()) + if status == 0 { + if rq.in.Context().Err() != nil { + // The visitor went away or stopped its sending side. This panic has Go's + // server close the connection without an answer, and log nothing; the + // deferred finish still runs. + panic(http.ErrAbortHandler) + } + + rq.h.processLog.Warn("request to the app failed", "error", err.Error()) + + status = http.StatusBadGateway + } + + rq.answer(status) +} + +// answer sends rproxy's own answer, unless the response has already started. +func (rq *request) answer(status int) { + rq.refused.CompareAndSwap(0, int64(status)) + + if rq.out.status != 0 { + return // too late to answer: the connection can only be cut + } + + // A visitor found too slow is read no more; any other may go on sending until + // its time is up, so that Go's server can read the rest of the body and end + // the request cleanly. + deadline := rq.requestDeadline() + if status == http.StatusRequestTimeout { + deadline = time.Now() + } + + rq.prepareOwnAnswer(deadline) + + header := rq.out.Header() + header.Set("Content-Type", plainText) + header.Set("X-Content-Type-Options", "nosniff") + rq.send(status, []byte(http.StatusText(status)+"\n")) +} + +// send gives the visitor an answer rproxy holds whole: status, the headers set so +// far and body. Go's server would otherwise send so short an answer only once the +// handler has returned, after finish has written the log line and sent the report. +// Without Content-Length, the flush would send the body in chunks, the last of +// which would still wait for the handler. A HEAD's answer has no body, so any +// Content-Length it has stays as servewaf gave it. +func (rq *request) send(status int, body []byte) { + if rq.in.Method != http.MethodHead { + rq.out.Header().Set("Content-Length", strconv.Itoa(len(body))) + } + + rq.out.WriteHeader(status) + _, _ = rq.out.Write(body) + _ = rq.rc.Flush() +} + +// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the +// reading of a body that has not arrived whole ends at deadline, and the answer +// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app +// used that time up first. +func (rq *request) prepareOwnAnswer(deadline time.Time) { + rq.stopReadingBody(deadline) + rq.endRequest() + + limit := rq.h.config.MaxResponseTime + if limit > 0 { + _ = rq.rc.SetWriteDeadline(time.Now().Add(limit)) + } +} + +// refuse records status, unless an earlier refusal was, and ends the request to +// the app, if one was made. +func (rq *request) refuse(status int) { + rq.refused.CompareAndSwap(0, int64(status)) + + if rq.cancel != nil { + rq.cancel() + } +} + +// finish ends the request's time limits, writes its log line and, if servewaf +// answered the question, sends the report. ctx is the request's own context. +func (rq *request) finish(ctx context.Context) { + rq.stopTimers() + + if rq.refused.Load() == 0 { + rq.stopReadingBody(rq.requestDeadline()) + } + + requestEnd := rq.endRequest() + end := time.Now() + requestBytes, responseBytes := rq.bytesEachWay() + + err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{ + Time: requestlog.FormatTime(rq.start), + RequestID: rq.id, + ClientIP: rq.client.String(), + Host: rq.in.Host, + Method: rq.in.Method, + Path: rules.Path(rq.in), + Query: rq.in.URL.RawQuery, + Status: rq.out.status, + RequestBytes: requestBytes, + ResponseBytes: responseBytes, + DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)), + DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)), + Disposition: rq.disposition, + DefaultDisposition: rq.byDefault, + }) + if err != nil { + rq.h.processLog.Error("writing the request log failed", "error", err.Error()) + } + + if !rq.answered { + return + } + + report := &proxy.Report{ + RequestID: rq.id, + Status: min(rq.out.status, reportStatusMax), + RequestBytes: requestBytes, + ResponseBytes: responseBytes, + DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)), + } + if !rq.upstreamStart.IsZero() { + report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart)) + } + + rq.h.sendReport(ctx, report) +} + +// bytesEachWay returns the body bytes of the request and of the answer. For an +// upgraded connection, such as a WebSocket, which has closed by then, what it +// carried from the visitor counts with the request's and what it carried from the +// app with the answer's. +func (rq *request) bytesEachWay() (int64, int64) { + var requestBytes int64 + if rq.body != nil { + requestBytes = rq.body.bytes.Load() + } + + responseBytes := rq.out.bytes + + if rq.upgraded != nil { + requestBytes += rq.upgraded.toApp.Load() + responseBytes += rq.upgraded.fromApp.Load() + } + + return requestBytes, responseBytes +} + +// requestDeadline is when the visitor must have sent its whole request, or zero +// when SWRP_MAX_REQUEST_TIME_MS is off. +func (rq *request) requestDeadline() time.Time { + limit := rq.h.config.MaxRequestTime + if limit == 0 { + return time.Time{} + } + + return rq.start.Add(limit) +} + +// stopReadingBody ends, at deadline, the reading of a visitor's body that has not +// arrived whole: Go's server then reads no more of it, and closes the connection +// after the answer. +func (rq *request) stopReadingBody(deadline time.Time) { + if rq.body == nil || rq.body.received.Load() { + return + } + + _ = rq.rc.SetReadDeadline(deadline) +} + +// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the +// request's headers being read until the app has been sent the whole request. +func (rq *request) startRequestTimer() { + deadline := rq.requestDeadline() + if deadline.IsZero() { + return + } + + rq.mu.Lock() + defer rq.mu.Unlock() + + rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut) +} + +// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the +// request is still on its way to the app. The answer names the side rproxy was +// waiting on at that moment: 408 when it was waiting for the visitor to send more +// of its body, 504 when it was waiting for the app to be reached or to take what +// it had. +func (rq *request) requestTimedOut() { + rq.mu.Lock() + defer rq.mu.Unlock() + + if rq.timersStopped { + return + } + + if rq.body == nil || !rq.body.waiting.Load() { + // Once the app has answered, the transport, ReverseProxy and Go's server + // each close the visitor's body, and closing it reads what is left of a + // body under 256 KiB without requestBody.Read, so waiting is false while + // that read waits for the visitor. That read ends now, before refuse: + // should rproxy still answer itself, answer then sets the deadline it + // gives its own answers. + if !rq.answerStarted.IsZero() { + rq.stopReadingBody(time.Now()) + } + + rq.refuse(http.StatusGatewayTimeout) + + return + } + + rq.refuse(http.StatusRequestTimeout) + // The transport gives up on the app only once its Read of the visitor's body + // returns, so that Read is ended now. The lock keeps this from reaching the + // connection after the request is handled. + _ = rq.rc.SetReadDeadline(time.Now()) +} + +// gotFirstResponseByte is called once the first byte of the app's answer has +// arrived. +func (rq *request) gotFirstResponseByte() { + rq.mu.Lock() + defer rq.mu.Unlock() + + rq.answerStarted = time.Now() +} + +// wroteRequest is called once the app has been sent the whole request: +// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts. +func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) { + if info.Err != nil { + return // the transport gives up, or tries again + } + + requestEnd := rq.endRequest() + + rq.mu.Lock() + defer rq.mu.Unlock() + + if rq.timersStopped { + return + } + + stopTimer(rq.requestTimer) + + limit := rq.h.config.MaxResponseTime + if limit > 0 { + rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)), + rq.responseTimedOut) + } +} + +// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the +// app has sent its whole answer. +func (rq *request) responseTimedOut() { + rq.mu.Lock() + defer rq.mu.Unlock() + + if !rq.timersStopped { + rq.refuse(http.StatusGatewayTimeout) + } +} + +// responseReceived is called once the app has sent its whole answer. An app may +// answer before the visitor has sent its whole body, as with a 404 it sends +// without reading it. The time limits then run on: until the transport's read of +// the body returns, Go's server cannot send the answer, and +// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops +// sending. +func (rq *request) responseReceived() { + if rq.body != nil && !rq.body.received.Load() { + return + } + + rq.stopTimers() +} + +// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the +// visitor: the app's answer must reach the visitor within it of the end of the +// request. +func (rq *request) setResponseDeadline() { + requestEnd := rq.endRequest() + + limit := rq.h.config.MaxResponseTime + if limit > 0 { + _ = rq.rc.SetWriteDeadline(requestEnd.Add(limit)) + } +} + +// endRequest notes now as the end of the request, unless an end is noted, and +// returns the end noted. +func (rq *request) endRequest() time.Time { + rq.mu.Lock() + defer rq.mu.Unlock() + + if rq.requestEnd.IsZero() { + rq.requestEnd = time.Now() + } + + return rq.requestEnd +} + +// stopTimers stops the request's time limits and keeps any from starting later: +// the app's answer is complete, the connection upgraded, or the request handled. +func (rq *request) stopTimers() { + rq.mu.Lock() + defer rq.mu.Unlock() + + rq.timersStopped = true + stopTimer(rq.requestTimer) + stopTimer(rq.responseTimer) +} + +// stopTimer stops t, which is nil when its time limit is off. +func stopTimer(t *time.Timer) { + if t != nil { + t.Stop() + } +} diff --git a/internal/rproxy/rproxy.go b/internal/rproxy/rproxy.go new file mode 100644 index 0000000..e662104 --- /dev/null +++ b/internal/rproxy/rproxy.go @@ -0,0 +1,228 @@ +// Package rproxy is the reverse proxy in front of one app that asks servewaf +// about each request. It passes a request servewaf allows to the app and the +// app's answer back, within the size and time limits. Any other answer from +// servewaf 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. rproxy itself refuses a request with a +// method it does not pass on, or one it cannot put to servewaf as the visitor +// sent it. It keeps nothing between requests and never touches disk. "rproxy" in +// SPEC.md says what it does. +package rproxy + +import ( + "context" + "io" + "log" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + + "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/proxy" +) + +// How rproxy keeps connections to the app and to servewaf open between +// requests. +const ( + idleConns = 100 + idleConnTimeout = 90 * time.Second +) + +// The limits on a connection from a visitor that have no setting. +const ( + // requestHeaderMaxBytes is the largest request line and headers a visitor + // may send. + requestHeaderMaxBytes = 32 << 10 + // goServerExtraHeaderBytes is how far past MaxHeaderBytes Go's server reads + // before it refuses. + goServerExtraHeaderBytes = 4 << 10 + // idleTimeout is how long a kept-open connection may wait for its next + // request. + idleTimeout = 120 * time.Second +) + +// adminPrefix starts the path of every request for smallwebwaf itself, which +// never reaches the app. +const adminPrefix = "/_smallwebwaf/" + +// allowedMethods lists the methods rproxy passes on, as an Allow header gives +// them. It answers any other method 405 itself. +const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" + +// Params are what New needs. +type Params struct { + Config *config.RProxy + // RequestLog receives one short JSON line per request. + RequestLog io.Writer + // ProcessLog receives the process's own messages. + ProcessLog *slog.Logger +} + +// Server is the server `smallwebwaf rproxy` runs. +type Server struct { + *http.Server + + h *handler +} + +// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses +// a request line and headers over requestHeaderMaxBytes, with 431, closes a +// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while +// the headers arrive. The handler applies the time and size limits from then +// on. +func New(params Params) *Server { + backend := params.Config.WAFBackend + errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) + h := &handler{ + config: params.Config, + requestLog: params.RequestLog, + processLog: params.ProcessLog, + errorLog: errorLog, + transport: newTransport(), + serveWAFTransport: newServeWAFTransport(), + reportURL: (&url.URL{ + Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath, + }).String(), + } + + return &Server{ + Server: &http.Server{ + Addr: params.Config.ListenAddr, + Handler: h, + ErrorLog: errorLog, + ReadHeaderTimeout: params.Config.MaxRequestTime, + IdleTimeout: idleTimeout, + MaxHeaderBytes: requestHeaderMaxBytes - goServerExtraHeaderBytes, + }, + h: h, + } +} + +// WaitForReports returns once the reports being sent have gone or failed, +// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of +// a WebSocket that closes while rproxy stops, for one. +func (s *Server) WaitForReports() { + s.h.reportsMu.Lock() + s.h.reportsStopped = true + s.h.reportsMu.Unlock() + + s.h.reports.Wait() +} + +// handler is the proxy. It holds what every request shares; what belongs to +// one request is in a request. +type handler struct { + config *config.RProxy + requestLog io.Writer + processLog *slog.Logger + errorLog *log.Logger + // transport carries requests to the app, and serveWAFTransport questions and + // reports to servewaf. + transport http.RoundTripper + serveWAFTransport http.RoundTripper + // reportURL is where the reports go. + reportURL string + // reports waits for the reports being sent. reportsMu guards starting one + // against reportsStopped, which is set once nothing is to wait for another. + reports sync.WaitGroup + reportsMu sync.Mutex + reportsStopped bool +} + +// newTransport returns what carries requests to the app. It never goes through +// a proxy named in the environment, and leaves answers compressed or not as they +// were sent. +func newTransport() *http.Transport { + return &http.Transport{ + MaxIdleConns: idleConns, + MaxIdleConnsPerHost: idleConns, + IdleConnTimeout: idleConnTimeout, + DisableCompression: true, + } +} + +// newServeWAFTransport returns what carries questions and reports to servewaf. +// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade +// header, or a Connection header other than close or keep-alive, and that would +// read as servewaf giving no answer. +func newServeWAFTransport() *http.Transport { + transport := newTransport() + transport.Protocols = new(http.Protocols) + transport.Protocols.SetHTTP1(true) + + return transport +} + +// ServeHTTP handles one request: it answers the health check itself, refuses a +// request with a method it does not pass on or one it cannot put to servewaf as +// the visitor sent it, and for any other request reads the first part of the +// body, asks servewaf, and does what the answer says. Once the answer has ended, +// it writes the request's log line and sends its report. +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 { + rq.answerHealth() + + return + } + + if !slices.Contains(strings.Split(allowedMethods, ", "), r.Method) { + rq.out.Header().Set("Allow", allowedMethods) + rq.answer(http.StatusMethodNotAllowed) + + return + } + + if !canBeAsked(r) { + rq.answer(http.StatusBadRequest) + + return + } + + if !rq.readFirstPartOfBody() { + return + } + + answer, err := rq.ask(r.Context()) + if err != nil { + rq.handleNoUsableAnswer(r.Context(), err) + + return + } + + rq.noteAnswer(answer) + + switch rq.disposition { + case proxy.DispositionAllow: + rq.passToApp(r.Context()) + case proxy.DispositionClose: + // This panic has Go's server close the connection without an answer, + // and log nothing; the deferred finish still runs. + panic(http.ErrAbortHandler) + default: // a deny, an error, or an answer without a valid disposition + rq.answerWith(answer) + } +} + +// passToApp passes the request on to the app, unless a limit or its path keeps +// it from the app. servewaf answers a path under adminPrefix itself, so such a +// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the +// request's own context. +func (rq *request) passToApp(ctx context.Context) { + maxBytes := rq.h.config.MaxRequestSize + + switch { + case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes): + rq.answer(http.StatusRequestEntityTooLarge) + case strings.HasPrefix(rq.in.URL.Path, adminPrefix): + rq.answer(http.StatusNotFound) + default: + rq.forward(ctx) + } +} diff --git a/internal/rproxy/rproxy_test.go b/internal/rproxy/rproxy_test.go new file mode 100644 index 0000000..2e65e00 --- /dev/null +++ b/internal/rproxy/rproxy_test.go @@ -0,0 +1,1758 @@ +package rproxy_test + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "maps" + "net" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "sync" + "testing" + "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" +) + +const ( + // waitLimit bounds how long a test waits for what should happen. + waitLimit = 10 * time.Second + // pollInterval is how often a test looks for a log line. + pollInterval = 10 * time.Millisecond + // localhost is where every test server listens, and so the address rproxy + // sees each test's requests come from. + localhost = "127.0.0.1" + // site is the host the tests' requests ask for. + site = "site.example" + // greeting is what the tests' app answers by default. + greeting = "hello from the app" + // nowhere is a URL nothing listens at: port 9 is the discard service, which + // nothing on a test host runs. + nowhere = "http://127.0.0.1:9" +) + +// The settings the tests set. +const ( + timeoutSetting = "SWRP_TIMEOUT_MS" + defaultDisposition = "SWRP_DEFAULT_DISPOSITION" + trustedProxies = "SWRP_TRUSTED_PROXIES" + maxRequestSize = "SWRP_MAX_REQUEST_SIZE" + maxResponseSize = "SWRP_MAX_RESPONSE_SIZE" + maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS" + maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS" +) + +// The headers the tests set and look at. +const ( + dispositionHeader = proxy.DispositionHeader + forwardedForHeader = "X-Forwarded-For" + forwardedProtoHeader = "X-Forwarded-Proto" + requestIDHeader = "X-Request-ID" + asnHeader = "X-Client-Asn" + countryHeader = "X-Client-Country" + contentLengthHeader = "Content-Length" + // fromServeWAFHeader is a header the stand-in for servewaf adds to a deny. + fromServeWAFHeader = "X-From-Servewaf" + // addedHeaderValue is the value of a header a test adds to an answer, to see it + // reach the visitor. + addedHeaderValue = "yes" +) + +// What the stand-in for servewaf answers. +const ( + dispositionAllow = proxy.DispositionAllow + dispositionDeny = proxy.DispositionDeny + dispositionClose = proxy.DispositionClose + // deniedBody is the body of a deny. + deniedBody = "refused by servewaf" +) + +// output collects what rproxy writes on stdout. +type output struct { + mu sync.Mutex + buf bytes.Buffer +} + +// Write adds lines rproxy writes. +func (o *output) Write(p []byte) (int, error) { + o.mu.Lock() + defer o.mu.Unlock() + + return o.buf.Write(p) +} + +// requestLine waits for the request log line of a test's one request. +func (o *output) requestLine(t *testing.T) map[string]any { + t.Helper() + + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + o.mu.Lock() + text := o.buf.String() + o.mu.Unlock() + + for line := range strings.Lines(text) { + var fields map[string]any + + err := json.Unmarshal([]byte(line), &fields) + if err != nil { + t.Fatalf("output line %q is not JSON: %v", line, err) + } + + if fields["type"] == "request" { + return fields + } + } + + time.Sleep(pollInterval) + } + + t.Fatal("no request log line") + + return nil +} + +// has reports whether what rproxy has written so far holds text. +func (o *output) has(text string) bool { + o.mu.Lock() + defer o.mu.Unlock() + + return strings.Contains(o.buf.String(), text) +} + +// waitFor waits until what rproxy has written holds text. +func (o *output) waitFor(t *testing.T, text string) { + t.Helper() + + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + if o.has(text) { + return + } + + time.Sleep(pollInterval) + } + + t.Fatalf("rproxy did not write %q", text) +} + +// heldLog is a request log whose Write waits until release is closed. +type heldLog struct { + release chan struct{} +} + +// Write waits until release is closed. +func (l heldLog) Write(p []byte) (int, error) { + <-l.release + + return len(p), nil +} + +// question is what the stand-in for servewaf received as a question. +type question struct { + method, target, host string + header http.Header + body []byte + contentLength int64 + transferEncoding []string +} + +// serveWAF is a stand-in for servewaf that answers each question as answer +// does, and keeps the questions and the reports it gets. +type serveWAF struct { + *httptest.Server + + questions chan question + reports chan proxy.Report +} + +// startServeWAF starts a stand-in for servewaf. +func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF { + t.Helper() + + s := &serveWAF{ + questions: make(chan question, 10), + reports: make(chan proxy.Report, 10), + } + s.Server = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(proxy.ClientHeader) == "" { + s.takeReport(t, w, r) + + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read the question's body: %v", err) + } + + s.questions <- question{ + r.Method, r.RequestURI, r.Host, r.Header.Clone(), body, r.ContentLength, + r.TransferEncoding, + } + + answer(w, r) + })) + t.Cleanup(s.Close) + + return s +} + +// takeReport keeps a report and answers it as servewaf does. +func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + + var report proxy.Report + + err := json.NewDecoder(r.Body).Decode(&report) + if err != nil || r.Method != http.MethodPost || r.URL.Path != proxy.ReportPath { + t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err) + } + + w.WriteHeader(http.StatusNoContent) + + s.reports <- report +} + +// wantNoReport checks that no report comes for a moment. +func (s *serveWAF) wantNoReport(t *testing.T) { + t.Helper() + + select { + case report := <-s.reports: + t.Errorf("report %+v, want none", report) + case <-time.After(10 * pollInterval): + } +} + +// report waits for a report. +func (s *serveWAF) report(t *testing.T) proxy.Report { + t.Helper() + + select { + case report := <-s.reports: + return report + case <-time.After(waitLimit): + t.Fatal("no report") + + return proxy.Report{} + } +} + +// question waits for a question. +func (s *serveWAF) question(t *testing.T) question { + t.Helper() + + select { + case asked := <-s.questions: + return asked + case <-time.After(waitLimit): + t.Fatal("no question") + + return question{} + } +} + +// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each +// question as answer does and each report as report does, and returns its URL. +func startServeWAFAnsweringReports( + t *testing.T, answer, report http.HandlerFunc, +) string { + t.Helper() + + backend := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(proxy.ClientHeader) != "" { + answer(w, r) + + return + } + + report(w, r) + })) + t.Cleanup(backend.Close) + + return backend.URL +} + +// allow answers a question with allow. +func allow(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(dispositionHeader, dispositionAllow) + w.WriteHeader(http.StatusOK) +} + +// deny answers a question with deny, status 403 and a body of its own. +func deny(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(dispositionHeader, dispositionDeny) + w.Header().Set(fromServeWAFHeader, addedHeaderValue) + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, deniedBody) +} + +// startApp starts app as the app rproxy passes requests to. +func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server { + t.Helper() + + server := httptest.NewServer(app) + t.Cleanup(server.Close) + + return server +} + +// greet is an app that reads the request's body and answers greeting. +func greet(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + _, _ = io.WriteString(w, greeting) +} + +// startRProxy starts rproxy with servewaf at backend and the app at appURL, and +// the settings in env on top of the defaults. It returns where rproxy listens +// and what it writes. +func startRProxy( + t *testing.T, backend, appURL string, env map[string]string, +) (string, *output) { + t.Helper() + + addr, out, _ := startServer(t, backend, appURL, env) + + return addr, out +} + +// startServer is startRProxy, and returns the server as well. +func startServer( + t *testing.T, backend, appURL string, env map[string]string, +) (string, *output, *rproxy.Server) { + t.Helper() + + out := &output{} + addr, server := startServerLogging(t, backend, appURL, env, out, out) + + return addr, out, server +} + +// startServerLogging is startServer with rproxy's request lines going to +// requestLog and its process lines to processLog. +func startServerLogging( + t *testing.T, backend, appURL string, env map[string]string, + requestLog, processLog io.Writer, +) (string, *rproxy.Server) { + t.Helper() + + settings := map[string]string{ + "SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL, + } + maps.Copy(settings, env) + + cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) { + value, ok := settings[name] + + return value, ok + }) + if err != nil { + t.Fatalf("settings %v: %v", settings, err) + } + + server := rproxy.New(rproxy.Params{ + Config: cfg, + RequestLog: requestLog, + ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo), + }) + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + go func() { + _ = server.Serve(listener) + }() + + t.Cleanup(func() { + _ = server.Close() + + server.WaitForReports() + }) + + return listener.Addr().String(), server +} + +// answered is the visitor's answer. +type answered struct { + status int + header http.Header + body string +} + +// visit sends req as a visitor, on a connection of its own, and returns the +// answer. +func visit(t *testing.T, req *http.Request) answered { + t.Helper() + + transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true} + defer transport.CloseIdleConnections() + + res, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("request: %v", err) + } + + defer func() { + _ = res.Body.Close() + }() + + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + return answered{res.StatusCode, res.Header, string(body)} +} + +// newRequest returns a request for target at addr. +func newRequest( + t *testing.T, method, addr, target string, body io.Reader, +) *http.Request { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target, + body) + if err != nil { + t.Fatalf("new request: %v", err) + } + + return req +} + +// get sends a GET for target at addr, and returns the answer. +func get(t *testing.T, addr, target string) answered { + t.Helper() + + return visit(t, newRequest(t, http.MethodGet, addr, target, http.NoBody)) +} + +// wantGreeting checks that the answer is the app's. +func wantGreeting(t *testing.T, got answered) { + t.Helper() + + if got.status != http.StatusOK || got.body != greeting { + t.Errorf("answered %d %q, want the app's greeting", got.status, got.body) + } +} + +// dial opens a connection to addr, closed as the test ends. +func dial(t *testing.T, addr string) net.Conn { + t.Helper() + + conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + + t.Cleanup(func() { + _ = conn.Close() + }) + + err = conn.SetDeadline(time.Now().Add(waitLimit)) + if err != nil { + t.Fatalf("set the deadline: %v", err) + } + + return conn +} + +// send writes text to conn. +func send(t *testing.T, conn net.Conn, text string) { + t.Helper() + + _, err := io.WriteString(conn, text) + if err != nil { + t.Fatalf("send: %v", err) + } +} + +// readStatus reads the answer's status from conn. +func readStatus(t *testing.T, conn net.Conn) int { + t.Helper() + + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + _ = res.Body.Close() + + return res.StatusCode +} + +func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(asnHeader, "AS64496") + w.Header().Set(countryHeader, "DE") + allow(w, r) + }) + + seen := make(chan *http.Request, 1) + app := startApp(t, func(w http.ResponseWriter, r *http.Request) { + seen <- r.Clone(t.Context()) + + w.Header().Set("X-App", addedHeaderValue) + w.WriteHeader(http.StatusTeapot) + _, _ = io.WriteString(w, greeting) + }) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" + + req := newRequest(t, http.MethodGet, addr, target, http.NoBody) + req.URL.Opaque = pathGoReencodes + req.Host = site + req.Header.Set(asnHeader, "AS1") + req.Header.Set(countryHeader, "XX") + req.Header.Set(requestIDHeader, "the-visitor's") + + got := visit(t, req) + if got.status != http.StatusTeapot || got.body != greeting || + got.header.Get("X-App") != addedHeaderValue { + t.Errorf("answered %+v, want the app's answer", got) + } + + received := <-seen + asked := servewaf.question(t) + + if received.RequestURI != target || received.Host != site { + t.Errorf("the app saw %s for %s", received.RequestURI, received.Host) + } + + for name, want := range map[string]string{ + asnHeader: "AS64496", + countryHeader: "DE", + forwardedForHeader: localhost, + requestIDHeader: asked.header.Get(requestIDHeader), + } { + if received.Header.Get(name) != want { + t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want) + } + } +} + +func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) { + t.Parallel() + + removed := []string{ + "X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri", + "X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert", + "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", + } + + servewaf := 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{ + trustedProxies: "10.0.0.0/8", + }) + + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Host = site + req.Header.Set(forwardedForHeader, "198.51.100.1") + req.Header.Set("X-Forwarded-Host", "claimed.example") + req.Header.Set(forwardedProtoHeader, "https") + + for _, name := range removed { + req.Header.Set(name, "claimed") + } + + wantGreeting(t, visit(t, req)) + + header := <-seen + + for name, want := range map[string]string{ + forwardedForHeader: localhost, + "X-Forwarded-Host": site, + forwardedProtoHeader: "http", + } { + if got := header.Values(name); !slices.Equal(got, []string{want}) { + t.Errorf("the app saw %s %q, want %q", name, got, want) + } + } + + for _, name := range removed { + if got := header.Values(name); len(got) != 0 { + t.Errorf("the app saw %s %q, want none", name, got) + } + } +} + +func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { + t.Parallel() + + servewaf := 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{ + trustedProxies: localhost + "/32", + }) + + sent := map[string]string{ + "Forwarded": "for=198.51.100.1", + "X-Forwarded-Host": "public.example", + forwardedProtoHeader: "https", + "X-Real-Ip": "198.51.100.1", + } + + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Host = site + + for name, value := range sent { + req.Header.Set(name, value) + } + + wantGreeting(t, visit(t, req)) + + header := <-seen + + for name, want := range sent { + if got := header.Values(name); !slices.Equal(got, []string{want}) { + t.Errorf("the app saw %s %q, want %q", name, got, want) + } + } +} + +func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header()["Content-Type"] = nil + _, _ = io.WriteString(w, "") + }) + _, _, server := startServer(t, servewaf.URL, app.URL, nil) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) + w := httptest.NewRecorder() + + server.Handler.ServeHTTP(w, req) + + // Go's server guesses a type when the first part of the body goes before the + // headers are flushed, which is a matter of timing here. So this looks for what + // stops it: the key with no value. + values, there := w.Header()["Content-Type"] + if !there || len(values) != 0 { + t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value", + values, there) + } +} + +func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + backend func(t *testing.T) string + }{ + {"servewaf allows", func(t *testing.T) string { + t.Helper() + + return startServeWAF(t, allow).URL + }}, + {"servewaf is down", func(*testing.T) string { return nowhere }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + 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, tc.backend(t), app.URL, nil) + + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Header.Set(asnHeader, "AS1") + req.Header.Set(countryHeader, "XX") + wantGreeting(t, visit(t, req)) + + header := <-seen + if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 { + t.Errorf("the app saw the visitor's lookup headers: %v", header) + } + }) + } +} + +func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) { + t.Parallel() + + servewaf := 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) + + got := get(t, addr, "/") + if got.status != http.StatusForbidden || got.body != deniedBody || + got.header.Get(fromServeWAFHeader) != addedHeaderValue { + t.Errorf("answered %+v, want servewaf's answer", got) + } + + if len(got.header.Values(dispositionHeader)) != 0 { + t.Errorf("the visitor got %s", dispositionHeader) + } + + report := servewaf.report(t) + if report.Status != http.StatusForbidden || + report.ResponseBytes != int64(len(deniedBody)) || + report.DurationUpstreamTotal != 0 { + t.Errorf("report %+v, of a request denied", report) + } + + line := out.requestLine(t) + if line["disposition"] != dispositionDeny || line["default_disposition"] != nil { + t.Errorf("line %v, want servewaf's deny", line) + } +} + +func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, deny) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody)) + if got.status != http.StatusForbidden || + got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) { + t.Errorf("answered %+v, want servewaf's 403 and its length", got) + } +} + +func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) { + t.Parallel() + + const host = " HTTP/1.1\r\nHost: site.example\r\n\r\n" + + for _, tc := range []struct { + name, request, body string + status int + }{ + {"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}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + log := heldLog{release: make(chan struct{})} + servewaf := startServeWAF(t, deny) + app := startApp(t, greet) + addr, _ := startServerLogging(t, servewaf.URL, app.URL, nil, log, io.Discard) + + t.Cleanup(func() { close(log.release) }) + + conn := dial(t, addr) + send(t, conn, tc.request) + + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + defer func() { + _ = res.Body.Close() + }() + + body, err := io.ReadAll(res.Body) + if err != nil || res.StatusCode != tc.status || string(body) != tc.body { + t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err, + tc.status, tc.body) + } + }) + } +} + +func TestRProxysOwnAnswerIsPlainText(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.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") + + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + _ = res.Body.Close() + + if res.Header.Get("Content-Type") != "text/plain; charset=utf-8" || + res.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Errorf("the answer's headers are %v", res.Header) + } +} + +func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) { + t.Parallel() + + servewaf := 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) + + conn := dial(t, addr) + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + text, err := io.ReadAll(conn) + if err != nil || len(text) != 0 { + t.Errorf("read %q (%v), want the connection closed without an answer", + text, err) + } + + report := servewaf.report(t) + if report.Status != 0 || report.ResponseBytes != 0 { + t.Errorf("report %+v, of a visitor sent nothing", report) + } + + line := out.requestLine(t) + if line["disposition"] != dispositionClose || line["status"] != 0.0 { + t.Errorf("line %v, want close and status 0", line) + } +} + +func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) { + t.Parallel() + + backend := startServeWAFAnsweringReports(t, + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(dispositionHeader, dispositionClose) + }, + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + // A negative time would be about a microsecond, which one request would seldom + // show. + for range 100 { + addr, out := startRProxy(t, backend, nowhere, nil) + + conn := dial(t, addr) + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + line := out.requestLine(t) + + duration, ok := line["duration_response"].(float64) + if !ok || duration < 0 { + t.Errorf("line %v, want a duration_response that is not negative", line) + } + } +} + +// answeringWith returns what starts a stand-in for servewaf that answers each +// question as answer does, and returns its URL. +func answeringWith(answer http.HandlerFunc) func(t *testing.T) string { + return func(t *testing.T) string { + t.Helper() + + return startServeWAF(t, answer).URL + } +} + +// noAnswers are the ways servewaf can leave the decision to the default. +func noAnswers() map[string]func(t *testing.T) string { + return map[string]func(t *testing.T) string{ + "no connection": func(*testing.T) string { return nowhere }, + "no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) { + panic(http.ErrAbortHandler) + }), + "a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + }), + "headers followed by a stalled body": answeringWith( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(dispositionHeader, dispositionDeny) + w.WriteHeader(http.StatusForbidden) + _ = http.NewResponseController(w).Flush() + + <-r.Context().Done() + }), + "an answer cut short": answeringWith( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(dispositionHeader, dispositionDeny) + w.Header().Set(contentLengthHeader, "100") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, deniedBody) + // Go's server drops on this panic what it has not flushed. + _ = http.NewResponseController(w).Flush() + + panic(http.ErrAbortHandler) + }), + } +} + +func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, disposition, body string + status int + }{ + {"a refusal", dispositionDeny, deniedBody, http.StatusForbidden}, + {"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests}, + {"servewaf's 400", "", "refused\n", http.StatusBadRequest}, + {"a 500", "", "broken\n", http.StatusInternalServerError}, + {"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable}, + {"no header", "", "who knows\n", http.StatusOK}, + {"an unknown disposition", "maybe", "who knows\n", http.StatusOK}, + } { + for _, setting := range []string{dispositionAllow, dispositionDeny} { + t.Run(tc.name+" with the default "+setting, func(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + if tc.disposition != "" { + w.Header().Set(dispositionHeader, tc.disposition) + } + + w.Header().Set(fromServeWAFHeader, addedHeaderValue) + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + }) + 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{ + defaultDisposition: setting, + }) + + got := get(t, addr, "/") + if got.status != tc.status || got.body != tc.body || + got.header.Get(fromServeWAFHeader) != addedHeaderValue { + t.Errorf("answered %+v, want servewaf's answer", got) + } + + if len(got.header.Values(dispositionHeader)) != 0 { + t.Errorf("the visitor got %s", dispositionHeader) + } + + line := out.requestLine(t) + if line["status"] != float64(tc.status) || line["default_disposition"] != nil { + t.Errorf("line %v, want status %d and no default", line, tc.status) + } + + if out.has("servewaf gave no answer") { + t.Error("rproxy warned that servewaf gave no answer") + } + }) + } + } +} + +func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) { + t.Parallel() + + servewaf := 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) + + if got := get(t, addr, "/"); got.status != http.StatusBadRequest { + t.Errorf("answered %+v, want servewaf's 400", got) + } + + if line := out.requestLine(t); line["disposition"] != nil { + t.Errorf("line %v, want no disposition", line) + } + + servewaf.wantNoReport(t) +} + +func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header()["Content-Type"] = nil + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "broken") + }) + addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) + + got := get(t, addr, "/") + if contentType, sent := got.header["Content-Type"]; sent { + t.Errorf("the visitor got the Content-Type %v", contentType) + } +} + +func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) { + t.Parallel() + + const size = 1 << 20 + + servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(make([]byte, size)) + }) + addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) + + got := get(t, addr, "/") + if got.status != http.StatusInternalServerError || len(got.body) != size { + t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d", + got.status, len(got.body), size) + } +} + +// startServeWAFWriting starts a stand-in for servewaf that reads each question and +// answers it with text, as it is, and returns its URL. It can send what Go's +// server would not. +func startServeWAFWriting(t *testing.T, text string) string { + t.Helper() + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + t.Cleanup(func() { + _ = listener.Close() + }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + + _ = conn.SetDeadline(time.Now().Add(waitLimit)) + _, _ = http.ReadRequest(bufio.NewReader(conn)) + _, _ = io.WriteString(conn, text) + _ = conn.Close() + } + }() + + return "http://" + listener.Addr().String() +} + +// overOneMiB answers a question with a body over the most rproxy reads, and with +// disposition unless it is "". +func overOneMiB(disposition string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + if disposition != "" { + w.Header().Set(dispositionHeader, disposition) + } + + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write(make([]byte, 1<<20+1)) + } +} + +// switchAndHold answers a 101 and holds the connection open until rproxy closes +// it, or until long after any test has stopped waiting. +func switchAndHold(w http.ResponseWriter, _ *http.Request) { + conn, _, err := http.NewResponseController(w).Hijack() + if err != nil { + return + } + + defer func() { + _ = conn.Close() + }() + + _ = conn.SetDeadline(time.Now().Add(2 * waitLimit)) + _, _ = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+ + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + _, _ = io.Copy(io.Discard, conn) +} + +func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) { + t.Parallel() + + for name, backend := range map[string]func(t *testing.T) string{ + "a deny over 1 MiB": answeringWith(overOneMiB(dispositionDeny)), + "an allow over 1 MiB": answeringWith(overOneMiB(dispositionAllow)), + "no header over 1 MiB": answeringWith(overOneMiB("")), + "a status below 100": func(t *testing.T) string { + t.Helper() + + return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n") + }, + "a 101 that holds its connection open": answeringWith(switchAndHold), + } { + for _, setting := range []string{dispositionAllow, dispositionDeny} { + t.Run(name+" with the default "+setting, func(t *testing.T) { + t.Parallel() + + app := startApp(t, func(http.ResponseWriter, *http.Request) { + t.Error("the app was asked") + }) + addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ + defaultDisposition: setting, + }) + + if got := get(t, addr, "/"); got.status != http.StatusBadGateway { + t.Errorf("answered %+v, want 502", got) + } + + line := out.requestLine(t) + if line["status"] != float64(http.StatusBadGateway) || + line["disposition"] != nil || line["default_disposition"] != nil { + t.Errorf("line %v, want status 502 and no disposition", line) + } + + out.waitFor(t, "servewaf's answer is dropped") + }) + } + } +} + +func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, overOneMiB(dispositionDeny)) + addr, out := startRProxy(t, servewaf.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) +} + +func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) { + t.Parallel() + + for name, backend := range noAnswers() { + t.Run("fails open on "+name, func(t *testing.T) { + t.Parallel() + + app := startApp(t, greet) + addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ + timeoutSetting: "200", + }) + + wantGreeting(t, get(t, addr, "/")) + + line := out.requestLine(t) + if line["disposition"] != dispositionAllow || + line["default_disposition"] != true { + t.Errorf("line %v, want allow by default", line) + } + }) + + t.Run("fails closed on "+name, func(t *testing.T) { + t.Parallel() + + app := startApp(t, func(http.ResponseWriter, *http.Request) { + t.Error("the app was asked") + }) + addr, out := startRProxy(t, backend(t), app.URL, map[string]string{ + timeoutSetting: "200", + defaultDisposition: dispositionDeny, + }) + + got := get(t, addr, "/") + if got.status != http.StatusServiceUnavailable { + t.Errorf("answered %+v, want 503", got) + } + + line := out.requestLine(t) + if line["disposition"] != dispositionDeny || + line["default_disposition"] != true { + t.Errorf("line %v, want deny by default", line) + } + }) + } +} + +func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) { + t.Parallel() + + for _, disposition := range []string{dispositionAllow, dispositionDeny} { + t.Run("with the default "+disposition, func(t *testing.T) { + t.Parallel() + + servewaf := 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{ + 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) + + _ = conn.Close() + + line := out.requestLine(t) + if line["status"] != 0.0 || line["disposition"] != nil || + line["default_disposition"] != nil { + t.Errorf("line %v, want status 0 and no disposition", line) + } + + if out.has("servewaf gave no answer") { + t.Error("rproxy warned that servewaf gave no answer") + } + + servewaf.wantNoReport(t) + }) + } +} + +func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) { + t.Parallel() + + app := startApp(t, greet) + addr, out := startRProxy(t, nowhere, app.URL, nil) + + wantGreeting(t, get(t, addr, "/")) + out.waitFor(t, "servewaf gave no answer") +} + +func TestSendsNoReportWithoutAnAnswer(t *testing.T) { + t.Parallel() + + servewaf := 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{ + timeoutSetting: "200", + }) + + wantGreeting(t, get(t, addr, "/")) + out.requestLine(t) + servewaf.wantNoReport(t) +} + +func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) { + t.Parallel() + + app := startApp(t, func(http.ResponseWriter, *http.Request) { + t.Error("the app was asked") + }) + addr, _ := startRProxy(t, nowhere, app.URL, nil) + + got := get(t, addr, "/_smallwebwaf/metrics") + if got.status != http.StatusNotFound { + t.Errorf("answered %+v, want 404", got) + } +} + +func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, nowhere, app.URL, nil) + + got := get(t, addr, proxy.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 { + 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) + + if len(servewaf.questions) != 0 { + t.Error("servewaf was asked about the health check") + } +} + +func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + backend := startServeWAFAnsweringReports(t, deny, + func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + + w.WriteHeader(http.StatusNoContent) + }) + + app := startApp(t, greet) + addr, _ := startRProxy(t, backend, app.URL, map[string]string{ + timeoutSetting: "60000", + }) + + t.Cleanup(func() { close(release) }) + + // Go's server reads the next request on a connection only once the handler has + // returned, so the second answer comes only if the handler does not wait for + // the first report, which the stand-in leaves unanswered. + conn := dial(t, addr) + reader := bufio.NewReader(conn) + + for range 2 { + send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + res, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + + if res.StatusCode != http.StatusForbidden { + t.Errorf("status %d, want servewaf's 403", res.StatusCode) + } + } +} + +func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + backend := startServeWAFAnsweringReports(t, allow, + func(w http.ResponseWriter, _ *http.Request) { + <-release + + w.WriteHeader(http.StatusNoContent) + }) + + app := startApp(t, greet) + addr, out := startRProxy(t, backend, app.URL, map[string]string{ + timeoutSetting: "1000", + }) + + t.Cleanup(func() { close(release) }) + + wantGreeting(t, get(t, addr, "/")) + out.waitFor(t, "a report is dropped") +} + +func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) { + t.Parallel() + + backend := startServeWAFAnsweringReports(t, allow, + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }) + + app := startApp(t, greet) + addr, out := startRProxy(t, backend, app.URL, nil) + + wantGreeting(t, get(t, addr, "/")) + out.waitFor(t, "a report is dropped") +} + +func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) { + t.Parallel() + + backend := startServeWAFAnsweringReports(t, allow, switchAndHold) + app := startApp(t, greet) + addr, out := startRProxy(t, backend, app.URL, map[string]string{ + timeoutSetting: "60000", + }) + + wantGreeting(t, get(t, addr, "/")) + out.waitFor(t, "a report is dropped") +} + +func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out, server := startServer(t, servewaf.URL, app.URL, nil) + + server.WaitForReports() + + wantGreeting(t, get(t, addr, "/")) + out.requestLine(t) + servewaf.wantNoReport(t) +} + +func TestReportGivesWhatHappened(t *testing.T) { + t.Parallel() + + const body = "the visitor's body" + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, servewaf.URL, app.URL, nil) + + wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/", + strings.NewReader(body)))) + + report := servewaf.report(t) + + if report.RequestID == "" || report.Status != http.StatusOK || + report.RequestBytes != int64(len(body)) || + report.ResponseBytes != int64(len(greeting)) || + report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 || + report.DurationUpstreamTotal > report.DurationTotal { + t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting)) + } + + line := out.requestLine(t) + if line["request_id"] != report.RequestID { + t.Errorf("line %v, report %+v, want the same request id", line, report) + } +} + +func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) { + t.Parallel() + + reportGotThere := make(chan struct{}) + release := make(chan struct{}) + backend := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(proxy.ClientHeader) != "" { + allow(w, r) + + return + } + + close(reportGotThere) + + select { + case <-release: + case <-r.Context().Done(): + } + + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(backend.Close) + + app := startApp(t, greet) + addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{ + timeoutSetting: "5000", + }) + + wantGreeting(t, get(t, addr, "/")) + + select { + case <-reportGotThere: + case <-time.After(waitLimit): + t.Fatal("no report") + } + + waited := make(chan struct{}) + + go func() { + server.WaitForReports() + close(waited) + }() + + select { + case <-waited: + t.Error("it did not wait for the report") + case <-time.After(10 * pollInterval): + } + + close(release) + + select { + case <-waited: + case <-time.After(waitLimit): + t.Error("it still waits, the report having been answered") + } +} + +func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) { + t.Parallel() + + servewaf := startServeWAF(t, allow) + app := startApp(t, echoAfterUpgrade) + addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + maxRequestTime: "2000", + maxResponseTime: "2000", + }) + + conn := dial(t, addr) + send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+ + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + + reader := bufio.NewReader(conn) + + res, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatalf("read the answer to the upgrade: %v", err) + } + + _ = res.Body.Close() + + if res.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols) + } + + // Past both time limits, the connection still carries what is sent. + time.Sleep(2100 * time.Millisecond) + + const message = "still here\n" + + send(t, conn, message) + + echoed, err := reader.ReadString('\n') + if err != nil || echoed != message { + t.Errorf("echo %q (%v), want %q", echoed, err, message) + } + + _ = conn.Close() + + report := servewaf.report(t) + if report.Status != http.StatusSwitchingProtocols || + report.RequestBytes != int64(len(message)) || + report.ResponseBytes != int64(len(message)) { + t.Errorf("report %+v, of %d bytes each way", report, len(message)) + } + + if line := out.requestLine(t); line["status"] != float64(res.StatusCode) { + t.Errorf("line %v, want status 101", line) + } +} + +func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) { + t.Parallel() + + const goodbye = "sent after the visitor stopped sending\n" + + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { + conn, buffered, err := http.NewResponseController(w).Hijack() + if err != nil { + return + } + + defer func() { + _ = conn.Close() + }() + + _, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + _ = buffered.Flush() + + _, _ = io.Copy(io.Discard, buffered) + + _, _ = buffered.WriteString(goodbye) + _ = buffered.Flush() + }) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + conn := dial(t, addr) + send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+ + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + + reader := bufio.NewReader(conn) + + res, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatalf("read the answer to the upgrade: %v", err) + } + + _ = res.Body.Close() + + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + t.Fatalf("the connection is a %T", conn) + } + + err = tcpConn.CloseWrite() + if err != nil { + t.Fatalf("stop sending: %v", err) + } + + text, err := io.ReadAll(reader) + if err != nil || string(text) != goodbye { + t.Errorf("read %q (%v), want %q", text, err, goodbye) + } +} + +// This test does not run in parallel. Go closes a connection nothing refers to at +// its next garbage collection, which other tests' allocations would soon start, and +// that would hide a connection left open. +// +//nolint:paralleltest // see above +func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) { + closed := make(chan error, 1) + servewaf := startServeWAF(t, allow) + app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { + conn, buffered, err := http.NewResponseController(w).Hijack() + if err != nil { + closed <- err + + return + } + + defer func() { + _ = conn.Close() + }() + + // Not the protocol the visitor asked for, so ReverseProxy refuses the switch. + _, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\nUpgrade: another\r\n\r\n") + _ = buffered.Flush() + + _ = conn.SetReadDeadline(time.Now().Add(waitLimit)) + + _, err = buffered.ReadByte() + closed <- err + }) + addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) + + conn := dial(t, addr) + send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+ + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + + if status := readStatus(t, conn); status != http.StatusBadGateway { + t.Errorf("status %d, want 502", status) + } + + err := <-closed + if !errors.Is(err, io.EOF) { + t.Errorf("the app's read gave %v, want its connection closed", err) + } +} + +// echoAfterUpgrade is an app that switches protocols on request, and then sends +// back each line it receives. +func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) { + conn, buffered, err := http.NewResponseController(w).Hijack() + if err != nil { + return + } + + defer func() { + _ = conn.Close() + }() + + _, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + _ = buffered.Flush() + + for { + line, err := buffered.ReadString('\n') + if err != nil { + return + } + + _, _ = buffered.WriteString(line) + _ = buffered.Flush() + } +} + +func TestEachRequestGetsAFreshID(t *testing.T) { + t.Parallel() + + const fromTraefik = "an-id-from-traefik" + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ + trustedProxies: localhost + "/32", + }) + + ids := make([]string, 0, 2) + + for range 2 { + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Header.Set(requestIDHeader, fromTraefik) + wantGreeting(t, visit(t, req)) + + ids = append(ids, servewaf.question(t).header.Get(requestIDHeader)) + } + + if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] { + t.Errorf("ids %v, want two fresh ones", ids) + } +} + +func TestLogLineGivesTheRequestAsSent(t *testing.T) { + t.Parallel() + + const ( + path = "/search%2Fall" + query = "q=secret&token=%zz" + body = "the visitor's body" + ) + + servewaf := startServeWAF(t, allow) + app := startApp(t, greet) + addr, out := startRProxy(t, servewaf.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) + + for name, want := range map[string]any{ + "type": "request", + "request_id": asked.header.Get(requestIDHeader), + "client_ip": localhost, + "host": site, + "method": http.MethodPost, + "path": path, + "query": query, + "status": float64(http.StatusOK), + "request_bytes": float64(len(body)), + "response_bytes": float64(len(greeting)), + "disposition": dispositionAllow, + } { + if line[name] != want { + t.Errorf("%s is %v, want %v", name, line[name], want) + } + } + + if line["default_disposition"] != nil { + t.Errorf("default_disposition is %v, want it left out", line["default_disposition"]) + } + + for _, name := range []string{"duration_request", "duration_response"} { + duration, ok := line[name].(float64) + if !ok || duration <= 0 { + t.Errorf("%s is %v, want a time", name, line[name]) + } + } + + text, _ := line["time"].(string) + + _, err := time.Parse(time.RFC3339, text) + if err != nil { + t.Errorf("time: %v", err) + } +} diff --git a/internal/rproxy/servewaf.go b/internal/rproxy/servewaf.go new file mode 100644 index 0000000..3328e18 --- /dev/null +++ b/internal/rproxy/servewaf.go @@ -0,0 +1,266 @@ +package rproxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "strings" + + "sneak.berlin/go/smallwebwaf/internal/proxy" +) + +// answerBodyMaxBytes is the most of the body of servewaf's answer that is read. +// It is far above any answer's. +const answerBodyMaxBytes = 1 << 20 + +// reportAnswerMaxBytes is the most of the answer to a report that is read, +// which has no body when servewaf takes the report. +const reportAnswerMaxBytes = 4 << 10 + +var ( + errCannotPassOn = errors.New("the answer cannot be passed to the visitor") + errReportRefused = errors.New("the report was answered") +) + +// servewafAnswer is what servewaf answered to a question. +type servewafAnswer struct { + status int + header http.Header + body []byte +} + +// noteAnswer notes what servewaf answered. An answer without a valid disposition +// 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) + + switch disposition { + case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose: + rq.disposition = disposition + rq.answered = true + rq.asn = answer.header.Get(asnHeader) + rq.country = answer.header.Get(countryHeader) + } +} + +// handleNoUsableAnswer deals with a request whose question ended in err, which +// leaves no answer to act on. ctx is the request's own context. +func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { + switch { + case ctx.Err() != nil: + // The visitor went away while servewaf was being asked. servewaf has not + // failed, and nothing is noted. This panic has Go's server close the + // connection without an answer, and log nothing; the deferred finish still + // runs. + panic(http.ErrAbortHandler) + case errors.Is(err, errCannotPassOn): + rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error()) + rq.answer(http.StatusBadGateway) + default: + // servewaf cannot be connected to, does not answer at all, or does not answer + // within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past + // SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets + // SWRP_DEFAULT_DISPOSITION here. 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. + rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error()) + + rq.disposition = rq.h.config.DefaultDisposition + rq.byDefault = true + + if rq.disposition == proxy.DispositionAllow { + rq.passToApp(ctx) + } else { + rq.answer(http.StatusServiceUnavailable) + } + } +} + +// ask sends servewaf the question, and returns its answer, which must have come +// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes +// away. +func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { + ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) + defer cancel() + + question, err := rq.newQuestion(ctx) + if err != nil { + return nil, err + } + + res, err := rq.h.serveWAFTransport.RoundTrip(question) + if err != nil { + return nil, fmt.Errorf("send the question: %w", err) + } + + defer func() { + _ = res.Body.Close() + }() + + // The body of a 101 is the connection itself, and the time limit does not end + // a read of that. Go's client also takes a status below 100, which Go's server + // panics when asked to send. + if res.StatusCode < http.StatusOK { + return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read the answer: %w", err) + } + + if len(body) > answerBodyMaxBytes { + return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn) + } + + return &servewafAnswer{res.StatusCode, res.Header, body}, nil +} + +// canBeAsked reports whether Go's client can send servewaf r as the visitor sent +// it. It cannot send these as they came: +// - a target that does not start with /, or that starts with //; +// - an empty Host, for which it sends servewaf's own address; +// - a Host holding an IPv6 zone, which it removes; +// - more than one User-Agent, of which it sends the first; +// - an empty User-Agent, which it leaves out, the one case that still goes to +// servewaf. +func canBeAsked(r *http.Request) bool { + target, host := r.RequestURI, r.Host + hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%") + + return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && + host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1 +} + +// newQuestion returns the question about the request, as "The question" in +// SPEC.md gives it: the visitor's request as the visitor sent it, with the part +// of the body it holds and rproxy's headers. +func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) { + in := rq.in + + var held []byte + if rq.body != nil { + held = rq.body.held + } + + question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL + ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held)) + if err != nil { + return nil, fmt.Errorf("make the question: %w", err) + } + + backend := question.URL + target := *in.URL + target.Scheme = backend.Scheme + target.Host = backend.Host + setPathAsSent(&target, in) + question.URL = &target + question.Host = in.Host + + // The length NewRequestWithContext set is that of what is held. This line makes + // a body whose length the visitor did not announce go chunked, as "The question" + // in SPEC.md requires. + 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) + + announced := in.Header.Get("Content-Length") + if announced != "" { + question.Header.Set(proxy.ContentLengthHeader, announced) + } + + question.Header.Set(requestIDHeader, rq.id) + question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted)) + + // Go's client adds a User-Agent of its own to a request without the key. + _, sent := question.Header["User-Agent"] + if !sent { + question.Header["User-Agent"] = nil + } + + return question, nil +} + +// answerWith gives the visitor servewaf's answer, less its disposition. +func (rq *request) answerWith(answer *servewafAnswer) { + rq.prepareOwnAnswer(rq.requestDeadline()) + + header := rq.out.Header() + maps.Copy(header, answer.header) + header.Del(proxy.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. + if header.Get("Content-Type") == "" { + header["Content-Type"] = nil + } + + rq.send(answer.status, answer.body) +} + +// 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) { + h.reportsMu.Lock() + defer h.reportsMu.Unlock() + + if h.reportsStopped { + return + } + + h.reports.Go(func() { + err := h.postReport(context.WithoutCancel(ctx), report) + if err != nil { + h.processLog.Warn("a report is dropped", "error", err.Error()) + } + }) +} + +// 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 { + body, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("encode the report: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, h.config.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL + ctx, http.MethodPost, h.reportURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("make the request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + res, err := h.serveWAFTransport.RoundTrip(req) + if err != nil { + return fmt.Errorf("send the report: %w", err) + } + + // The body of a 101 is the connection itself, and the time limit does not end + // a read of that. + if res.StatusCode >= http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes)) + } + + _ = res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("%w %s", errReportRefused, res.Status) + } + + return nil +} diff --git a/internal/smallwebwaf/rproxy.go b/internal/smallwebwaf/rproxy.go new file mode 100644 index 0000000..0c95194 --- /dev/null +++ b/internal/smallwebwaf/rproxy.go @@ -0,0 +1,99 @@ +package smallwebwaf + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "os" + + "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/requestlog" + "sneak.berlin/go/smallwebwaf/internal/rproxy" +) + +// RunRProxy is `smallwebwaf rproxy`: it reads rproxy's settings, then serves +// requests until ctx is done. Then it gives the requests in progress +// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It +// returns the process's exit status, 1 when rproxy cannot start. Its process +// messages name the host's name as instance, and it writes those at info or +// more severe: rproxy has no setting for either. +func RunRProxy(ctx context.Context, params Params) int { + hostname, _ := os.Hostname() // "" when the host has no name to give + processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo) + + cfg, err := config.RProxyFromEnvironment(params.LookupEnv) + if err != nil { + processLog.Error("invalid setting", "error", err.Error()) + + return 1 + } + + server := rproxy.New(rproxy.Params{ + Config: cfg, + RequestLog: params.Stdout, + ProcessLog: processLog, + }) + + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr) + if err != nil { + processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error()) + + return 1 + } + + processLog.Info("starting", + "version", params.Version, + "address", listener.Addr().String(), + "settings", cfg) + + return serveRProxy(ctx, server, listener, processLog) +} + +// serveRProxy serves requests on listener until ctx is done, and then stops as +// RunRProxy says. +func serveRProxy( + ctx context.Context, server *rproxy.Server, listener net.Listener, + processLog *slog.Logger, +) int { + served := make(chan error, 1) + + go func() { + served <- server.Serve(listener) + }() + + select { + case err := <-served: + processLog.Error("serving failed", "error", err.Error()) + + return 1 + case <-ctx.Done(): + } + + processLog.Info("stopping") + + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), + shutdownTimeout) + defer cancel() + + err := server.Shutdown(shutdownCtx) + if err != nil { + processLog.Warn("requests still in progress were cut off", + "error", err.Error()) + + _ = server.Close() + } + + err = <-served + if !errors.Is(err, http.ErrServerClosed) { + processLog.Error("serving failed", "error", err.Error()) + + return 1 + } + + server.WaitForReports() + processLog.Info("stopped") + + return 0 +} diff --git a/internal/smallwebwaf/rproxy_test.go b/internal/smallwebwaf/rproxy_test.go new file mode 100644 index 0000000..4774c0f --- /dev/null +++ b/internal/smallwebwaf/rproxy_test.go @@ -0,0 +1,787 @@ +package smallwebwaf_test + +import ( + "bufio" + "context" + "encoding/pem" + "io" + "maps" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/proxy" + "sneak.berlin/go/smallwebwaf/internal/smallwebwaf" +) + +const ( + // wafBackend is the setting that names servewaf to rproxy. + wafBackend = "SWRP_WAF_BACKEND" + // rproxyListenAddr is the setting that says where rproxy listens. + rproxyListenAddr = "SWRP_LISTEN_ADDR" + // rproxyTimeout is the setting that says how long rproxy waits for servewaf. + rproxyTimeout = "SWRP_TIMEOUT_MS" + // bannedProbe is a path the default rule file bans a client for. + bannedProbe = "/.env" + // otherClient is a client the tests do not ban. + otherClient = "198.51.100.7" +) + +// runRProxy runs `smallwebwaf rproxy` with the settings in env until ctx is +// done, and returns its exit status. +func runRProxy(ctx context.Context, env map[string]string, out *output) int { + return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{ + Version: testVersion, + LookupEnv: func(name string) (string, bool) { + value, ok := env[name] + + return value, ok + }, + Stdout: out, + }) +} + +// startRProxy runs rproxy with servewaf at backend and the app at appURL, and +// returns its URL and what it writes. It believes X-Forwarded-For from the +// tests. It stops as the test ends, and that it stopped in order is checked. +func startRProxy(t *testing.T, backend, appURL string) (string, *output) { + t.Helper() + + ctx, stop := context.WithCancel(t.Context()) + out := &output{} + exited := make(chan int, 1) + + go func() { + exited <- runRProxy(ctx, map[string]string{ + rproxyListenAddr: localhost + ":0", + "SWRP_UPSTREAM_URL": appURL, + "SWRP_TRUSTED_PROXIES": localhost + "/32", + wafBackend: backend, + }, out) + }() + + t.Cleanup(func() { + stop() + + select { + case status := <-exited: + if status != 0 { + t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + } + case <-time.After(waitLimit): + t.Error("rproxy still running after being told to stop") + } + + out.line(t, "msg", "stopped") + }) + + addr, _ := out.line(t, "msg", "starting")["address"].(string) + + return "http://" + addr, out +} + +// startServeWAF runs servewaf with the settings in env, and returns its address +// and URL, what it writes, and what stops it, which checks that it stopped in +// order. +func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) { + t.Helper() + + ctx, stop := context.WithCancel(t.Context()) + out := &output{} + exited := make(chan int, 1) + + go func() { + exited <- runAs(ctx, true, env, out) + }() + + addr, _ := out.line(t, "msg", "starting")["address"].(string) + + return addr, out, func() { + stop() + + select { + case status := <-exited: + if status != 0 { + t.Fatalf("servewaf's exit status %d, want 0; output:\n%s", status, out.text()) + } + case <-time.After(waitLimit): + t.Fatal("servewaf still running after being told to stop") + } + } +} + +// serveWAFSettings are servewaf's settings with the default rule file, to listen +// at any free port. +func serveWAFSettings(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + listenAddr: localhost + ":0", + stateDir: t.TempDir(), + rulesDir: filepath.Join("..", "..", "share", "rules.d"), + } +} + +func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) { + t.Parallel() + + addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + defer stop() + + backend := "http://" + addr + first, firstOut := startRProxy(t, backend, startApp(t)) + second, _ := startRProxy(t, backend, startApp(t)) + + wantStatus(t, first+"/", placed, http.StatusOK) + wantStatus(t, second+"/", placed, http.StatusOK) + + // The default rule file bans a probe for this. + wantStatus(t, first+bannedProbe, placed, http.StatusForbidden) + wantStatus(t, second+"/", placed, http.StatusForbidden) + wantStatus(t, second+"/", otherClient, http.StatusOK) + + // 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 { + t.Errorf("rproxy's line is %v", line) + } + + id, _ := line["request_id"].(string) + decision := serveWAFLine(t, serveWAFOut, id, "decision") + report := serveWAFLine(t, serveWAFOut, id, "report") + + if decision["host"] != line["host"] || decision["client_ip"] != placed || + report["status"] != float64(http.StatusForbidden) { + t.Errorf("servewaf's lines are %v and %v", decision, report) + } +} + +// serveWAFLine waits for servewaf's line with request id and servewaf_line kind. +func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any { + t.Helper() + + deadline := time.Now().Add(waitLimit) + for time.Now().Before(deadline) { + line := out.findLine(t, func(fields map[string]any) bool { + return fields["request_id"] == id && fields["servewaf_line"] == kind + }) + if line != nil { + return line + } + + time.Sleep(pollInterval) + } + + t.Fatalf("no %s line for %s in:\n%s", kind, id, out.text()) + + return nil +} + +func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) { + t.Parallel() + + env := serveWAFSettings(t) + addr, _, stop := startServeWAF(t, env) + site, out := startRProxy(t, "http://"+addr, startApp(t)) + + // A visitor who keeps asking throughout, and is never refused. + visitorDone := make(chan struct{}) + visitorStop := make(chan struct{}) + + var refused atomic.Int64 + + go func() { + defer close(visitorDone) + + for { + select { + case <-visitorStop: + return + case <-time.After(pollInterval): + } + + if !answersNormally(t.Context(), site+"/", otherClient) { + refused.Add(1) + } + } + }() + + wantStatus(t, site+bannedProbe, placed, http.StatusForbidden) + wantStatus(t, site+"/", placed, http.StatusForbidden) + stop() + + // With servewaf down, the site answers, and rproxy says the default did it. + wantStatus(t, site+"/after-stop", placed, http.StatusOK) + + line := out.line(t, "path", "/after-stop") + if line["disposition"] != proxy.DispositionAllow || + line["default_disposition"] != true { + t.Errorf("rproxy's line is %v", line) + } + + // Back at its address, with its state files, it applies the ban again. + restarted := maps.Clone(env) + restarted[listenAddr] = addr + _, _, stop = startServeWAF(t, restarted) + + defer stop() + + wantStatus(t, site+"/", placed, http.StatusForbidden) + + close(visitorStop) + <-visitorDone + + if refused.Load() != 0 { + t.Errorf("the site refused its visitor %d times", refused.Load()) + } +} + +// answersNormally reports whether a request to url from the client at from, as +// X-Forwarded-For names it, gets the app's answer. +func answersNormally(ctx context.Context, url, from string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return false + } + + req.Header.Set("X-Forwarded-For", from) + + transport := &http.Transport{} + defer transport.CloseIdleConnections() + + res, err := transport.RoundTrip(req) + if err != nil { + return false + } + + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + + return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting +} + +// sendRaw sends request, as it is, to rproxy at site, and returns the status of +// the answer. +func sendRaw(t *testing.T, site, request string) int { + t.Helper() + + conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", + strings.TrimPrefix(site, "http://")) + if err != nil { + t.Fatalf("dial: %v", err) + } + + defer func() { + _ = conn.Close() + }() + + err = conn.SetDeadline(time.Now().Add(waitLimit)) + if err != nil { + t.Fatalf("set the deadline: %v", err) + } + + _, err = io.WriteString(conn, request) + if err != nil { + t.Fatalf("send: %v", err) + } + + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("read the answer: %v", err) + } + + _ = res.Body.Close() + + return res.StatusCode +} + +func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) { + t.Parallel() + + addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + defer stop() + + site, _ := startRProxy(t, "http://"+addr, startApp(t)) + + // Read as a further request, this body would ban otherClient on every site. + const smuggledID = "smuggled-request" + + smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" + + proxy.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"+ + "X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+ + strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n") + if status != http.StatusOK { + t.Errorf("status %d, want the app's 200", status) + } + + // Read as a further request, it would act straight after the answer. + for range 20 { + wantStatus(t, site+"/", otherClient, http.StatusOK) + time.Sleep(pollInterval) + } + + line := serveWAFOut.findLine(t, func(fields map[string]any) bool { + return fields["request_id"] == smuggledID + }) + if line != nil { + t.Errorf("servewaf read the body as a request: %v", line) + } +} + +func TestATargetThatCannotGoToServeWAFIsRefusedForABannedClient(t *testing.T) { + t.Parallel() + + addr, _, stop := startServeWAF(t, serveWAFSettings(t)) + defer stop() + + site, out := startRProxy(t, "http://"+addr, startApp(t)) + + wantStatus(t, site+bannedProbe, placed, http.StatusForbidden) + + status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+ + "X-Forwarded-For: "+placed+"\r\n\r\n") + if status != http.StatusBadRequest { + t.Errorf("status %d, want 400", status) + } + + line := out.line(t, "path", "admin") + if line["disposition"] != nil || line["default_disposition"] != nil { + t.Errorf("rproxy's line is %v, want no disposition", line) + } +} + +func TestAClientWithAZoneIsTheClientWithout(t *testing.T) { + t.Parallel() + + const zoned = "2001:db8::1%eth0" + + addr, _, stop := startServeWAF(t, serveWAFSettings(t)) + defer stop() + + site, out := startRProxy(t, "http://"+addr, startApp(t)) + + wantStatus(t, site+bannedProbe, zoned, http.StatusForbidden) + wantStatus(t, site+"/", zoned, http.StatusForbidden) + wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden) + + line := out.line(t, "path", bannedProbe) + if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil { + t.Errorf("rproxy's line is %v", line) + } +} + +func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) { + t.Parallel() + + const status = 799 + + app := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + })) + t.Cleanup(app.Close) + + addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t)) + defer stop() + + site, out := startRProxy(t, "http://"+addr, app.URL) + + wantStatus(t, site+"/", placed, status) + + line := out.line(t, "path", "/") + if line["status"] != float64(status) { + t.Errorf("rproxy's line is %v, want the status the visitor got", line) + } + + id, _ := line["request_id"].(string) + + report := serveWAFLine(t, serveWAFOut, id, "report") + if report["status"] != 599.0 { + t.Errorf("servewaf's line is %v, want status 599", report) + } +} + +// startRProxyProcess runs `smallwebwaf rproxy` in a process of its own, with the +// settings in env, each as NAME=value. It returns where rproxy listens, the +// process, and what rproxy writes. +func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) { + t.Helper() + + binary, err := os.Executable() + if err != nil { + t.Fatalf("find the test binary: %v", err) + } + + ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit) + t.Cleanup(cancel) + + out := &output{} + cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary + cmd.Stdout = out + + cmd.Env = append(os.Environ(), + commandForMain+"=rproxy", + "SWRP_LISTEN_ADDR="+localhost+":0") + cmd.Env = append(cmd.Env, env...) + + err = cmd.Start() + if err != nil { + t.Fatalf("start: %v", err) + } + + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + addr, _ := out.line(t, "msg", "starting")["address"].(string) + + return addr, cmd, out +} + +func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) { + t.Parallel() + + protocols := make(chan string, 10) + backend := httptest.NewUnstartedServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(proxy.ClientHeader) != "" { + protocols <- r.Proto + + w.Header().Set(proxy.DispositionHeader, proxy.DispositionDeny) + w.WriteHeader(http.StatusForbidden) + } + })) + backend.EnableHTTP2 = true + backend.StartTLS() + t.Cleanup(backend.Close) + + // rproxy has no setting for the certificates it trusts, so it is told of this + // one as Go's client is told of any, in a process of its own. + certificates := filepath.Join(t.TempDir(), "servewaf.pem") + + err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", Bytes: backend.Certificate().Raw, + }), 0o600) + if err != nil { + t.Fatalf("write the certificate: %v", err) + } + + addr, _, _ := startRProxyProcess(t, + wafBackend+"="+backend.URL, + "SWRP_UPSTREAM_URL="+startApp(t), + "SSL_CERT_FILE="+certificates) + + for name, headers := range map[string]string{ + "no such header": "", + "Upgrade": "Upgrade: anything\r\n", + "Connection": "Connection: anything\r\n", + "a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n", + } { + status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n") + if status != http.StatusForbidden { + t.Errorf("with %s: status %d, want servewaf's 403", name, status) + } + + select { + case protocol := <-protocols: + if protocol != "HTTP/1.1" { + t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol) + } + case <-time.After(waitLimit): + t.Errorf("with %s: servewaf was not asked", name) + } + } +} + +func TestRProxyProcessLinesNameTheHostAndComeAtInfo(t *testing.T) { + t.Parallel() + + hostname, err := os.Hostname() + if err != nil { + t.Fatalf("the host's name: %v", err) + } + + // Nothing listens at the backend's address; no request asks it. + _, out := startRProxy(t, "http://127.0.0.1:9", startApp(t)) + + line := out.line(t, "msg", "starting") + if line["instance"] != hostname || line["level"] != "INFO" { + t.Errorf("rproxy's line is %v, want instance %s at INFO", line, hostname) + } +} + +func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) { + t.Parallel() + + reportGotThere := make(chan struct{}) + 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) + + return + } + + close(reportGotThere) + <-release + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(backend.Close) + + ctx, stop := context.WithCancel(t.Context()) + defer stop() + + env := map[string]string{ + rproxyListenAddr: localhost + ":0", + "SWRP_UPSTREAM_URL": startApp(t), + rproxyTimeout: "60000", + wafBackend: backend.URL, + } + out := &output{} + exited := make(chan int, 1) + + go func() { + exited <- runRProxy(ctx, env, out) + }() + + addr, _ := out.line(t, "msg", "starting")["address"].(string) + + wantStatus(t, "http://"+addr+"/", placed, http.StatusOK) + + select { + case <-reportGotThere: + case <-time.After(waitLimit): + t.Fatal("no report") + } + + stop() + + select { + case <-exited: + t.Error("rproxy stopped with a report on its way") + close(release) + + return + case <-time.After(10 * pollInterval): + } + + close(release) + + select { + case status := <-exited: + if status != 0 { + t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + } + case <-time.After(waitLimit): + t.Error("rproxy still running, the report having been answered") + } +} + +// sendAndKeepOpen sends request to rproxy at addr, and returns the connection. A +// read from it ends with an error once waitLimit is up. +func sendAndKeepOpen(t *testing.T, addr, request string) net.Conn { + t.Helper() + + conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + + t.Cleanup(func() { + _ = conn.Close() + }) + + err = conn.SetDeadline(time.Now().Add(waitLimit)) + if err != nil { + t.Fatalf("set the deadline: %v", err) + } + + _, err = io.WriteString(conn, request) + if err != nil { + t.Fatalf("send: %v", err) + } + + return conn +} + +func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) { + t.Parallel() + + const ( + cutOffAfter = 5 * time.Second + upstreamSetting = "SWRP_UPSTREAM_URL" + ) + + asked := make(chan struct{}) + release := make(chan struct{}) + app := httptest.NewServer(http.HandlerFunc( + func(http.ResponseWriter, *http.Request) { + close(asked) + <-release + })) + t.Cleanup(app.Close) + t.Cleanup(func() { close(release) }) + + backend := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow) + })) + t.Cleanup(backend.Close) + + ctx, stop := context.WithCancel(t.Context()) + defer stop() + + env := map[string]string{ + rproxyListenAddr: localhost + ":0", + upstreamSetting: app.URL, + wafBackend: backend.URL, + } + out := &output{} + exited := make(chan int, 1) + + go func() { + exited <- runRProxy(ctx, env, out) + }() + + addr, _ := out.line(t, "msg", "starting")["address"].(string) + conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n") + + select { + case <-asked: + case <-time.After(waitLimit): + t.Fatal("the app was not asked") + } + + stopped := time.Now() + + stop() + + text, err := io.ReadAll(conn) + if err != nil || len(text) != 0 { + t.Errorf("read %q (%v), want the connection cut off", text, err) + } + + if elapsed := time.Since(stopped); elapsed < cutOffAfter { + t.Errorf("cut off after %s, want %s", elapsed, cutOffAfter) + } + + select { + case status := <-exited: + if status != 0 { + t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text()) + } + case <-time.After(waitLimit): + t.Error("rproxy still running after the cut-off") + } + + out.line(t, "msg", "requests still in progress were cut off") +} + +func TestRProxyInvalidSettingStopsTheStart(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, setting string + env map[string]string + }{ + {"no backend", wafBackend, nil}, + { + "another invalid", rproxyTimeout, + map[string]string{wafBackend: "http://servewaf:8080", rproxyTimeout: "soon"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out := &output{} + + if status := runRProxy(t.Context(), tc.env, out); status != 1 { + t.Errorf("exit status %d, want 1", status) + } + + line := out.line(t, "msg", "invalid setting") + message, _ := line["error"].(string) + + if !strings.HasPrefix(message, tc.setting+": ") { + t.Errorf("start refused with %v, want it to name %s", line, tc.setting) + } + }) + } +} + +func TestRProxyAddressInUseStopsTheStart(t *testing.T) { + t.Parallel() + + taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + defer func() { + _ = taken.Close() + }() + + out := &output{} + + status := runRProxy(t.Context(), map[string]string{ + wafBackend: "http://servewaf:8080", + rproxyListenAddr: taken.Addr().String(), + }, out) + if status != 1 { + t.Errorf("exit status %d, want 1", status) + } + + out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR") +} + +func TestMainRunsRProxy(t *testing.T) { + t.Parallel() + + // Nothing listens there; the health check does not ask. + 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) + if err != nil { + t.Fatalf("new request: %v", err) + } + + transport := &http.Transport{} + defer transport.CloseIdleConnections() + + res, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("health check: %v", err) + } + + body, err := io.ReadAll(res.Body) + _ = res.Body.Close() + + if err != nil || string(body) != "ok\n" { + t.Errorf("health check answered %q (%v)", body, err) + } + + err = cmd.Process.Signal(syscall.SIGTERM) + if err != nil { + t.Fatalf("signal: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Errorf("exit: %v; output:\n%s", err, out.text()) + } +} diff --git a/internal/smallwebwaf/smallwebwaf.go b/internal/smallwebwaf/smallwebwaf.go index 3545ef4..3e6809a 100644 --- a/internal/smallwebwaf/smallwebwaf.go +++ b/internal/smallwebwaf/smallwebwaf.go @@ -2,7 +2,9 @@ // the rule files, the lookup database and the state files, serves requests // until it is told to stop, and then stops in an orderly way, writing the // state files. It serves them in front of an app, or, as `smallwebwaf -// servewaf`, by answering rproxy's questions about them. +// servewaf`, by answering rproxy's questions about them. As `smallwebwaf +// rproxy`, it instead reads rproxy's settings alone, and runs the reverse proxy +// in front of one app that asks servewaf about each request. package smallwebwaf import ( @@ -54,7 +56,9 @@ type Params struct { // Main runs smallwebwaf until SIGTERM or SIGINT, and returns the // process's exit status. Run as `smallwebwaf servewaf`, it answers // rproxy's questions rather than pass requests to an app. Run as -// `smallwebwaf healthcheck`, it is the container's health check instead. +// `smallwebwaf rproxy`, it is the reverse proxy that asks those questions. +// Run as `smallwebwaf healthcheck`, it is the container's health check +// instead. func Main(version string) int { if len(os.Args) > 1 && os.Args[1] == "healthcheck" { return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr) @@ -64,12 +68,18 @@ func Main(version string) int { syscall.SIGTERM, os.Interrupt) defer stop() - return Run(ctx, Params{ + params := Params{ Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout, ServeWAF: len(os.Args) > 1 && os.Args[1] == "servewaf", - }) + } + + if len(os.Args) > 1 && os.Args[1] == "rproxy" { + return RunRProxy(ctx, params) + } + + return Run(ctx, params) } // Run reads the settings, the rule files, the lookup database and the diff --git a/internal/smallwebwaf/smallwebwaf_test.go b/internal/smallwebwaf/smallwebwaf_test.go index 75dc067..610c957 100644 --- a/internal/smallwebwaf/smallwebwaf_test.go +++ b/internal/smallwebwaf/smallwebwaf_test.go @@ -76,21 +76,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any { deadline := time.Now().Add(waitLimit) for time.Now().Before(deadline) { - o.mu.Lock() - text := o.buf.String() - o.mu.Unlock() - - for line := range strings.Lines(text) { - var fields map[string]any - - err := json.Unmarshal([]byte(line), &fields) - if err != nil { - t.Fatalf("output line %q is not JSON: %v", line, err) - } - - if fields[key] == value { - return fields - } + fields := o.findLine(t, func(fields map[string]any) bool { + return fields[key] == value + }) + if fields != nil { + return fields } time.Sleep(pollInterval) @@ -101,6 +91,29 @@ func (o *output) line(t *testing.T, key, value string) map[string]any { return nil } +// findLine returns the first line written so far for which matches is true, or +// nil. +func (o *output) findLine( + t *testing.T, matches func(map[string]any) bool, +) map[string]any { + t.Helper() + + for line := range strings.Lines(o.text()) { + var fields map[string]any + + err := json.Unmarshal([]byte(line), &fields) + if err != nil { + t.Fatalf("output line %q is not JSON: %v", line, err) + } + + if matches(fields) { + return fields + } + } + + return nil +} + // text returns everything written so far. func (o *output) text() string { o.mu.Lock() -- 2.54.0