rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171) #181
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)).
|
||||
|
||||
+38
-1
@@ -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"}
|
||||
```
|
||||
|
||||
+42
-2
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<b/ HTTP/1.1\r\n" + host,
|
||||
"a target of a host": "GET site.example:443 HTTP/1.1\r\n" + host,
|
||||
"a target of *": "GET * HTTP/1.1\r\n" + host,
|
||||
"a target of //": "GET //x/y HTTP/1.1\r\n" + host,
|
||||
"a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host,
|
||||
"an empty Host": "GET / HTTP/1.1\r\nHost:\r\n\r\n",
|
||||
"HTTP/1.0 with no Host": "GET / HTTP/1.0\r\n\r\n",
|
||||
"a Host with a zone": "GET / HTTP/1.1\r\nHost: [fe80::1%25eth0]:80\r\n\r\n",
|
||||
"two User-Agents": "GET / HTTP/1.1\r\nUser-Agent: a\r\nUser-Agent: b\r\n" +
|
||||
host,
|
||||
} {
|
||||
t.Run(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, request)
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusBadRequest {
|
||||
t.Errorf("status %d, want 400", status)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["status"] != float64(http.StatusBadRequest) ||
|
||||
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want status 400 and no disposition", line)
|
||||
}
|
||||
|
||||
if len(servewaf.questions) != 0 {
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
servewaf.wantNoReport(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "a%b"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n")
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusOK {
|
||||
t.Errorf("status %d, want the app's 200", status)
|
||||
}
|
||||
|
||||
if asked := servewaf.question(t); asked.host != host {
|
||||
t.Errorf("asked about the host %q, want %q", asked.host, host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const want = "http"
|
||||
|
||||
servewaf := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
|
||||
asked := servewaf.question(t)
|
||||
if got := asked.header.Get(forwardedProtoHeader); got != want {
|
||||
t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, remoteAddr, forwardedFor, client, peer string
|
||||
}{
|
||||
{"from a link-local peer", "[fe80::1%eth0]:1234", "", "fe80::1", "fe80::1"},
|
||||
{
|
||||
"from X-Forwarded-For through a trusted peer", localhost + ":1234",
|
||||
"2001:db8::1%eth0", "2001:db8::1", localhost,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servewaf := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, out, server := startServer(t, servewaf.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
req.Header.Set(forwardedForHeader, tc.forwardedFor)
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
asked := servewaf.question(t)
|
||||
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
||||
asked.header.Get(proxy.PeerHeader) != tc.peer {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
package rproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||
// soon as it arrives.
|
||||
const flushAfterEachWrite time.Duration = -1
|
||||
|
||||
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
||||
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||
const reportStatusMax = 599
|
||||
|
||||
// plainText is the Content-Type of the answers rproxy gives itself.
|
||||
const plainText = "text/plain; charset=utf-8"
|
||||
|
||||
// request is one request on its way through rproxy, from the moment its headers
|
||||
// have been read to its log line and report.
|
||||
type request struct {
|
||||
h *handler
|
||||
in *http.Request
|
||||
// rc sets the deadlines of the connection to the visitor.
|
||||
rc *http.ResponseController
|
||||
out *responseWriter
|
||||
body *requestBody // nil for a request without a body
|
||||
|
||||
// id is the fresh id made for the request when it arrived.
|
||||
id string
|
||||
client netip.Addr
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
start time.Time
|
||||
|
||||
// disposition is what is done with the request: servewaf's valid disposition,
|
||||
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||
disposition string
|
||||
// answered is true when servewaf gave disposition, which makes a report due,
|
||||
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||
answered bool
|
||||
byDefault bool
|
||||
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
||||
// app.
|
||||
asn, country string
|
||||
|
||||
// upstreamStart is when the request was handed to the app.
|
||||
upstreamStart time.Time
|
||||
// cancel ends the request to the app.
|
||||
cancel context.CancelFunc
|
||||
// refused is the status of the first refusal, from whichever goroutine meets
|
||||
// it: what the visitor is answered if the response has not started yet.
|
||||
refused atomic.Int64
|
||||
// upgraded is the connection to the app once the app has switched protocols,
|
||||
// as for a WebSocket, and nil otherwise.
|
||||
upgraded *upgradedConn
|
||||
|
||||
// mu guards what follows. The time limits run on goroutines of their own,
|
||||
// and the transport starts and stops them, and notes the times below, from
|
||||
// its own; once timersStopped is set, neither time limit acts any more.
|
||||
mu sync.Mutex
|
||||
timersStopped bool
|
||||
requestTimer *time.Timer
|
||||
responseTimer *time.Timer
|
||||
// requestEnd is when the app had been sent the whole request, or the answer
|
||||
// started, if that was sooner.
|
||||
requestEnd time.Time
|
||||
// answerStarted is when the first byte of the app's answer arrived.
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it notes the time, works out the visitor from
|
||||
// the TCP peer and X-Forwarded-For, and makes the request's id.
|
||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
|
||||
rq := &request{
|
||||
h: h,
|
||||
in: r,
|
||||
rc: http.NewResponseController(w),
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
id: rand.Text(),
|
||||
client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
|
||||
peer: peer,
|
||||
peerTrusted: isInside(peer, trusted),
|
||||
start: time.Now(),
|
||||
}
|
||||
|
||||
if r.Body != http.NoBody {
|
||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
|
||||
}
|
||||
|
||||
return rq
|
||||
}
|
||||
|
||||
// answerHealth answers the health check.
|
||||
func (rq *request) answerHealth() {
|
||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||
rq.out.Header().Set("Content-Type", plainText)
|
||||
rq.send(http.StatusOK, []byte("ok\n"))
|
||||
}
|
||||
|
||||
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
|
||||
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
|
||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||
// visitor that went away or stopped its sending side, the connection is closed
|
||||
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||
// read, so that servewaf is asked first; passToApp then refuses it.
|
||||
func (rq *request) readFirstPartOfBody() bool {
|
||||
if rq.body == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||
held, err := io.ReadAll(io.LimitReader(rq.body, proxy.QuestionBodyMaxBytes))
|
||||
// From here on, the request timer ends a read of the rest of the body, so that
|
||||
// the answer can name the side rproxy was waiting on.
|
||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||
|
||||
rq.body.held = held
|
||||
|
||||
switch {
|
||||
case err == nil || rq.refused.Load() != 0:
|
||||
return true
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
rq.answer(http.StatusRequestTimeout)
|
||||
|
||||
return false
|
||||
default:
|
||||
// This panic has Go's server close the connection without an answer, and
|
||||
// log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// setPathAsSent has out, the URL of a request made from in, send the path as the
|
||||
// visitor sent it. Go sends the path URL.EscapedPath gives, which, for a path
|
||||
// holding a character Go escapes, such as |, is the whole path decoded and
|
||||
// escaped again, %2F turned into /. Opaque is sent as it is.
|
||||
func setPathAsSent(out *url.URL, in *http.Request) {
|
||||
out.Opaque = rules.Path(in)
|
||||
}
|
||||
|
||||
// forward passes the request to the app, the part of the body held and then the
|
||||
// rest, and the app's answer back. ctx is the request's own context.
|
||||
func (rq *request) forward(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
rq.cancel = cancel
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||
})
|
||||
|
||||
out := rq.in.WithContext(ctx)
|
||||
if rq.body != nil {
|
||||
out.Body = rq.body
|
||||
}
|
||||
|
||||
reverseProxy := &httputil.ReverseProxy{
|
||||
Rewrite: rq.rewrite,
|
||||
Transport: rq.h.transport,
|
||||
FlushInterval: flushAfterEachWrite,
|
||||
ErrorLog: rq.h.errorLog,
|
||||
ModifyResponse: rq.modifyResponse,
|
||||
ErrorHandler: rq.answerError,
|
||||
}
|
||||
|
||||
rq.startRequestTimer()
|
||||
rq.upstreamStart = time.Now()
|
||||
reverseProxy.ServeHTTP(rq.out, out)
|
||||
}
|
||||
|
||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||
// servewaf's.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
pr.Out.URL.Host = upstream.Host
|
||||
// ReverseProxy drops query parameters it cannot parse; the app gets the
|
||||
// query as the visitor sent it.
|
||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||
setPathAsSent(pr.Out.URL, pr.In)
|
||||
|
||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||
pr.Out.Header.Set(requestIDHeader, rq.id)
|
||||
pr.Out.Header.Del(asnHeader)
|
||||
pr.Out.Header.Del(countryHeader)
|
||||
|
||||
if rq.asn != "" {
|
||||
pr.Out.Header.Set(asnHeader, rq.asn)
|
||||
}
|
||||
|
||||
if rq.country != "" {
|
||||
pr.Out.Header.Set(countryHeader, rq.country)
|
||||
}
|
||||
}
|
||||
|
||||
// modifyResponse looks at the app's answer before ReverseProxy passes it on.
|
||||
func (rq *request) modifyResponse(res *http.Response) error {
|
||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||
// An upgraded connection, such as a WebSocket, is not cut by the time
|
||||
// limits. ReverseProxy writes this answer straight to the connection it
|
||||
// takes over, not through rq.out, and then copies what passes each way
|
||||
// through res.Body, the connection to the app. It may still refuse the
|
||||
// switch, as when the app's Upgrade is not the visitor's, so the status
|
||||
// is noted only once it has taken over the connection, by rq.out's
|
||||
// Hijack.
|
||||
rq.stopTimers()
|
||||
|
||||
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||
if ok {
|
||||
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
||||
res.Body = rq.upgraded
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
maxBytes := rq.h.config.MaxResponseSize
|
||||
if maxBytes > 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -76,11 +76,29 @@ 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()
|
||||
fields := o.findLine(t, func(fields map[string]any) bool {
|
||||
return fields[key] == value
|
||||
})
|
||||
if fields != nil {
|
||||
return fields
|
||||
}
|
||||
|
||||
for line := range strings.Lines(text) {
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text())
|
||||
|
||||
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)
|
||||
@@ -88,16 +106,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
||||
t.Fatalf("output line %q is not JSON: %v", line, err)
|
||||
}
|
||||
|
||||
if fields[key] == value {
|
||||
if matches(fields) {
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user