Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553bbcbe95 | ||
|
|
2eeea136e8 | ||
|
|
718a2c94b2 |
@@ -10,18 +10,28 @@ 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
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
||||
client 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte
|
||||
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
|
||||
a limit is refused and banned. The first ban lasts an hour. Each repeat within
|
||||
a day makes the next ban three times as long. A ban that would last more than
|
||||
seven days is permanent.
|
||||
client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves
|
||||
room for programmatic use of an API, such as an automation fleet on another
|
||||
host. A fleet of about 20 agents makes about 120 requests a minute at its
|
||||
busiest. A gitea page opened with an empty cache loads about 40 files, so a
|
||||
few such first visits fit in the minute limit too. The minute limit catches,
|
||||
within seconds, a client firing requests as fast as it can. The hour limit
|
||||
catches a client that keeps up more than about 5.5 a second for an hour. The
|
||||
day limit catches a client that keeps up 4 a second around the clock. Counting
|
||||
cannot catch a client that stays under all three limits, such as one that
|
||||
keeps up 3 a second; only the protections below can. Byte limits give each
|
||||
client 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over a limit
|
||||
is refused and banned. The first ban lasts an hour. Each repeat within a day
|
||||
makes the next ban three times as long. A ban that would last more than seven
|
||||
days is permanent.
|
||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||
default rule file bans such a client for seven days at its first probe. Its
|
||||
next request during the ban makes the ban permanent. The default rule file
|
||||
@@ -333,12 +343,19 @@ 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, and keeps the site answering while
|
||||
`servewaf` is down. Its settings 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`.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`smallwebwaf` is a web application firewall and reverse proxy for one app. It
|
||||
runs inside the app's container, between traefik and the app. This document says
|
||||
what it must do and why, each requirement once. Everything in it is built,
|
||||
except the last section, "Planned, not built". `README.md` says how to run it.
|
||||
except what the last section lists. `README.md` says how to run it.
|
||||
`EVALUATION.md` says why no existing tool was adopted. The reference documents
|
||||
under `docs/` give the settings, the formats and the lists. This document links
|
||||
to them.
|
||||
@@ -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
|
||||
|
||||
@@ -47,14 +48,20 @@ to them.
|
||||
- `internal/smallwebwaf`: the process. It reads the settings, the rule
|
||||
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 healthcheck`, it is the image's health check instead.
|
||||
`smallwebwaf servewaf`, it answers `rproxy` instead of passing requests to
|
||||
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
|
||||
standard library's `httputil.ReverseProxy`, within the timeouts and size
|
||||
limits, and answers its own endpoints in `answerAdmin`. Once the request
|
||||
has ended, it counts bytes, refusals and anomalies, and writes the log
|
||||
line.
|
||||
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,
|
||||
@@ -289,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.
|
||||
@@ -369,22 +379,38 @@ Conventions:
|
||||
owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and
|
||||
byte limits only; the error burst, attack detection and bans still apply.
|
||||
- Many people can share one address, as in an office, on a mobile carrier or
|
||||
through Tor. They are one client, and share its limits and its bans. The
|
||||
default limits are sized for about 40 busy people on one address. A known
|
||||
shared address busier than that belongs in `SWWAF_ALLOW_NETS` or
|
||||
through Tor. They are one client, and share its limits and its bans. A known
|
||||
shared address busier than the limits allow belongs in `SWWAF_ALLOW_NETS` or
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
### Request rate limits, per client
|
||||
|
||||
Breaking one bans the client, so the defaults sit about twice above what 40
|
||||
people busy with heavy web apps make together on one address. Each such person
|
||||
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
|
||||
Breaking one bans the client. The defaults suit programmatic use of an API, such
|
||||
as an automation fleet on another host, and still stop abusive bots. A fleet of
|
||||
20 agents, each making a request every 10 seconds at its busiest, makes 120
|
||||
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
|
||||
a day. It stays under all three limits.
|
||||
|
||||
A client slower than those 40 people cannot be told from them by counting. Only
|
||||
the other checks can catch an abuser that slow. They include the trap paths, the
|
||||
rule files, the Core Rule Set and the blocklists. An abuser that trips none of
|
||||
them is not caught. A machine that talks to the app all day can still pass the
|
||||
day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
|
||||
a minute and a few quick first visits with an empty cache. A gitea page opened
|
||||
with an empty cache loads about 40 files. A client firing requests as fast as
|
||||
it can passes the limit within seconds.
|
||||
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
|
||||
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
|
||||
under the minute limit. The fleet's 7,200 an hour is about a third of it.
|
||||
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that
|
||||
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and
|
||||
14,400 an hour, it stays under the other two limits.
|
||||
|
||||
Counting cannot catch a client that stays under all three limits, such as one
|
||||
that keeps up 3 requests a second. Only the other checks can catch an abuser
|
||||
that slow. They include the trap paths, the rule files, the Core Rule Set and
|
||||
the blocklists. An abuser that trips none of them is not caught. A machine that
|
||||
keeps up more than 500 requests a minute passes the minute limit. One that keeps
|
||||
up more than about 5.5 a second for an hour passes the hour limit. One that
|
||||
keeps up more than about 3.5 a second all day passes the day limit, as two busy
|
||||
Actions runners on one address do. Such a machine's address belongs in
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
|
||||
static assets or health checks; their bytes are not counted either. A prefix is
|
||||
@@ -397,12 +423,12 @@ matched the same way.
|
||||
|
||||
### Byte limits, per client
|
||||
|
||||
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
|
||||
hour and day limits sit about twice above what they move together. A response's
|
||||
bytes are counted when it ends (see step 15). So the minute limit holds two of
|
||||
the largest responses, with their requests, ending in the same minute. No single
|
||||
download then breaks a limit. An operator who raises `SWWAF_RESPONSE_MAX_BYTES`
|
||||
raises the byte limits with it.
|
||||
The hour and day limits sit about twice above what 40 people busy with heavy web
|
||||
apps move together on one address. Each moves about 256 MiB an hour, for ten
|
||||
hours a day. A response's bytes are counted when it ends (see step 15). So the
|
||||
minute limit holds two of the largest responses, with their requests, ending in
|
||||
the same minute. No single download then breaks a limit. An operator who raises
|
||||
`SWWAF_RESPONSE_MAX_BYTES` raises the byte limits with it.
|
||||
|
||||
### Size and time limits
|
||||
|
||||
@@ -1006,6 +1032,382 @@ answers.
|
||||
costs).
|
||||
- A ban made through the endpoints starts at once.
|
||||
|
||||
## `servewaf`
|
||||
|
||||
`smallwebwaf servewaf` runs the checks for `smallwebwaf rproxy`, a small reverse
|
||||
proxy in front of each app. `rproxy` asks `servewaf` about each visitor's
|
||||
request. `servewaf` answers `rproxy` and passes nothing to an app itself. One
|
||||
`servewaf` serves many sites.
|
||||
|
||||
This section is the agreement between the two. The header names, the fields and
|
||||
the statuses below are exact. Each `SWWAF_` setting and check keeps the meaning
|
||||
this document gives it, except as this section says.
|
||||
|
||||
`servewaf` starts as `smallwebwaf` with no command does. It reads the settings,
|
||||
the rule files, the lookup database and the state files, and listens at
|
||||
`SWWAF_LISTEN_ADDR`. It stops in the same orderly way, writing the state files.
|
||||
`docs/settings.md` lists the settings it ignores.
|
||||
|
||||
### The question
|
||||
|
||||
A request carrying `X-Smallwebwaf-Client` is `rproxy` asking about one visitor's
|
||||
request. It is the visitor's request as the visitor sent it:
|
||||
|
||||
- the method;
|
||||
- the path and query as sent;
|
||||
- `Host`;
|
||||
- every header the visitor sent, including its own `X-Forwarded-For`;
|
||||
- the first 64 KiB of the body, or all of it when shorter.
|
||||
|
||||
`rproxy` refuses itself any request it cannot send this way (see "Requests
|
||||
`rproxy` refuses itself"). So a question always has:
|
||||
|
||||
- a path starting with one `/`;
|
||||
- a `Host` that is not empty and holds no IPv6 zone;
|
||||
- at most one `User-Agent`.
|
||||
|
||||
A body whose length the visitor did not announce comes chunked, whatever the
|
||||
method. Any other body comes with a `Content-Length` of the part it carries.
|
||||
|
||||
`rproxy` sets these headers, replacing any the visitor sent, and adds no others:
|
||||
|
||||
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out,
|
||||
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
|
||||
absent when the visitor announced none.
|
||||
- `X-Request-ID`: the request's id, which `rproxy` makes fresh for every request
|
||||
when it arrives. `servewaf` takes it as given and never makes one.
|
||||
- `X-Forwarded-Proto`: the scheme.
|
||||
|
||||
Before any check, `servewaf` removes the three `X-Smallwebwaf-` headers. It also
|
||||
sets `Content-Length` to the length the visitor announced, or removes it. So the
|
||||
rule files and the Core Rule Set see the headers the visitor sent, with
|
||||
`rproxy`'s `X-Request-ID` and `X-Forwarded-Proto`.
|
||||
|
||||
`servewaf` refuses a question with `400` when:
|
||||
|
||||
- `X-Smallwebwaf-Client` is not an address, or is an address with a zone, such
|
||||
as `2001:db8::1%eth0`;
|
||||
- `X-Request-ID` is absent, empty, over 128 bytes or not valid UTF-8;
|
||||
- `X-Request-ID` is the id of a question `servewaf` is still handling, or of a
|
||||
request it holds (see "The report");
|
||||
- the body is over 64 KiB.
|
||||
|
||||
For a refused question, `servewaf` writes a process warning. It runs no check,
|
||||
writes no request log line and counts nothing in the metrics.
|
||||
|
||||
These do not refuse a question:
|
||||
|
||||
- `X-Smallwebwaf-Peer` absent or not an address: the log line's `peer_ip` is
|
||||
`invalid IP`.
|
||||
- `X-Smallwebwaf-Content-Length` not a whole number, or negative: it counts as
|
||||
absent.
|
||||
- `X-Forwarded-Proto` absent: the log line's `scheme` is `http`.
|
||||
- One of the five headers above sent more than once: `servewaf` uses its first
|
||||
value.
|
||||
|
||||
`servewaf` reads the whole body before any check. A question whose body stops
|
||||
arriving gets no answer. `servewaf` closes the connection once the 60 seconds
|
||||
below run out, or as soon as `rproxy` closes its side. It writes a process
|
||||
warning, runs no check, writes no request log line and counts nothing in the
|
||||
metrics.
|
||||
|
||||
### The answer
|
||||
|
||||
`servewaf` runs steps 3 to 12 of "What happens to a request", in that order.
|
||||
Every answer but the last below carries `X-Smallwebwaf-Disposition`.
|
||||
|
||||
- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the
|
||||
request to the app. With `SWWAF_ADD_LOOKUP_HEADERS` the answer also carries
|
||||
`X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it
|
||||
passes on. A header whose value is unknown is left out.
|
||||
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the
|
||||
visitor is to get. `rproxy` gives them to the visitor, less this header. The
|
||||
status is one of:
|
||||
- `SWWAF_BAN_RESPONSE`, for a refusal;
|
||||
- `403`, for a `block` rule or the Core Rule Set;
|
||||
- `404`, for a path under `/_smallwebwaf/` that passes the checks, so that
|
||||
the app never sees that prefix.
|
||||
- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the
|
||||
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.
|
||||
|
||||
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
|
||||
still answered `404`.
|
||||
|
||||
The Core Rule Set inspects the body a question carries only while
|
||||
`SWWAF_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it
|
||||
does not inspect a JSON or XML body of 64 KiB or more, which may have been cut
|
||||
short.
|
||||
|
||||
### The report
|
||||
|
||||
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:
|
||||
|
||||
- `request_id`: the question's `X-Request-ID`;
|
||||
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
|
||||
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
|
||||
WebSocket carried each way, each a whole number from `0` to `1125899906842624`
|
||||
(1 PiB);
|
||||
- `duration_total`: milliseconds from the request's headers being read to the
|
||||
end of the answer, from `0` to `1000000000000` (about 31 years);
|
||||
- `duration_upstream_total`: milliseconds from the hand-over to the app to the
|
||||
end of the answer, in the same range, and `0` for a request not passed to the
|
||||
app.
|
||||
|
||||
`servewaf` answers `400` and writes a process warning when the body:
|
||||
|
||||
- is not one JSON object, such as `null`, or an object followed by other data;
|
||||
- lacks one of these fields, or gives it as `null`;
|
||||
- gives a value of the wrong type, or out of its range;
|
||||
- is over 1 MiB.
|
||||
|
||||
It answers `204` to any other report, also for a request it does not hold, which
|
||||
it ignores. It ignores a field not listed here. `rproxy` does not act on the
|
||||
answer.
|
||||
|
||||
For a request it holds, `servewaf` writes the request's last log line (see "The
|
||||
log"). For a request it allowed, it first:
|
||||
|
||||
- counts the bytes as `SWWAF_BYTES_COUNT` says, toward the client's byte limits
|
||||
at the percentages it gave that request;
|
||||
- bans a client that passes a byte limit;
|
||||
- adds the request with its status to the client's history, the anomaly counters
|
||||
and the metrics.
|
||||
|
||||
A denied request's counting is finished at its decision, so its report counts
|
||||
nothing.
|
||||
|
||||
`servewaf` holds each answered request until its report comes, up to 10,000
|
||||
requests. Past that, the request answered longest ago is dropped. A request
|
||||
whose report never comes gets no last log line. If it was allowed, it is in
|
||||
neither the client's history nor the metrics, and its bytes are not counted.
|
||||
|
||||
`servewaf` holds only the decision's log line and what counting needs. It never
|
||||
holds the request or response objects, and no `Authorization` or `Cookie`. A
|
||||
report reaches only what is held, so it can never reach a question still being
|
||||
handled. `servewaf` ignores a report naming such a question.
|
||||
|
||||
### The log
|
||||
|
||||
Each request gets three request log lines, joined by `request_id`:
|
||||
|
||||
- `rproxy`'s short line;
|
||||
- `servewaf`'s line at its decision;
|
||||
- `servewaf`'s line when the report comes, with everything known: the decision
|
||||
line's fields and the report's together.
|
||||
|
||||
The repetition is intended. The field `servewaf_line` says which of `servewaf`'s
|
||||
two lines a line is. `docs/request-log.md` gives the fields.
|
||||
|
||||
### `servewaf`'s own endpoints
|
||||
|
||||
A request without `X-Smallwebwaf-Client` is for `servewaf` itself, at its own
|
||||
address on the private network. The internet must not be able to reach that
|
||||
address. Its endpoints are:
|
||||
|
||||
- the health check, the metrics, and the ban and client endpoints, as
|
||||
`docs/admin-endpoints.md` lists them, with the same tokens;
|
||||
- the report.
|
||||
|
||||
Anything else gets `404`. No check runs on these requests, since their peers are
|
||||
`rproxy` and the admin's tools. The report needs no token, so anything on the
|
||||
private network can send one or ask a question.
|
||||
|
||||
A request and its answer have 60 seconds together, from the request's headers.
|
||||
`servewaf` closes a connection idle for 120 seconds. It takes a request line and
|
||||
headers up to 1 MiB, Go's default, so that it never refuses a question for the
|
||||
size of a visitor's headers.
|
||||
|
||||
### Sites
|
||||
|
||||
One `servewaf` keeps one table of clients, counters and bans for every site that
|
||||
asks it. A client banned through one site is refused on all of them, and its
|
||||
requests on all of them count toward one set of limits. Log lines, alerts raised
|
||||
by a request and ban notes name the site by its host.
|
||||
|
||||
`docs/metrics.md` says how its metrics count.
|
||||
|
||||
## `rproxy`
|
||||
|
||||
`smallwebwaf rproxy` is the reverse proxy in front of one app. It asks
|
||||
`servewaf` about each request, and passes on what `servewaf` allows. It keeps
|
||||
nothing between requests and never touches disk. When `servewaf` is down, slow
|
||||
or overloaded, the site keeps answering.
|
||||
|
||||
`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of
|
||||
them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no
|
||||
rule files and no state files, and it writes its lines to stdout only.
|
||||
|
||||
The image's run script and health check are those of `smallwebwaf` with no
|
||||
command (see "Deployment"). A container that runs `rproxy` names its own command
|
||||
and health check.
|
||||
|
||||
### What `rproxy` does with a request
|
||||
|
||||
1. `GET /_smallwebwaf/healthz` is answered `ok` at once, without asking
|
||||
`servewaf`.
|
||||
2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only
|
||||
from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a
|
||||
request" says. It removes any IPv6 zone.
|
||||
3. It makes a fresh request id, whatever id the request came with. `servewaf`
|
||||
and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it.
|
||||
4. It refuses a request it cannot put to `servewaf` as the visitor sent it (see
|
||||
"Requests `rproxy` refuses itself").
|
||||
5. It reads up to the first 64 KiB of the body into memory.
|
||||
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to
|
||||
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to
|
||||
send a visitor's `Upgrade` header, and most `Connection` headers. The
|
||||
question never carries the visitor's `Trailer`.
|
||||
7. It does what the answer says (see "The answer"):
|
||||
- `allow`: it passes the request to the app.
|
||||
- `deny`: the visitor gets the answer's status, headers and body, less
|
||||
`X-Smallwebwaf-Disposition`.
|
||||
- `close`: it closes the visitor's connection without an answer.
|
||||
8. Once the answer has ended, it writes its log line. `docs/request-log.md`
|
||||
gives the fields. An app's answer can end just after the line: Go's server
|
||||
sends the closing chunk of a chunked answer, and may send all of an empty
|
||||
one, only once `rproxy` has finished with the request.
|
||||
9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close`
|
||||
(see "The report"). The visitor does not wait for it. A report that fails is
|
||||
dropped, with a process warning. A status above `599` from the app goes to
|
||||
the visitor as it is, and into the report as `599`, the highest `servewaf`
|
||||
takes.
|
||||
|
||||
### Requests `rproxy` refuses itself
|
||||
|
||||
`SWRP_DEFAULT_DISPOSITION` applies only when `servewaf` itself is down, slow or
|
||||
answers badly. A visitor can make `servewaf` slow. A request that keeps
|
||||
`servewaf` busy past `SWRP_TIMEOUT_MS`, like any request while `servewaf` is
|
||||
overloaded, gets `SWRP_DEFAULT_DISPOSITION`. `deny` closes this, at the cost of
|
||||
answering `503` whenever `servewaf` is slow.
|
||||
|
||||
`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 when
|
||||
`servewaf`:
|
||||
|
||||
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
|
||||
question and reading the whole answer;
|
||||
- cannot be reached;
|
||||
- answers without a valid `X-Smallwebwaf-Disposition`, as with the `400` for a
|
||||
question it refuses;
|
||||
- sends an answer whose body breaks off or is over 1 MiB.
|
||||
|
||||
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||
Either way `rproxy` writes a process warning, its log line has
|
||||
`default_disposition`, and it sends no report.
|
||||
|
||||
A path under `/_smallwebwaf/` that `allow` would pass on is answered `404`
|
||||
instead, so that the app never sees that prefix.
|
||||
|
||||
A visitor that goes away while `servewaf` is being asked is no failure of
|
||||
`servewaf`. `rproxy` closes the connection, with no warning, no default and no
|
||||
report. Its log line has status `0` and no `disposition`.
|
||||
|
||||
### Passing a request to the app
|
||||
|
||||
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
|
||||
the body it holds, then the rest, streaming. The app's answer comes back the
|
||||
same way. The request reaches the app unchanged but for these headers:
|
||||
|
||||
- `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that
|
||||
peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass
|
||||
on.
|
||||
- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own
|
||||
claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host`
|
||||
the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers
|
||||
in which traefik tells an app about a visitor are removed.
|
||||
- `X-Request-ID`, which carries the fresh id.
|
||||
- `X-Client-ASN` and `X-Client-Country`, which carry what `servewaf` answered. A
|
||||
header it did not give is left out. Any of the two the visitor sent is
|
||||
removed.
|
||||
- The headers that concern only the connection to `rproxy` never reach the app:
|
||||
- `Connection`, and any header it names;
|
||||
- `Keep-Alive`;
|
||||
- `Proxy-Authenticate`;
|
||||
- `Proxy-Authorization`;
|
||||
- `Proxy-Connection`;
|
||||
- `Te`, though one holding `trailers` goes on as `Te: trailers`;
|
||||
- `Trailer`;
|
||||
- `Upgrade`.
|
||||
|
||||
A WebSocket upgrade passes through, with `Connection: Upgrade` and its
|
||||
`Upgrade`. The report of a WebSocket comes when it closes, with what it carried
|
||||
each way.
|
||||
|
||||
### `rproxy`'s limits
|
||||
|
||||
The limits work as "Size and time limits" says, with these settings in place of
|
||||
those:
|
||||
|
||||
- `SWRP_MAX_REQUEST_SIZE` for `SWWAF_REQUEST_MAX_BYTES`.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` for `SWWAF_RESPONSE_MAX_BYTES`.
|
||||
- `SWRP_MAX_REQUEST_TIME_MS` for `SWWAF_CLIENT_REQUEST_TIMEOUT` and
|
||||
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`.
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` for `SWWAF_CLIENT_RESPONSE_TIMEOUT` and
|
||||
`SWWAF_UPSTREAM_RESPONSE_TIMEOUT`.
|
||||
|
||||
The largest request line and headers is 32 KiB. A kept-open connection is closed
|
||||
after 120 seconds idle. Neither has a setting.
|
||||
|
||||
`SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their end,
|
||||
it then bounds the time until the app has been sent the whole request. The wait
|
||||
for `servewaf` counts toward it. A visitor too slow to send the first 64 KiB is
|
||||
answered `408`, with no question and no report.
|
||||
|
||||
`SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte of
|
||||
the answer reaching the visitor. The request ends when the app has been sent all
|
||||
of it, or when the app's answer arrives if that is sooner. An answer `rproxy`
|
||||
gives itself has the whole time from when it starts.
|
||||
|
||||
`rproxy` applies the request size limit only after `servewaf` has allowed the
|
||||
request. So a request `servewaf` denies gets `servewaf`'s answer, whatever its
|
||||
size. A request `servewaf` allows is answered `413` when its body is over the
|
||||
limit: at once for a length it announced, otherwise when the limit is reached.
|
||||
If part of the body has gone to the app by then, the request to the app is cut
|
||||
off.
|
||||
|
||||
### Starting and stopping
|
||||
|
||||
An invalid setting, or an address it cannot listen on, stops the start with a
|
||||
process line. The process lines give the host's name as `instance`, and only
|
||||
those at `info` or more severe are written.
|
||||
|
||||
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
|
||||
progress five seconds to finish. It then waits for the reports on their way,
|
||||
each for up to `SWRP_TIMEOUT_MS`. A report that comes due after that is dropped,
|
||||
such as that of a WebSocket that closes later.
|
||||
|
||||
## Deployment
|
||||
|
||||
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
|
||||
@@ -1171,8 +1573,7 @@ and `script/gitea-requests` checks those notes.
|
||||
|
||||
## Planned, not built
|
||||
|
||||
These two points are specified as later work, and nothing in the code does them
|
||||
yet:
|
||||
These points are specified as later work, and nothing in the code does them yet:
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Command smallwebwaf is a web application firewall for one app: it runs
|
||||
// between traefik and the app, and passes requests through within its
|
||||
// limits. Everything it does is in internal/smallwebwaf.
|
||||
// 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 rproxy`, is the reverse proxy that asks those questions.
|
||||
// Everything it does is in internal/smallwebwaf.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -46,3 +46,14 @@ curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://app.example/_smallwebwaf/clients/203.0.113.9
|
||||
```
|
||||
|
||||
## `servewaf`
|
||||
|
||||
`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)).
|
||||
|
||||
+13
-10
@@ -35,34 +35,34 @@ on one line:
|
||||
```json
|
||||
{
|
||||
"instance": "fsn1app1/gitea",
|
||||
"time": "2026-10-06T12:00:00.123461Z",
|
||||
"time": "2026-10-06T12:00:50.123461Z",
|
||||
"event": "ban",
|
||||
"client": "203.0.113.9",
|
||||
"netblock": "203.0.113.9/32",
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"reason": "requests per minute over the limit of 6000",
|
||||
"reason": "requests per minute over the limit of 500",
|
||||
"detail": {
|
||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
||||
"ban_expires": "2026-10-06T13:00:50.123Z",
|
||||
"cause": "limit",
|
||||
"notes": {
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"kind": "requests",
|
||||
"limit": 6000,
|
||||
"limit": 500,
|
||||
"window": "minute",
|
||||
"count": 6001,
|
||||
"count": 501,
|
||||
"request": {
|
||||
"time": "2026-10-06T12:00:00.123456789Z",
|
||||
"time": "2026-10-06T12:00:50.123456789Z",
|
||||
"method": "GET",
|
||||
"host": "app.example",
|
||||
"path": "/owner/repo/commits/branch/main?page=812",
|
||||
"path": "/owner/repo/commits/branch/main?page=501",
|
||||
"status": 403,
|
||||
"user_agent": "scraper/1.0"
|
||||
},
|
||||
"requests": 8210,
|
||||
"requests": 501,
|
||||
"refused": 0,
|
||||
"earlier_bans": {
|
||||
"limit": 0,
|
||||
@@ -78,6 +78,9 @@ on one line:
|
||||
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
one of the events above.
|
||||
- `host`: the site of the request that raised the alert. Only `servewaf` sends
|
||||
this field, since it serves many sites, and not for `source_failure`,
|
||||
`file_error` and `summary`.
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`source_failure`, `file_error` and `summary`.
|
||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||
@@ -89,7 +92,7 @@ on one line:
|
||||
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
||||
raised. They are empty when GeoJS had not answered yet.
|
||||
- `reason`: a short sentence, such as
|
||||
`requests per minute over the limit of 6000` or `listed by a blocklist`.
|
||||
`requests per minute over the limit of 500` or `listed by a blocklist`.
|
||||
- `detail`: what is particular to the event:
|
||||
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
||||
under `bans.json` in [`state-files.md`](state-files.md));
|
||||
@@ -110,7 +113,7 @@ Slack and ntfy get a message with a title and a text. The title is the instance
|
||||
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one
|
||||
line for each of these that the alert has:
|
||||
|
||||
- the client, the netblock and the country;
|
||||
- the host, the client, the netblock and the country;
|
||||
- the file, the source, the error and the mode from `detail`;
|
||||
- the suppressed repeats, if any.
|
||||
|
||||
|
||||
+7
-5
@@ -34,11 +34,13 @@ checked again when gitea or the Core Rule Set changes version.
|
||||
day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
|
||||
43,200 requests a day. While it runs a job, it reports the job's log and state
|
||||
every second instead, two requests a second. So one runner busy all day sends
|
||||
172,800 requests, under the request limits. Six such runners on one address,
|
||||
each busy all day, pass the day limit of 1,000,000 within two days; five stay
|
||||
under it. Their address is then banned, their running jobs fail, and each
|
||||
repeat within a day makes the ban longer. Newer runners ask for work less
|
||||
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
120 requests a minute and 172,800 a day, under the request limits. Two such
|
||||
runners on one address, each busy all day, make 345,600 and pass the day limit
|
||||
of 300,000. Three busy at once make 21,600 an hour and pass the hour limit of
|
||||
20,000. Five busy at once make 600 a minute and pass the minute limit of 500.
|
||||
Their address is then banned, their running jobs fail, and each repeat within
|
||||
a day makes the ban longer. Newer runners ask for work less often. Put the
|
||||
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
## What the Core Rule Set lets through
|
||||
|
||||
|
||||
@@ -79,3 +79,14 @@ By `file`:
|
||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
||||
`smallwebwaf_alerts_dropped_total`;
|
||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||
|
||||
## `servewaf`
|
||||
|
||||
`smallwebwaf servewaf` counts a denied request at its decision. It counts an
|
||||
allowed request when its report comes, with the status, bytes and times the
|
||||
report gives. These are not counted:
|
||||
|
||||
- a report itself;
|
||||
- an allowed request whose report never comes;
|
||||
- a question refused with `400`;
|
||||
- a question whose body stopped arriving.
|
||||
|
||||
+98
-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;
|
||||
@@ -117,3 +118,99 @@ All in milliseconds:
|
||||
connection;
|
||||
- `duration_upstream_first_byte`: until the first byte of the answer;
|
||||
- `duration_upstream_total`: until the end of the answer.
|
||||
|
||||
## `servewaf`'s lines
|
||||
|
||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
||||
|
||||
- `decision`: the line it writes once it has answered the question;
|
||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||
known. The repetition is intended.
|
||||
|
||||
A request to its own endpoints writes one line, without `servewaf_line`. A
|
||||
report writes no line but the `report` line of the request it names.
|
||||
|
||||
These questions write no line:
|
||||
|
||||
- a question refused with `400` (see "The question" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- a question whose body stopped arriving, which gets no answer.
|
||||
|
||||
### The `decision` line
|
||||
|
||||
It has the fields above, with these meanings where they differ:
|
||||
|
||||
- `client_ip`: `X-Smallwebwaf-Client`.
|
||||
- `peer_ip`: `X-Smallwebwaf-Peer`.
|
||||
- `host`: the site the visitor asked for.
|
||||
- `protocol`: the question's own, `HTTP/1.1`.
|
||||
- `content_length`: `X-Smallwebwaf-Content-Length`.
|
||||
- `request_id`: the question's `X-Request-ID`.
|
||||
- `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`,
|
||||
or `0` for `close`, as the visitor is then sent nothing.
|
||||
- `request_bytes`: how much of the question's body the Core Rule Set read.
|
||||
- `response_bytes`: the body of `servewaf`'s answer.
|
||||
- `action`: `forward` for `allow`, and `admin` for the `404` for a path under
|
||||
`/_smallwebwaf/`.
|
||||
- `aborted`: true when `rproxy` went away before the answer.
|
||||
- `duration_total`: from `servewaf` having read the question's body to the line.
|
||||
- `time`: when `servewaf` had read the question's body.
|
||||
|
||||
The line leaves out `upstream_status`, `websocket` and the
|
||||
`duration_upstream_...` fields, since `servewaf` passes nothing to the app.
|
||||
|
||||
The bytes of an allowed request are counted when its report comes. So `counts`
|
||||
holds the client's bytes without this request's.
|
||||
|
||||
### The `report` line
|
||||
|
||||
It has every field of the `decision` line, and `time` stays as the `decision`
|
||||
line gives it. These fields say what the report says:
|
||||
|
||||
- `status`: what the visitor got, `0` if nothing;
|
||||
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
|
||||
WebSocket carried each way;
|
||||
- `duration_total`: from the request's headers being read by `rproxy` to the end
|
||||
of the answer;
|
||||
- `duration_upstream_total`: from the hand-over to the app to the end of the
|
||||
answer, for an allowed request.
|
||||
|
||||
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, and when the
|
||||
visitor went away while it was being asked.
|
||||
- `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"}
|
||||
```
|
||||
|
||||
+72
-4
@@ -99,9 +99,21 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
||||
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
|
||||
most requests a client may make in each window. A count or `off`.
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
|
||||
requests a client may make in a minute. A count or `off`. The default catches,
|
||||
within seconds, a client firing requests as fast as it can. It leaves room for
|
||||
a fleet of about 20 agents at its busiest, about 120 requests a minute. It
|
||||
also leaves room for a few quick first visits to gitea with an empty cache,
|
||||
about 40 files each.
|
||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `20000`, about 5.5 a second): the most
|
||||
requests a client may make in an hour. A count or `off`. The default leaves
|
||||
room for an automation fleet at work. It catches a client that keeps up more
|
||||
than about 5.5 a second for an hour.
|
||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most
|
||||
requests a client may make in a day. A count or `off`. The default catches a
|
||||
scraper that keeps up 4 a second around the clock.
|
||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||
rate limiting and the byte limits, such as `/assets/`.
|
||||
|
||||
@@ -327,4 +339,60 @@ A few limits are fixed rather than settings:
|
||||
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
|
||||
- at most 1,000 DNS blocklist queries are under way at once;
|
||||
- at most 20,000 anomaly counters are kept;
|
||||
- each alert destination's queue holds at most 1,000 alerts.
|
||||
- each alert destination's queue holds at most 1,000 alerts;
|
||||
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `rproxy` takes a request line and headers of at most 32 KiB, closes a
|
||||
kept-open connection idle for 120 seconds, and reads at most 1 MiB of the body
|
||||
of `servewaf`'s answer.
|
||||
|
||||
## `servewaf`
|
||||
|
||||
`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";
|
||||
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_REQUEST_MAX_BYTES` and
|
||||
`SWWAF_RESPONSE_MAX_BYTES`.
|
||||
|
||||
An invalid value of one of these still stops the start, and the line of settings
|
||||
at start still shows them.
|
||||
|
||||
`SWWAF_ADD_LOOKUP_HEADERS` puts the headers on the answer to `rproxy`, not on a
|
||||
request to an app. `SWWAF_WAF_BODY_LIMIT` has an effect up to 64 KiB less one
|
||||
byte, whatever its value. A question carries no more of a body than 64 KiB (see
|
||||
"`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
|
||||
`servewaf` gives no answer about. `allow` passes it to the app, and `deny`
|
||||
answers `503`.
|
||||
- `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.
|
||||
|
||||
@@ -150,13 +150,16 @@ type Params struct {
|
||||
|
||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||
// Country are, for a ban, the client's as the ban's notes give them.
|
||||
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||
// the site of the request that raised the alert, while one servewaf serves
|
||||
// many sites, and "" otherwise.
|
||||
//
|
||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||
type Alert struct {
|
||||
Instance string `json:"instance"`
|
||||
Time time.Time `json:"time"`
|
||||
Event string `json:"event"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Client netip.Addr `json:"client"`
|
||||
Netblock netip.Prefix `json:"netblock"`
|
||||
ASN string `json:"asn"`
|
||||
@@ -899,12 +902,16 @@ func title(alert *Alert) string {
|
||||
}
|
||||
|
||||
// text returns the text of alert in Slack and ntfy: its reason, then a
|
||||
// line for each of its client, netblock and country, the file, source,
|
||||
// error and mode its detail gives, and its suppressed repeats, that it
|
||||
// has.
|
||||
// line for each of its host, client, netblock and country, the file,
|
||||
// source, error and mode its detail gives, and its suppressed repeats, that
|
||||
// it has.
|
||||
func text(alert *Alert) string {
|
||||
lines := []string{alert.Reason}
|
||||
|
||||
if alert.Host != "" {
|
||||
lines = append(lines, "host: "+alert.Host)
|
||||
}
|
||||
|
||||
if alert.Client.IsValid() {
|
||||
lines = append(lines, "client: "+alert.Client.String())
|
||||
}
|
||||
|
||||
@@ -1284,9 +1284,11 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||
Reason: "refused by the Core Rule Set",
|
||||
Host: "app.example",
|
||||
},
|
||||
"fsn1app1/gitea: waf_block", "default shield",
|
||||
"refused by the Core Rule Set\nclient: 192.0.2.1\nnetblock: 192.0.2.1/32",
|
||||
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||
"netblock: 192.0.2.1/32",
|
||||
},
|
||||
{
|
||||
alerts.Alert{
|
||||
|
||||
@@ -103,6 +103,8 @@ type Counter struct {
|
||||
|
||||
// Request is a request that has ended, as the counters count it.
|
||||
type Request struct {
|
||||
// Host is the site the alert names, "" for none.
|
||||
Host string
|
||||
// Client is the client's address, and ClientGroup the client it is
|
||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
||||
@@ -396,6 +398,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
||||
|
||||
return alerts.Alert{
|
||||
Event: alerts.EventAnomaly,
|
||||
Host: r.Host,
|
||||
Client: r.Client,
|
||||
Netblock: k.netblock,
|
||||
ASN: r.ASN,
|
||||
|
||||
@@ -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 (
|
||||
@@ -506,9 +508,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
|
||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
|
||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||
|
||||
@@ -262,9 +262,9 @@ func TestDefaults(t *testing.T) {
|
||||
UpstreamResponseTimeout: 30 * time.Minute,
|
||||
RequestMaxBytes: 100 << 20,
|
||||
ResponseMaxBytes: 1 << 30,
|
||||
RateLimitPerMinute: 6000,
|
||||
RateLimitPerHour: 100000,
|
||||
RateLimitPerDay: 1000000,
|
||||
RateLimitPerMinute: 500,
|
||||
RateLimitPerHour: 20000,
|
||||
RateLimitPerDay: 300000,
|
||||
BanResponse: 403,
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: 24 * time.Hour,
|
||||
@@ -2375,9 +2375,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
allowNets: "",
|
||||
rateLimitExemptNets: "",
|
||||
denyNets: "",
|
||||
rateLimitPerMinute: "6000",
|
||||
rateLimitPerHour: "100000",
|
||||
rateLimitPerDay: "1000000",
|
||||
rateLimitPerMinute: "500",
|
||||
rateLimitPerHour: "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
rateLimitExemptPaths: "",
|
||||
bytesLimitPerMinute: "4G",
|
||||
bytesLimitPerHour: "20G",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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 servewaf gives no
|
||||
// answer about, 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 servewaf
|
||||
// gives no answer about: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-20
@@ -403,28 +403,21 @@ func (m *Metrics) RequestEnded(
|
||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
m.inFlight.Dec()
|
||||
m.count(line, limit, duration, upstreamDuration)
|
||||
}
|
||||
|
||||
class := statusClass(line.Status)
|
||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
||||
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
||||
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
||||
m.requestDuration.Observe(duration.Seconds())
|
||||
// RequestAllowed counts a question of rproxy's, answered allow, as no longer
|
||||
// under way. RequestReported counts the request it was about.
|
||||
func (m *Metrics) RequestAllowed() {
|
||||
m.inFlight.Dec()
|
||||
}
|
||||
|
||||
if upstreamDuration > 0 {
|
||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||
}
|
||||
|
||||
if limit != "" {
|
||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
||||
}
|
||||
|
||||
if line.Country != "" {
|
||||
m.countries.add(line.Country, line)
|
||||
}
|
||||
|
||||
if line.ASN != "" {
|
||||
m.asns.add(line.ASN, line)
|
||||
}
|
||||
// RequestReported counts a request rproxy passed to the app, as RequestEnded
|
||||
// does, from what its report says, which line holds.
|
||||
func (m *Metrics) RequestReported(
|
||||
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
m.count(line, "", duration, upstreamDuration)
|
||||
}
|
||||
|
||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
||||
@@ -493,6 +486,33 @@ func (m *Metrics) StateFileEditSetAside(name string) {
|
||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||
}
|
||||
|
||||
// count counts a request that has ended, as RequestEnded describes.
|
||||
func (m *Metrics) count(
|
||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||
) {
|
||||
class := statusClass(line.Status)
|
||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
||||
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
||||
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
||||
m.requestDuration.Observe(duration.Seconds())
|
||||
|
||||
if upstreamDuration > 0 {
|
||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||
}
|
||||
|
||||
if limit != "" {
|
||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
||||
}
|
||||
|
||||
if line.Country != "" {
|
||||
m.countries.add(line.Country, line)
|
||||
}
|
||||
|
||||
if line.ASN != "" {
|
||||
m.asns.add(line.ASN, line)
|
||||
}
|
||||
}
|
||||
|
||||
// addReputationQueries adds the counter of the queries to source, a DNSBL
|
||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||
|
||||
@@ -41,6 +41,15 @@ var (
|
||||
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
||||
)
|
||||
|
||||
// answerHealth answers a request for the health endpoint.
|
||||
func (rq *request) answerHealth() {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
}
|
||||
|
||||
// answerAdmin answers a request for smallwebwaf itself, under
|
||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||
// token, sent as Authorization: Bearer <token>: the metrics
|
||||
|
||||
@@ -15,9 +15,11 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
||||
// anomaly counters, nor a clock, and the request no response: reading
|
||||
// any of them to count the request panics.
|
||||
rq := &request{
|
||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||
client: netip.MustParseAddr("203.0.113.9"),
|
||||
lookedUp: true,
|
||||
decision: decision{
|
||||
client: netip.MustParseAddr("203.0.113.9"),
|
||||
lookedUp: true,
|
||||
},
|
||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||
}
|
||||
|
||||
defer func() {
|
||||
|
||||
@@ -323,6 +323,7 @@ func (rq *request) alertBan(ban bans.Ban) {
|
||||
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: event,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: ban.Netblock,
|
||||
ASN: ban.Notes.ASN,
|
||||
@@ -339,11 +340,11 @@ func (rq *request) alertBan(ban bans.Ban) {
|
||||
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||
return bans.Request{
|
||||
Time: now,
|
||||
Method: rq.in.Method,
|
||||
Host: rq.in.Host,
|
||||
Path: rq.in.URL.RequestURI(),
|
||||
Method: rq.line.Method,
|
||||
Host: rq.line.Host,
|
||||
Path: rq.requestURI,
|
||||
Status: status,
|
||||
UserAgent: rq.in.UserAgent(),
|
||||
UserAgent: rq.line.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
userAgent = "ban-test/1.0"
|
||||
// permanent is the log line's ban_expires for a permanent ban.
|
||||
permanent = "permanent"
|
||||
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||
banResponseClose = "close"
|
||||
)
|
||||
|
||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
@@ -237,7 +239,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
{"", http.StatusForbidden},
|
||||
{"403", http.StatusForbidden},
|
||||
{"429", http.StatusTooManyRequests},
|
||||
{"close", 0},
|
||||
{banResponseClose, 0},
|
||||
} {
|
||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -113,6 +113,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
||||
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventWAFBlock,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: rq.h.clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
rateLimitPerMinute: "1",
|
||||
rateLimitExemptNets: scraper,
|
||||
denyNets: denied,
|
||||
banResponse: "close",
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
})
|
||||
|
||||
+52
-33
@@ -1,6 +1,8 @@
|
||||
// Package proxy passes each request to the app and the app's answer back,
|
||||
// unchanged, within the size and time limits, and writes one request log
|
||||
// line for each request.
|
||||
// Package proxy runs the checks on each request and writes its request log
|
||||
// line. New passes each request it lets through to the app and the app's
|
||||
// answer back, unchanged, within the size and time limits. NewServeWAF instead
|
||||
// answers rproxy's question about each request, and takes rproxy's report once
|
||||
// the visitor's answer has ended, which writes the request a second log line.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
@@ -11,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
@@ -19,7 +23,6 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
@@ -53,7 +56,8 @@ const ClientsPath = "/_smallwebwaf/clients/"
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives one JSON line per request.
|
||||
// RequestLog receives one JSON line per request, and from servewaf a second
|
||||
// when the request's report comes.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
ProcessLog *slog.Logger
|
||||
@@ -109,6 +113,23 @@ type Server struct {
|
||||
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
||||
// applies the timeouts and size limits from then on.
|
||||
func New(params Params) *Server {
|
||||
h := newHandler(params)
|
||||
|
||||
return h.server(&http.Server{
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
||||
// Off is an IdleTimeout of 0, which Go's server replaces with
|
||||
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
|
||||
IdleTimeout: params.Config.ClientIdleTimeout,
|
||||
// Go's server reads 4 KiB past MaxHeaderBytes before it
|
||||
// refuses, so the limit a client meets is the setting.
|
||||
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
|
||||
})
|
||||
}
|
||||
|
||||
// newHandler returns the handler with what every request shares, as the
|
||||
// settings in params make it.
|
||||
func newHandler(params Params) *handler {
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
@@ -171,29 +192,7 @@ func New(params Params) *Server {
|
||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||
m.AddRules(params.Rules)
|
||||
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
||||
// Off is an IdleTimeout of 0, which Go's server replaces with
|
||||
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
|
||||
IdleTimeout: params.Config.ClientIdleTimeout,
|
||||
// Go's server reads 4 KiB past MaxHeaderBytes before it
|
||||
// refuses, so the limit a client meets is the setting.
|
||||
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
|
||||
ErrorLog: errorLog,
|
||||
},
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: m,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||
@@ -274,6 +273,10 @@ type handler struct {
|
||||
rules *rules.Files
|
||||
coreRuleSet *waf.CoreRuleSet
|
||||
alerts *alerts.Queue
|
||||
// awaitingReport holds, in servewaf, the decision on each answered
|
||||
// question by its id until its report comes, and nil for a question still
|
||||
// being handled.
|
||||
awaitingReport *lru.Cache[string, *decision]
|
||||
}
|
||||
|
||||
// newTransport returns what carries requests to the app. It never goes
|
||||
@@ -299,11 +302,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// The health endpoint is answered at once, before any check, so that
|
||||
// a health checker is never refused. It does not ask the app.
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -339,3 +338,23 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rq.forward(r.Context())
|
||||
}
|
||||
|
||||
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
|
||||
// logs through h, with what httpServer sets.
|
||||
func (h *handler) server(httpServer *http.Server) *Server {
|
||||
httpServer.Addr = h.config.ListenAddr
|
||||
httpServer.ErrorLog = h.errorLog
|
||||
|
||||
return &Server{
|
||||
Server: httpServer,
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: h.metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,16 @@ func newProxy(
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newServerWith(t, proxy.New, appURL, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newServerWith is newProxy for the server build makes.
|
||||
func newServerWith(
|
||||
t *testing.T, build func(proxy.Params) *proxy.Server,
|
||||
appURL, geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*proxy.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||
wafMode: off,
|
||||
@@ -334,7 +344,7 @@ func newProxy(
|
||||
}
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
server := build(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
|
||||
@@ -110,6 +110,7 @@ func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||
rq.h.metrics.ReputationHit(hit.Source)
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventReputationHit,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: rq.h.clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
|
||||
+97
-55
@@ -39,20 +39,20 @@ type refusal struct {
|
||||
limit string
|
||||
}
|
||||
|
||||
// request is one request on its way through smallwebwaf, from the moment
|
||||
// its headers have been read to its log line.
|
||||
type request struct {
|
||||
h *handler
|
||||
in *http.Request
|
||||
// rc sets the deadlines of the connection to the client.
|
||||
rc *http.ResponseController
|
||||
out *responseWriter
|
||||
body *requestBody // nil for a request without a body
|
||||
line requestlog.Line
|
||||
|
||||
client netip.Addr
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
// decision is what the checks note of a request: its log line, and what
|
||||
// counting the request needs once it has ended. servewaf keeps a copy of it
|
||||
// until the request's report comes. It holds no credentials, since the log line
|
||||
// never does, and neither the request's objects nor their bodies.
|
||||
type decision struct {
|
||||
line requestlog.Line
|
||||
client netip.Addr
|
||||
// requestURI is the path with the query, as a ban's notes keep it.
|
||||
requestURI string
|
||||
// site is, in servewaf, the host the visitor asked for, which the
|
||||
// alerts name, since one servewaf serves many sites. It is "" otherwise.
|
||||
site string
|
||||
// allowed is true for a question answered with DispositionAllow.
|
||||
allowed bool
|
||||
// lookedUp is true once the client's AS number and country have been
|
||||
// looked up, whether or not an answer was there, and lookupAnswer is
|
||||
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
||||
@@ -65,18 +65,37 @@ type request struct {
|
||||
counted bool
|
||||
limitPercent, bytesPercent percentage
|
||||
// attack is true for a request that matched a ban rule or asked for a
|
||||
// trap path, ruleBlocked for one a block rule refused, wafBlocked for
|
||||
// one the Core Rule Set refused, and tokenRefused for one refused for a
|
||||
// missing or wrong token, each an offence its client's history counts.
|
||||
attack, ruleBlocked, wafBlocked, tokenRefused bool
|
||||
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
|
||||
// one the Core Rule Set refused, each an offence its client's history
|
||||
// counts.
|
||||
attack, ruleBlocked, wafBlocked bool
|
||||
// reputation is the reputation sources that list the client, for the
|
||||
// notes of a ban the request makes.
|
||||
reputation []bans.ReputationHit
|
||||
}
|
||||
|
||||
// request is one request on its way through smallwebwaf, from the moment
|
||||
// its headers have been read to its log line.
|
||||
type request struct {
|
||||
decision
|
||||
|
||||
h *handler
|
||||
in *http.Request
|
||||
// rc sets the deadlines of the connection to the client.
|
||||
rc *http.ResponseController
|
||||
out *responseWriter
|
||||
body *requestBody // nil for a request without a body
|
||||
|
||||
peer netip.Addr
|
||||
peerTrusted bool
|
||||
// tokenRefused is true for a request refused for a missing or wrong
|
||||
// token, an offence its client's history counts.
|
||||
tokenRefused bool
|
||||
// blocklisted is true once a blocklist is found to list the client,
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||
// reputation is the reputation sources that list the client, for the
|
||||
// notes of a ban the request makes.
|
||||
reputation []bans.ReputationHit
|
||||
start time.Time
|
||||
start time.Time
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
checked time.Time
|
||||
@@ -108,50 +127,62 @@ type request struct {
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it notes the time, counts the request as
|
||||
// under way, works out the client, and starts the log line with what is
|
||||
// known of the request.
|
||||
// newRequest starts handling r: it works out the client from the TCP peer
|
||||
// and X-Forwarded-For, and goes on as startRequest does.
|
||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
|
||||
|
||||
return h.startRequest(w, r, client, peer, isInside(peer, trusted))
|
||||
}
|
||||
|
||||
// startRequest starts handling r from client, which came through peer: it
|
||||
// notes the time, counts the request as under way, and starts the log line
|
||||
// with what is known of the request. peerTrusted says whether to believe
|
||||
// the request's id and scheme, which r's headers give.
|
||||
func (h *handler) startRequest(
|
||||
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, peerTrusted bool,
|
||||
) *request {
|
||||
h.metrics.RequestStarted()
|
||||
|
||||
start := time.Now()
|
||||
peer := peerAddress(r)
|
||||
trusted := h.config.TrustedProxies
|
||||
peerTrusted := isInside(peer, trusted)
|
||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||
client := clientAddress(peer, forwardedFor, trusted)
|
||||
|
||||
rq := &request{
|
||||
decision: decision{
|
||||
client: client,
|
||||
requestURI: r.URL.RequestURI(),
|
||||
line: requestlog.Line{
|
||||
Time: requestlog.FormatTime(start),
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, peerTrusted),
|
||||
Host: r.Host,
|
||||
Path: rules.Path(r),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, peerTrusted),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: h.clientGroup(client).String(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||
HasCookie: len(r.Header.Values("Cookie")) > 0,
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
},
|
||||
h: h,
|
||||
in: r,
|
||||
rc: http.NewResponseController(w),
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
client: client,
|
||||
peer: peer,
|
||||
peerTrusted: peerTrusted,
|
||||
start: start,
|
||||
line: requestlog.Line{
|
||||
Time: requestlog.FormatTime(start),
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, peerTrusted),
|
||||
Host: r.Host,
|
||||
Path: rules.Path(r),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, peerTrusted),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: h.clientGroup(client).String(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||
HasCookie: len(r.Header.Values("Cookie")) > 0,
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
}
|
||||
|
||||
// A length of -1 is a body whose length was not announced.
|
||||
@@ -569,10 +600,20 @@ func (rq *request) finish() {
|
||||
}
|
||||
|
||||
// Counted before the log line is written, so that the metrics count
|
||||
// every request whose line is out.
|
||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
||||
// every request whose line is out. An allowed request is the exception:
|
||||
// its report gives the status, bytes and times the metrics count.
|
||||
if rq.allowed {
|
||||
rq.h.metrics.RequestAllowed()
|
||||
} else {
|
||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
||||
}
|
||||
|
||||
err := requestlog.Write(rq.h.requestLog, line)
|
||||
rq.writeLine()
|
||||
}
|
||||
|
||||
// writeLine writes the request's log line.
|
||||
func (rq *request) writeLine() {
|
||||
err := requestlog.Write(rq.h.requestLog, &rq.line)
|
||||
if err != nil {
|
||||
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
||||
}
|
||||
@@ -634,6 +675,7 @@ func (rq *request) countAnomalies() {
|
||||
answer, _ := rq.answerAtTheEnd()
|
||||
|
||||
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
ClientGroup: rq.h.clientGroup(rq.client),
|
||||
ASN: answer.ASN,
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The headers of rproxy's question and of servewaf's answer, as
|
||||
// "servewaf" in SPEC.md gives them.
|
||||
const (
|
||||
// ClientHeader marks a request as rproxy's question, and gives the
|
||||
// visitor's address.
|
||||
ClientHeader = "X-Smallwebwaf-Client"
|
||||
// PeerHeader gives the address of rproxy's own TCP peer.
|
||||
PeerHeader = "X-Smallwebwaf-Peer"
|
||||
// ContentLengthHeader gives the body length the visitor announced.
|
||||
ContentLengthHeader = "X-Smallwebwaf-Content-Length"
|
||||
// DispositionHeader gives servewaf's answer.
|
||||
DispositionHeader = "X-Smallwebwaf-Disposition"
|
||||
)
|
||||
|
||||
// The values of DispositionHeader.
|
||||
const (
|
||||
// DispositionAllow tells rproxy to pass the request to the app.
|
||||
DispositionAllow = "allow"
|
||||
// DispositionDeny tells rproxy to give the visitor the answer's status,
|
||||
// headers and body.
|
||||
DispositionDeny = "deny"
|
||||
// DispositionClose tells rproxy to close the visitor's connection
|
||||
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
|
||||
// servewaf closing the question's connection instead would read as
|
||||
// servewaf being down, and rproxy would then let the request through.
|
||||
DispositionClose = "close"
|
||||
)
|
||||
|
||||
// ReportPath is where rproxy sends the report of each request servewaf
|
||||
// answered.
|
||||
const ReportPath = "/_smallwebwaf/report"
|
||||
|
||||
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||
const QuestionBodyMaxBytes = 64 << 10
|
||||
|
||||
const (
|
||||
// maxAwaitingReport is the most answered requests held until their
|
||||
// reports come. Past it, the request answered longest ago is dropped.
|
||||
maxAwaitingReport = 10000
|
||||
// maxRequestIDBytes is the longest id a question may give.
|
||||
maxRequestIDBytes = 128
|
||||
// reportBodyMaxBytes is the most of a report's body that is read. It is
|
||||
// far above any report's.
|
||||
reportBodyMaxBytes = 1 << 20
|
||||
// maxHTTPStatus is the highest status a report may give.
|
||||
maxHTTPStatus = 599
|
||||
// maxReportedBytes is the most bytes a report may give for each way, 1
|
||||
// PiB, so that adding them up cannot overflow.
|
||||
maxReportedBytes = 1 << 50
|
||||
// maxReportedMilliseconds is the longest duration a report may give,
|
||||
// about 31 years, so that it fits a time.Duration.
|
||||
maxReportedMilliseconds = 1e12
|
||||
// serveWAFTimeout bounds how long a request to servewaf takes to arrive,
|
||||
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open
|
||||
// connection waits for its next request.
|
||||
serveWAFTimeout = 60 * time.Second
|
||||
serveWAFIdleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errReportFieldMissing = errors.New("a field is missing")
|
||||
errReportValue = errors.New("a value is out of range")
|
||||
)
|
||||
|
||||
// Report is the JSON object rproxy posts to ReportPath once the visitor's
|
||||
// answer has ended, its fields named as the request log's of the same
|
||||
// meaning. RequestID is the question's id. Status is what the visitor got, 0
|
||||
// if nothing. The durations are in milliseconds.
|
||||
//
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Report struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Status int `json:"status"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
||||
}
|
||||
|
||||
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
|
||||
// rproxy's questions with the checks New runs, takes rproxy's reports and
|
||||
// answers its own endpoints. It passes nothing to an app. rproxy applies
|
||||
// the size and time limits to the visitor, so their settings do not apply
|
||||
// here. Go's default limit on a request's line and headers, 1 MiB, stays:
|
||||
// it is far above rproxy's, and a question refused for its size would have
|
||||
// rproxy let the request through.
|
||||
func NewServeWAF(params Params) *Server {
|
||||
cfg := *params.Config
|
||||
cfg.RequestMaxBytes = 0
|
||||
cfg.ClientRequestTimeout = serveWAFTimeout
|
||||
// The server's WriteTimeout does this. It sets a new deadline for each
|
||||
// request, where a deadline a handler sets stays on a kept-open
|
||||
// connection, and would run out under a later request's answer.
|
||||
cfg.ClientResponseTimeout = 0
|
||||
// A question's body of QuestionBodyMaxBytes may be cut short, and the
|
||||
// Core Rule Set does not read a JSON or XML body over its limit.
|
||||
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
|
||||
params.Config = &cfg
|
||||
|
||||
h := newHandler(params)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
if err != nil {
|
||||
panic(err) // New fails only for a size below one
|
||||
}
|
||||
|
||||
h.awaitingReport = awaitingReport
|
||||
|
||||
return h.server(&http.Server{
|
||||
Handler: http.HandlerFunc(h.serveWAF),
|
||||
ReadTimeout: serveWAFTimeout,
|
||||
WriteTimeout: serveWAFTimeout,
|
||||
IdleTimeout: serveWAFIdleTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// serveWAF handles one request to servewaf: a request carrying
|
||||
// ClientHeader is rproxy's question, and any other is for servewaf itself.
|
||||
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||
h.answerQuestion(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.answerOwnEndpoint(w, r)
|
||||
}
|
||||
|
||||
// answerQuestion runs the checks on the visitor's request a question
|
||||
// carries, and answers rproxy what to do with it. A denied request's counting
|
||||
// is finished here, and an allowed one's by its report.
|
||||
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
client, body, ok := h.readQuestion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// One that cannot be read leaves the log line's peer_ip invalid.
|
||||
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
|
||||
|
||||
rq := h.startRequest(w, asTheVisitorSentIt(r, body), client.Unmap(), peer.Unmap(),
|
||||
true)
|
||||
// The last deferred runs first, so that what is held has the finished log
|
||||
// line. Go's server sends the answer only once this function has
|
||||
// returned, so the report cannot come before it is held.
|
||||
defer rq.holdForReport()
|
||||
defer rq.finish()
|
||||
|
||||
rq.site = r.Host
|
||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
|
||||
|
||||
if refused == nil && !underOwnPrefix {
|
||||
// In observe mode, what enforce mode would have refused counts here.
|
||||
defer rq.countRefusal()
|
||||
|
||||
rq.allow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The last deferred runs first, as in ServeHTTP.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
|
||||
if refused != nil {
|
||||
rq.deny(*refused)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The app never sees this prefix. servewaf's own endpoints are at its
|
||||
// own address, so this answers as a path with no endpoint does.
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
}
|
||||
|
||||
// readQuestion returns the visitor's address and the body a question carries,
|
||||
// and whether servewaf takes the question. It answers a question it does not
|
||||
// take with 400 and no disposition, which rproxy treats as it treats no
|
||||
// answer. The id of a question it takes is held from here on, without a
|
||||
// decision yet, so that another question with that id is refused while this
|
||||
// one is being handled.
|
||||
//
|
||||
// The body is read before any check, since Go's server does not send an answer
|
||||
// until it has read the body. A question whose body stops arriving gets no
|
||||
// answer, and is not looked at.
|
||||
func (h *handler) readQuestion(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) (netip.Addr, []byte, bool) {
|
||||
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
|
||||
if err != nil || client.Zone() != "" {
|
||||
h.refuseQuestion(w, ClientHeader+" is not an address without a zone")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
id := r.Header.Get(requestIDHeader)
|
||||
if id == "" || len(id) > maxRequestIDBytes || !utf8.ValidString(id) {
|
||||
h.refuseQuestion(w, requestIDHeader+" is not a valid id")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes))
|
||||
|
||||
if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge {
|
||||
h.refuseQuestion(w, "the body is over 64 KiB")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
h.processLog.Warn("a question's body stopped arriving", "error", err.Error())
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
||||
if held {
|
||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
return client, body, true
|
||||
}
|
||||
|
||||
// refuseQuestion answers a question servewaf does not take, for reason.
|
||||
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
||||
h.processLog.Warn("a question is refused", "reason", reason)
|
||||
http.Error(w, reason, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
||||
// log line are to see it: without the headers rproxy adds for servewaf, with
|
||||
// body, which has been read of r, and with the length the visitor announced,
|
||||
// as -1 when it announced none. The question's own length covers only the part
|
||||
// of the body it carries.
|
||||
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
||||
sent := r.Clone(r.Context())
|
||||
announced := sent.Header.Get(ContentLengthHeader)
|
||||
|
||||
sent.Header.Del(ClientHeader)
|
||||
sent.Header.Del(PeerHeader)
|
||||
sent.Header.Del(ContentLengthHeader)
|
||||
sent.Header.Del("Content-Length")
|
||||
|
||||
sent.ContentLength = -1
|
||||
|
||||
length, err := strconv.ParseInt(announced, 10, 64)
|
||||
if err == nil && length >= 0 {
|
||||
sent.ContentLength = length
|
||||
sent.Header.Set("Content-Length", announced)
|
||||
}
|
||||
|
||||
sent.Body = http.NoBody
|
||||
if len(body) > 0 {
|
||||
sent.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
return sent
|
||||
}
|
||||
|
||||
// allow answers a question with DispositionAllow and, while
|
||||
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
||||
func (rq *request) allow() {
|
||||
rq.allowed = true
|
||||
|
||||
header := rq.out.Header()
|
||||
header.Set(DispositionHeader, DispositionAllow)
|
||||
|
||||
if rq.h.config.AddLookupHeaders {
|
||||
setLookupHeaders(header, rq.line.ASN, rq.line.Country)
|
||||
}
|
||||
|
||||
rq.out.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// holdForReport holds a copy of what the report's steps need, by the request's
|
||||
// id. A report only ever reaches that copy, never the request being handled.
|
||||
func (rq *request) holdForReport() {
|
||||
held := rq.decision
|
||||
rq.h.awaitingReport.Add(rq.line.RequestID, &held)
|
||||
}
|
||||
|
||||
// deny answers a question with what the visitor is to get for r.
|
||||
func (rq *request) deny(r refusal) {
|
||||
if r.status == 0 {
|
||||
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||
// status stays 0: the visitor is sent nothing.
|
||||
rq.refused.CompareAndSwap(nil, &r)
|
||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
rq.answer(r)
|
||||
}
|
||||
|
||||
// answerOwnEndpoint answers a request to servewaf's own address. No check
|
||||
// runs on it: the address is on the private network, where the peers are
|
||||
// rproxy and the admin's tools, not visitors.
|
||||
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == ReportPath {
|
||||
h.takeReport(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
peer := peerAddress(r)
|
||||
|
||||
rq := h.startRequest(w, r, peer, peer, false)
|
||||
defer rq.finish()
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerAdmin()
|
||||
}
|
||||
|
||||
// takeReport answers POST ReportPath: it finishes the request the report
|
||||
// names, if it holds that request's decision, and ignores the report
|
||||
// otherwise.
|
||||
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||
report, err := readReport(w, r)
|
||||
if err != nil {
|
||||
h.processLog.Warn("a report is refused", "error", err.Error())
|
||||
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
held, found := h.awaitingReport.Peek(report.RequestID)
|
||||
// Remove reports true to one report only, of several that name the id.
|
||||
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
||||
h.finishWith(held, report)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// readReport reads a report from r's body, which must be one JSON object with
|
||||
// all of Report's fields, in the ranges SPEC.md gives.
|
||||
func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
|
||||
var report Report
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes))
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read the body: %w", err)
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
|
||||
err = json.Unmarshal(body, &fields)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("decode the body: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"request_id", "status", "request_bytes", "response_bytes",
|
||||
"duration_total", "duration_upstream_total",
|
||||
} {
|
||||
value, there := fields[name]
|
||||
if !there || string(value) == "null" {
|
||||
return report, fmt.Errorf("%w: %s", errReportFieldMissing, name)
|
||||
}
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &report)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("decode the body: %w", err)
|
||||
}
|
||||
|
||||
return report, report.checkRanges()
|
||||
}
|
||||
|
||||
// checkRanges returns an error for a value of the report that cannot be true.
|
||||
func (report *Report) checkRanges() error {
|
||||
switch {
|
||||
case report.Status != 0 &&
|
||||
(report.Status < http.StatusContinue || report.Status > maxHTTPStatus):
|
||||
return fmt.Errorf("%w: status", errReportValue)
|
||||
case report.RequestBytes < 0 || report.RequestBytes > maxReportedBytes:
|
||||
return fmt.Errorf("%w: request_bytes", errReportValue)
|
||||
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
|
||||
return fmt.Errorf("%w: response_bytes", errReportValue)
|
||||
case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds:
|
||||
return fmt.Errorf("%w: duration_total", errReportValue)
|
||||
case report.DurationUpstreamTotal < 0 ||
|
||||
report.DurationUpstreamTotal > maxReportedMilliseconds:
|
||||
return fmt.Errorf("%w: duration_upstream_total", errReportValue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishWith finishes a request with what its report says, and writes the
|
||||
// request's last log line, which holds its decision's fields and the report's.
|
||||
// For an allowed request it first takes the steps ServeHTTP takes once a
|
||||
// request passed to the app has ended, with the status and bytes of the report
|
||||
// in place of those it would have seen. A denied request's counting was
|
||||
// finished at its decision.
|
||||
func (h *handler) finishWith(held *decision, report Report) {
|
||||
rq := &request{
|
||||
decision: *held,
|
||||
h: h,
|
||||
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
|
||||
body: &requestBody{},
|
||||
}
|
||||
rq.body.bytes.Store(report.RequestBytes)
|
||||
|
||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport
|
||||
rq.line.Status = report.Status
|
||||
rq.line.RequestBytes = report.RequestBytes
|
||||
rq.line.ResponseBytes = report.ResponseBytes
|
||||
rq.line.DurationTotal = report.DurationTotal
|
||||
|
||||
if rq.allowed {
|
||||
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
||||
// addToHistory goes by this for whether the request reached the app.
|
||||
rq.upstreamStart = time.Now()
|
||||
|
||||
rq.countBytes()
|
||||
rq.countAnomalies()
|
||||
rq.addToHistory()
|
||||
|
||||
rq.h.metrics.RequestReported(&rq.line,
|
||||
time.Duration(report.DurationTotal*float64(time.Millisecond)),
|
||||
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
rq.writeLine()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
||||
// JSON object per request, 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 (
|
||||
@@ -60,6 +61,16 @@ const OffenceLimit = "limit"
|
||||
// that broke the error burst.
|
||||
const LimitHitErrorBurst = "error_burst"
|
||||
|
||||
// The servewaf_line a request line names, which says which of servewaf's two
|
||||
// lines about a question it is.
|
||||
const (
|
||||
// ServeWAFLineDecision is servewaf's line at its decision.
|
||||
ServeWAFLineDecision = "decision"
|
||||
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with
|
||||
// the decision's fields and the report's together.
|
||||
ServeWAFLineReport = "report"
|
||||
)
|
||||
|
||||
// timeLayout is RFC 3339 with milliseconds.
|
||||
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
@@ -70,6 +81,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's
|
||||
// two lines about a question.
|
||||
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached
|
||||
// smallwebwaf, or the trusted proxy in front of it.
|
||||
@@ -180,10 +194,50 @@ 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 servewaf's answer or, while DefaultDisposition is true, the
|
||||
// value of SWRP_DEFAULT_DISPOSITION. It is "" when servewaf was not asked.
|
||||
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,725 @@
|
||||
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 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 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,370 @@
|
||||
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.MethodConnect, 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 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": "CONNECT 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 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,590 @@
|
||||
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, "" until servewaf has been
|
||||
// asked.
|
||||
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 {
|
||||
return // the visitor has gone, and there is no one to answer
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
requestEnd := rq.endRequest()
|
||||
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,212 @@
|
||||
// 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. When servewaf gives no
|
||||
// answer, SWRP_DEFAULT_DISPOSITION decides. A request it cannot put to servewaf
|
||||
// as the visitor sent it, it refuses itself. 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"
|
||||
"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/"
|
||||
|
||||
// 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 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 !canBeAsked(r) {
|
||||
rq.answer(http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !rq.readFirstPartOfBody() {
|
||||
return
|
||||
}
|
||||
|
||||
answer := rq.askServeWAF(r.Context())
|
||||
|
||||
switch rq.disposition {
|
||||
case proxy.DispositionAllow:
|
||||
rq.passToApp(r.Context())
|
||||
case proxy.DispositionDeny:
|
||||
if answer == nil {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerWith(answer)
|
||||
default: // proxy.DispositionClose, or none when the visitor has gone
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,234 @@
|
||||
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 (
|
||||
errNoDisposition = errors.New("the answer has no valid " + proxy.DispositionHeader)
|
||||
errAnswerTooLong = errors.New("the answer's body is over 1 MiB")
|
||||
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
|
||||
}
|
||||
|
||||
// askServeWAF asks servewaf about the request, and notes what is to be done
|
||||
// with it. It returns servewaf's answer, or nil when servewaf gave none, and
|
||||
// SWRP_DEFAULT_DISPOSITION decides. When the visitor went away while servewaf
|
||||
// was being asked, servewaf has not failed, and nothing is noted. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer {
|
||||
answer, err := rq.ask(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
||||
|
||||
rq.disposition = rq.h.config.DefaultDisposition
|
||||
rq.byDefault = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.disposition = answer.header.Get(proxy.DispositionHeader)
|
||||
rq.answered = true
|
||||
rq.asn = answer.header.Get(asnHeader)
|
||||
rq.country = answer.header.Get(countryHeader)
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
// ask sends servewaf the question, and returns its answer, which must have come
|
||||
// whole within SWRP_TIMEOUT_MS, with a valid disposition. 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()
|
||||
}()
|
||||
|
||||
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, errAnswerTooLong
|
||||
}
|
||||
|
||||
switch res.Header.Get(proxy.DispositionHeader) {
|
||||
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose:
|
||||
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w, and status %d", errNoDisposition, res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
_, _ = 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,688 @@
|
||||
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 TestAConnectBodyStaysABodyAtServeWAF(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, "CONNECT /tunnel 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.StatusForbidden {
|
||||
t.Errorf("status %d, want the 403 of the Core Rule Set, which refuses CONNECT",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package smallwebwaf_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||
)
|
||||
|
||||
// commandForMain, while set in the environment, has this test binary run Main
|
||||
// with the command it gives, in place of the tests, so that a test can run
|
||||
// Main in a process of its own.
|
||||
const commandForMain = "SMALLWEBWAF_TEST_COMMAND"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
command, set := os.LookupEnv(commandForMain)
|
||||
if !set {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
os.Args = strings.Fields("smallwebwaf " + command)
|
||||
|
||||
os.Exit(smallwebwaf.Main(testVersion))
|
||||
}
|
||||
|
||||
func TestMainAnswersQuestionsOnlyAsServeWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
command, disposition string
|
||||
}{
|
||||
{"servewaf", proxy.DispositionAllow},
|
||||
{"", ""},
|
||||
} {
|
||||
t.Run("command "+tc.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("find the test binary: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||
defer cancel()
|
||||
|
||||
out := &output{}
|
||||
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||
cmd.Stdout = out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
commandForMain+"="+tc.command,
|
||||
listenAddr+"="+localhost+":0",
|
||||
upstreamURL+"="+startApp(t),
|
||||
stateDir+"="+t.TempDir(),
|
||||
rulesDir+"="+t.TempDir(),
|
||||
lookupSource+"=off")
|
||||
|
||||
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)
|
||||
ask(t, addr, "/", http.StatusOK, tc.disposition)
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends servewaf at addr rproxy's question about a request for path from
|
||||
// placed, checks the answer's status and disposition, and closes it.
|
||||
func ask(t *testing.T, addr, path string, status int, disposition string) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"http://"+addr+path, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set(proxy.ClientHeader, placed)
|
||||
req.Header.Set("X-Request-ID", rand.Text())
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("question: %v", err)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != status ||
|
||||
res.Header.Get(proxy.DispositionHeader) != disposition {
|
||||
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
|
||||
res.Header.Get(proxy.DispositionHeader), status, disposition)
|
||||
}
|
||||
}
|
||||
|
||||
// runServeWAF runs `smallwebwaf servewaf` with the settings in env, has use
|
||||
// send it requests at the address it listens on, stops it as SIGTERM does, and
|
||||
// checks that it stopped in order.
|
||||
func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
||||
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)
|
||||
use(addr)
|
||||
stop()
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("still running after being told to stop")
|
||||
}
|
||||
|
||||
out.line(t, "msg", "stopped")
|
||||
}
|
||||
|
||||
func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}
|
||||
|
||||
runServeWAF(t, env, func(addr string) {
|
||||
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
|
||||
// The default rule file bans a probe for this.
|
||||
ask(t, addr, "/.env", http.StatusForbidden, proxy.DispositionDeny)
|
||||
})
|
||||
|
||||
runServeWAF(t, env, func(addr string) {
|
||||
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithoutACommandTheClientHeaderIsNoQuestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runUntilStopped(t, map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: t.TempDir(),
|
||||
}, func(url string) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||
http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set(proxy.ClientHeader, placed)
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil || string(body) != greeting ||
|
||||
res.Header.Get(proxy.DispositionHeader) != "" {
|
||||
t.Errorf("answered %q (%v) with disposition %q, want the app's greeting",
|
||||
body, err, res.Header.Get(proxy.DispositionHeader))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||
// 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.
|
||||
// state files. It serves them in front of an app, or, as `smallwebwaf
|
||||
// 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 (
|
||||
@@ -45,11 +48,17 @@ type Params struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
// Stdout receives the request log and the process's own messages.
|
||||
Stdout io.Writer
|
||||
// ServeWAF makes it `smallwebwaf servewaf`, which answers rproxy's
|
||||
// questions, rather than the proxy in front of an app.
|
||||
ServeWAF bool
|
||||
}
|
||||
|
||||
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
|
||||
// process's exit status. Run as `smallwebwaf healthcheck`, it is the
|
||||
// container's health check instead.
|
||||
// process's exit status. Run as `smallwebwaf servewaf`, it answers
|
||||
// rproxy's questions rather than pass requests to an app. Run as
|
||||
// `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)
|
||||
@@ -59,11 +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
|
||||
@@ -117,7 +133,12 @@ func Run(ctx context.Context, params Params) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
||||
newProxy := proxy.New
|
||||
if params.ServeWAF {
|
||||
newProxy = proxy.NewServeWAF
|
||||
}
|
||||
|
||||
server, err := newServer(newProxy, cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
||||
if err != nil {
|
||||
processLog.Error("cannot use the lookup database", "error", err.Error())
|
||||
|
||||
@@ -151,10 +172,12 @@ func Run(ctx context.Context, params Params) int {
|
||||
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
||||
}
|
||||
|
||||
// newServer returns the server smallwebwaf runs, with the metrics of the
|
||||
// alerts, after reading the lookup database while SWWAF_LOOKUP_SOURCE is
|
||||
// file. A lookup database that cannot be read is an error.
|
||||
// newServer returns the server smallwebwaf runs, which newProxy makes, with
|
||||
// the metrics of the alerts, after reading the lookup database while
|
||||
// SWWAF_LOOKUP_SOURCE is file. A lookup database that cannot be read is an
|
||||
// error.
|
||||
func newServer(
|
||||
newProxy func(proxy.Params) *proxy.Server,
|
||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||
) (*proxy.Server, error) {
|
||||
@@ -174,7 +197,7 @@ func newServer(
|
||||
}
|
||||
}
|
||||
|
||||
server := proxy.New(proxy.Params{
|
||||
server := newProxy(proxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: stdout,
|
||||
ProcessLog: processLog,
|
||||
|
||||
@@ -76,21 +76,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
o.mu.Lock()
|
||||
text := o.buf.String()
|
||||
o.mu.Unlock()
|
||||
|
||||
for line := range strings.Lines(text) {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil {
|
||||
t.Fatalf("output line %q is not JSON: %v", line, err)
|
||||
}
|
||||
|
||||
if fields[key] == value {
|
||||
return fields
|
||||
}
|
||||
fields := o.findLine(t, func(fields map[string]any) bool {
|
||||
return fields[key] == value
|
||||
})
|
||||
if fields != nil {
|
||||
return fields
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
@@ -101,6 +91,29 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// findLine returns the first line written so far for which matches is true, or
|
||||
// nil.
|
||||
func (o *output) findLine(
|
||||
t *testing.T, matches func(map[string]any) bool,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
for line := range strings.Lines(o.text()) {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil {
|
||||
t.Fatalf("output line %q is not JSON: %v", line, err)
|
||||
}
|
||||
|
||||
if matches(fields) {
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// text returns everything written so far.
|
||||
func (o *output) text() string {
|
||||
o.mu.Lock()
|
||||
@@ -113,6 +126,13 @@ func (o *output) text() string {
|
||||
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
||||
// so that no test sends GeoJS its clients' addresses.
|
||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||
return runAs(ctx, false, env, out)
|
||||
}
|
||||
|
||||
// runAs is run as `smallwebwaf servewaf` while serveWAF is true.
|
||||
func runAs(
|
||||
ctx context.Context, serveWAF bool, env map[string]string, out *output,
|
||||
) int {
|
||||
return smallwebwaf.Run(ctx, smallwebwaf.Params{
|
||||
Version: testVersion,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
@@ -123,7 +143,8 @@ func run(ctx context.Context, env map[string]string, out *output) int {
|
||||
|
||||
return value, ok
|
||||
},
|
||||
Stdout: out,
|
||||
Stdout: out,
|
||||
ServeWAF: serveWAF,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1244,9 +1265,9 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
|
||||
"SWWAF_ALLOW_NETS": "",
|
||||
rateLimitExemptNets: "",
|
||||
"SWWAF_DENY_NETS": "",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
|
||||
rateLimitPerDay: "1000000",
|
||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||
rateLimitPerDay: "300000",
|
||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||
"SWWAF_DENIED_COUNTRIES": "",
|
||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||
|
||||
Reference in New Issue
Block a user