Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0b71be0f7 | ||
|
|
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
|
accepts requests on the app's behalf, passes them to the app, and passes the
|
||||||
app's answers back.
|
app's answers back.
|
||||||
|
|
||||||
It needs no setting. Every setting has a default chosen for a service on the
|
It needs no setting. Every `SWWAF_` setting has a default chosen for a service
|
||||||
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The
|
on the open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and
|
||||||
reference documents under [`docs/`](docs/) are listed under "Documents" below.
|
why. The reference documents under [`docs/`](docs/) are listed under "Documents"
|
||||||
|
below.
|
||||||
|
|
||||||
## What it protects against
|
## What it protects against
|
||||||
|
|
||||||
- Scrapers that fetch every page of a site, all day. Rate limiting gives each
|
- 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
|
client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves
|
||||||
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
|
room for programmatic use of an API, such as an automation fleet on another
|
||||||
a limit is refused and banned. The first ban lasts an hour. Each repeat within
|
host. A fleet of about 20 agents makes about 120 requests a minute at its
|
||||||
a day makes the next ban three times as long. A ban that would last more than
|
busiest. A gitea page opened with an empty cache loads about 40 files, so a
|
||||||
seven days is permanent.
|
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
|
- 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
|
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
|
next request during the ban makes the ban permanent. The default rule file
|
||||||
@@ -333,12 +343,21 @@ pinned.
|
|||||||
`127.0.0.1` and `::1` for forwarded headers, besides the private address
|
`127.0.0.1` and `::1` for forwarded headers, besides the private address
|
||||||
ranges.
|
ranges.
|
||||||
- Both processes start with the container's environment variables, so every
|
- Both processes start with the container's environment variables, so every
|
||||||
setting of `smallwebwaf` starts with `SWWAF_`, and the app can read a token
|
setting of this `smallwebwaf` process starts with `SWWAF_`, and the app can
|
||||||
given as one. A token given as a file that only the `smallwebwaf` user can
|
read a token given as one. A token given as a file that only the `smallwebwaf`
|
||||||
read is out of its reach (see "Settings given as files" in
|
user can read is out of its reach (see "Settings given as files" in
|
||||||
[`docs/settings.md`](docs/settings.md)).
|
[`docs/settings.md`](docs/settings.md)).
|
||||||
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
|
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
|
||||||
them across a deploy.
|
them across a deploy.
|
||||||
|
- `smallwebwaf servewaf` runs the same checks for the sites that ask it about
|
||||||
|
each request, and passes nothing to an app (see "`servewaf`" in
|
||||||
|
[`SPEC.md`](SPEC.md)).
|
||||||
|
- `smallwebwaf rproxy` is the reverse proxy in front of one app that asks
|
||||||
|
`smallwebwaf servewaf` about each request. When `servewaf` cannot be connected
|
||||||
|
to, does not answer at all, or does not answer within `SWRP_TIMEOUT_MS`,
|
||||||
|
`SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`, keeps the site
|
||||||
|
answering. The settings of `rproxy` start with `SWRP_`, and it needs
|
||||||
|
`SWRP_WAF_BACKEND`, `servewaf`'s URL (see "`rproxy`" in [`SPEC.md`](SPEC.md)).
|
||||||
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
|
- `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
|
in progress five seconds to finish, so the app must keep answering for five
|
||||||
seconds after `SIGTERM`.
|
seconds after `SIGTERM`.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
`smallwebwaf` is a web application firewall and reverse proxy for one app. It
|
`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
|
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,
|
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
|
`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
|
under `docs/` give the settings, the formats and the lists. This document links
|
||||||
to them.
|
to them.
|
||||||
@@ -12,9 +12,9 @@ to them.
|
|||||||
|
|
||||||
- It protects one self-hosted app on the open internet from scrapers, scanners
|
- 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
|
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
|
all. Every `SWWAF_` setting has a default chosen for a service facing the
|
||||||
in 2026. A setting that needs an alert destination, an account key or a token
|
internet in 2026. A setting that needs an alert destination, an account key or
|
||||||
stays off until the operator supplies it.
|
a token stays off until the operator supplies it.
|
||||||
- It limits each client's requests and bytes per minute, hour and day. It bounds
|
- 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.
|
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,
|
- 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.
|
reputation sources, and send alerts.
|
||||||
- Everything is set by environment variables whose names start with `SWWAF_`,
|
- Everything is set by environment variables whose names start with `SWWAF_`,
|
||||||
since `smallwebwaf` shares its container, and so its environment, with the
|
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
|
app. `smallwebwaf rproxy` uses `SWRP_` in their place. The only other inputs
|
||||||
lookup database, and a setting given as a file.
|
are files: the rule files, the state files, a lookup database, and a `SWWAF_`
|
||||||
|
setting given as a file.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
@@ -47,14 +48,20 @@ to them.
|
|||||||
- `internal/smallwebwaf`: the process. It reads the settings, the rule
|
- `internal/smallwebwaf`: the process. It reads the settings, the rule
|
||||||
files, the lookup database and the state files, listens, and serves until
|
files, the lookup database and the state files, listens, and serves until
|
||||||
`SIGTERM` or `SIGINT`. Then it stops, writing the state files. Run as
|
`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/config`: reads the settings, the one place they are read.
|
||||||
- `internal/proxy`: what happens to each request. It works out the client
|
- `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
|
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
|
standard library's `httputil.ReverseProxy`, within the timeouts and size
|
||||||
limits, and answers its own endpoints in `answerAdmin`. Once the request
|
limits, and answers its own endpoints in `answerAdmin`. Once the request
|
||||||
has ended, it counts bytes, refusals and anomalies, and writes the log
|
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
|
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
|
||||||
refusals, and keeps each client's history.
|
refusals, and keeps each client's history.
|
||||||
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
|
- `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_WAF_ANOMALY_THRESHOLD`, `SWWAF_WAF_BODY_LIMIT` and
|
||||||
`SWWAF_ERROR_BURST_THRESHOLD`;
|
`SWWAF_ERROR_BURST_THRESHOLD`;
|
||||||
- `SWWAF_ALERT_COOLDOWN` and `SWWAF_ALERT_MAX_PER_HOUR`;
|
- `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`.
|
No other setting can be switched off with `off`.
|
||||||
|
|
||||||
- A list set to an empty value is an empty list, and replaces the default.
|
- 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:
|
- Any `SWWAF_` setting can be read from a file instead, for secrets and long
|
||||||
`SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. The
|
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
||||||
file's contents are the value, less one newline at the end, and are checked as
|
The file's contents are the value, less one newline at the end, and are
|
||||||
the value itself would be. Setting both forms stops the start, and so does
|
checked as the value itself would be. Setting both forms stops the start, and
|
||||||
naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already
|
so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE`
|
||||||
holds a file name, so it has no `_FILE` form.
|
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
|
- Settings, files included, are read once at start. Changing one means
|
||||||
restarting the container. The files `smallwebwaf` watches while it runs are
|
restarting the container. The files `smallwebwaf` watches while it runs are
|
||||||
its state files, its rule files and the lookup database.
|
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
|
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.
|
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
|
- 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
|
through Tor. They are one client, and share its limits and its bans. A known
|
||||||
default limits are sized for about 40 busy people on one address. A known
|
shared address busier than the limits allow belongs in `SWWAF_ALLOW_NETS` or
|
||||||
shared address busier than that belongs in `SWWAF_ALLOW_NETS` or
|
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||||
|
|
||||||
### Request rate limits, per client
|
### Request rate limits, per client
|
||||||
|
|
||||||
Breaking one bans the client, so the defaults sit about twice above what 40
|
Breaking one bans the client. The defaults suit programmatic use of an API, such
|
||||||
people busy with heavy web apps make together on one address. Each such person
|
as an automation fleet on another host, and still stop abusive bots. A fleet of
|
||||||
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
|
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
|
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
|
||||||
the other checks can catch an abuser that slow. They include the trap paths, the
|
a minute and a few quick first visits with an empty cache. A gitea page opened
|
||||||
rule files, the Core Rule Set and the blocklists. An abuser that trips none of
|
with an empty cache loads about 40 files. A client firing requests as fast as
|
||||||
them is not caught. A machine that talks to the app all day can still pass the
|
it can passes the limit within seconds.
|
||||||
day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
- 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
|
`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
|
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
|
### Byte limits, per client
|
||||||
|
|
||||||
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
|
The hour and day limits sit about twice above what 40 people busy with heavy web
|
||||||
hour and day limits sit about twice above what they move together. A response's
|
apps move together on one address. Each moves about 256 MiB an hour, for ten
|
||||||
bytes are counted when it ends (see step 15). So the minute limit holds two of
|
hours a day. A response's bytes are counted when it ends (see step 15). So the
|
||||||
the largest responses, with their requests, ending in the same minute. No single
|
minute limit holds two of the largest responses, with their requests, ending in
|
||||||
download then breaks a limit. An operator who raises `SWWAF_RESPONSE_MAX_BYTES`
|
the same minute. No single download then breaks a limit. An operator who raises
|
||||||
raises the byte limits with it.
|
`SWWAF_RESPONSE_MAX_BYTES` raises the byte limits with it.
|
||||||
|
|
||||||
### Size and time limits
|
### Size and time limits
|
||||||
|
|
||||||
@@ -1006,6 +1032,417 @@ answers.
|
|||||||
costs).
|
costs).
|
||||||
- A ban made through the endpoints starts at once.
|
- 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.
|
||||||
|
|
||||||
|
Go's HTTP code changes the visitor's headers in two ways:
|
||||||
|
|
||||||
|
- Go's client does not send an empty `User-Agent`.
|
||||||
|
- Go's server adds `Cache-Control: no-cache` to a request with
|
||||||
|
`Pragma: no-cache` and no `Cache-Control`, before `rproxy` sees it.
|
||||||
|
|
||||||
|
`rproxy` itself refuses any request it cannot send this way (see "Requests
|
||||||
|
`rproxy` refuses itself"). So a question always has:
|
||||||
|
|
||||||
|
- a path starting with one `/`;
|
||||||
|
- a `Host` that is not empty and holds no IPv6 zone;
|
||||||
|
- at most one `User-Agent`.
|
||||||
|
|
||||||
|
A body whose length the visitor did not announce comes chunked. Any other body
|
||||||
|
comes with a `Content-Length` of the part it carries.
|
||||||
|
|
||||||
|
`rproxy` sets these headers, replacing any the visitor sent, and adds no others:
|
||||||
|
|
||||||
|
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out,
|
||||||
|
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`
|
||||||
|
gives the visitor its status, headers and body, as it does for any answer
|
||||||
|
without a valid `X-Smallwebwaf-Disposition`.
|
||||||
|
|
||||||
|
In `observe` mode a request that `enforce` mode would refuse is answered
|
||||||
|
`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
|
||||||
|
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:
|
||||||
|
|
||||||
|
- an answer without a valid `X-Smallwebwaf-Disposition`, such as a `400`;
|
||||||
|
- an answer it cannot pass on (see "Without an answer from `servewaf`");
|
||||||
|
- no answer.
|
||||||
|
|
||||||
|
The report carries no `X-Smallwebwaf-Client`. Its body is one JSON object with
|
||||||
|
all of these fields, named as the request log's fields of the same meaning:
|
||||||
|
|
||||||
|
- `request_id`: the question's `X-Request-ID`;
|
||||||
|
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
|
||||||
|
- `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` cannot be
|
||||||
|
connected to, does not answer at all, or does not answer within
|
||||||
|
`SWRP_TIMEOUT_MS`, `SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`,
|
||||||
|
keeps the site answering.
|
||||||
|
|
||||||
|
`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of
|
||||||
|
them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no
|
||||||
|
rule files and no state files, and it writes its lines to stdout only.
|
||||||
|
|
||||||
|
The image's run script and health check are those of `smallwebwaf` with no
|
||||||
|
command (see "Deployment"). A container that runs `rproxy` names its own command
|
||||||
|
and health check.
|
||||||
|
|
||||||
|
### What `rproxy` does with a request
|
||||||
|
|
||||||
|
1. `GET /_smallwebwaf/healthz` is answered `ok` at once, without asking
|
||||||
|
`servewaf`.
|
||||||
|
2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only
|
||||||
|
from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a
|
||||||
|
request" says. It removes any IPv6 zone.
|
||||||
|
3. It makes a fresh request id, whatever id the request came with. `servewaf`
|
||||||
|
and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it.
|
||||||
|
4. It refuses a request with a method it does not pass on, or one it cannot put
|
||||||
|
to `servewaf` as the visitor sent it (see "Requests `rproxy` refuses
|
||||||
|
itself").
|
||||||
|
5. It reads up to the first 64 KiB of the body into memory.
|
||||||
|
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to
|
||||||
|
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to
|
||||||
|
send a visitor's `Upgrade` header, and most `Connection` headers. The
|
||||||
|
question never carries the visitor's `Trailer`.
|
||||||
|
7. It does what the answer says (see "The answer"):
|
||||||
|
- `allow`: it passes the request to the app.
|
||||||
|
- `close`: it closes the visitor's connection without an answer.
|
||||||
|
- Any other answer: the visitor gets its status, headers and body, less
|
||||||
|
`X-Smallwebwaf-Disposition`. That holds for a `deny`, for an error such as
|
||||||
|
`servewaf`'s `400` or a `5xx`, and for an answer without a valid
|
||||||
|
`X-Smallwebwaf-Disposition`.
|
||||||
|
8. Once the answer has ended, it writes its log line. `docs/request-log.md`
|
||||||
|
gives the fields. An app's answer can end just after the line: Go's server
|
||||||
|
sends the closing chunk of a chunked answer, and may send all of an empty
|
||||||
|
one, only once `rproxy` has finished with the request.
|
||||||
|
9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close`
|
||||||
|
(see "The report"). The visitor does not wait for it. A report that fails is
|
||||||
|
dropped, with a process warning. A status above `599` from the app goes to
|
||||||
|
the visitor as it is, and into the report as `599`, the highest `servewaf`
|
||||||
|
takes.
|
||||||
|
|
||||||
|
### Requests `rproxy` refuses itself
|
||||||
|
|
||||||
|
`rproxy` passes on only these methods:
|
||||||
|
|
||||||
|
- `GET`
|
||||||
|
- `HEAD`
|
||||||
|
- `POST`
|
||||||
|
- `OPTIONS`
|
||||||
|
- `PUT`
|
||||||
|
- `DELETE`
|
||||||
|
- `PATCH`
|
||||||
|
- `QUERY`
|
||||||
|
|
||||||
|
It answers any other method `405`, with no question and no report. The answer's
|
||||||
|
`Allow` header names these methods.
|
||||||
|
|
||||||
|
`rproxy` answers `400`, with no question and no report, to a request with:
|
||||||
|
|
||||||
|
- a target that does not start with `/`, such as `x:admin`,
|
||||||
|
`http://site.example/` or `site.example:443`;
|
||||||
|
- a target that starts with `//`;
|
||||||
|
- no `Host`, or an empty one;
|
||||||
|
- a `Host` holding an IPv6 zone: one that starts with `[` and holds a `%`, such
|
||||||
|
as `[fe80::1%25eth0]:80`;
|
||||||
|
- more than one `User-Agent`.
|
||||||
|
|
||||||
|
Go's HTTP client could not send any of these as the visitor sent it.
|
||||||
|
|
||||||
|
Go's HTTP server answers `OPTIONS *` itself, before `rproxy` sees it.
|
||||||
|
|
||||||
|
`rproxy` closes the connection without an answer when it cannot read the first
|
||||||
|
64 KiB of the body. That happens for a malformed chunk, and for a visitor that
|
||||||
|
goes away or stops its sending side. There is no question and no report, and the
|
||||||
|
log line has status `0`.
|
||||||
|
|
||||||
|
### Without an answer from `servewaf`
|
||||||
|
|
||||||
|
`SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when
|
||||||
|
`servewaf`:
|
||||||
|
|
||||||
|
- cannot be connected to;
|
||||||
|
- does not answer at all;
|
||||||
|
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
|
||||||
|
question and reading the whole answer.
|
||||||
|
|
||||||
|
An answer whose body breaks off counts as no answer at all.
|
||||||
|
|
||||||
|
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||||
|
Either way `rproxy` writes a process warning, its log line has
|
||||||
|
`default_disposition`, and it sends no report.
|
||||||
|
|
||||||
|
A visitor can make `servewaf` slow. A request that keeps `servewaf` busy past
|
||||||
|
`SWRP_TIMEOUT_MS`, like any request while `servewaf` is overloaded, gets
|
||||||
|
`SWRP_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead of
|
||||||
|
reaching the app.
|
||||||
|
|
||||||
|
A path under `/_smallwebwaf/` that `allow` would pass on is answered `404`
|
||||||
|
instead, so that the app never sees that prefix.
|
||||||
|
|
||||||
|
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
|
||||||
|
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
|
||||||
|
writes a process warning, its log line has no `disposition`, and it sends no
|
||||||
|
report.
|
||||||
|
|
||||||
|
A visitor that goes away while `servewaf` is being asked is no failure of
|
||||||
|
`servewaf`. `rproxy` closes the connection, with no warning, no default and no
|
||||||
|
report. Its log line has status `0` and no `disposition`.
|
||||||
|
|
||||||
|
### Passing a request to the app
|
||||||
|
|
||||||
|
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
|
||||||
|
the body it holds, then the rest, streaming. The app's answer comes back the
|
||||||
|
same way. 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
|
## Deployment
|
||||||
|
|
||||||
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
|
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
|
||||||
@@ -1171,8 +1608,7 @@ and `script/gitea-requests` checks those notes.
|
|||||||
|
|
||||||
## Planned, not built
|
## Planned, not built
|
||||||
|
|
||||||
These two points are specified as later work, and nothing in the code does them
|
These points are specified as later work, and nothing in the code does them yet:
|
||||||
yet:
|
|
||||||
|
|
||||||
- Planned: sharing bans between the instances of several apps, by running one
|
- 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
|
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
|
// Command smallwebwaf is a web application firewall for one app: it runs
|
||||||
// between traefik and the app, and passes requests through within its
|
// between traefik and the app and passes requests through within its limits,
|
||||||
// limits. Everything it does is in internal/smallwebwaf.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -46,3 +46,14 @@ curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
|||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
https://app.example/_smallwebwaf/clients/203.0.113.9
|
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
|
```json
|
||||||
{
|
{
|
||||||
"instance": "fsn1app1/gitea",
|
"instance": "fsn1app1/gitea",
|
||||||
"time": "2026-10-06T12:00:00.123461Z",
|
"time": "2026-10-06T12:00:50.123461Z",
|
||||||
"event": "ban",
|
"event": "ban",
|
||||||
"client": "203.0.113.9",
|
"client": "203.0.113.9",
|
||||||
"netblock": "203.0.113.9/32",
|
"netblock": "203.0.113.9/32",
|
||||||
"asn": "",
|
"asn": "",
|
||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "requests per minute over the limit of 6000",
|
"reason": "requests per minute over the limit of 500",
|
||||||
"detail": {
|
"detail": {
|
||||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
"ban_expires": "2026-10-06T13:00:50.123Z",
|
||||||
"cause": "limit",
|
"cause": "limit",
|
||||||
"notes": {
|
"notes": {
|
||||||
"asn": "",
|
"asn": "",
|
||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"kind": "requests",
|
"kind": "requests",
|
||||||
"limit": 6000,
|
"limit": 500,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
"count": 6001,
|
"count": 501,
|
||||||
"request": {
|
"request": {
|
||||||
"time": "2026-10-06T12:00:00.123456789Z",
|
"time": "2026-10-06T12:00:50.123456789Z",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"host": "app.example",
|
"host": "app.example",
|
||||||
"path": "/owner/repo/commits/branch/main?page=812",
|
"path": "/owner/repo/commits/branch/main?page=501",
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"user_agent": "scraper/1.0"
|
"user_agent": "scraper/1.0"
|
||||||
},
|
},
|
||||||
"requests": 8210,
|
"requests": 501,
|
||||||
"refused": 0,
|
"refused": 0,
|
||||||
"earlier_bans": {
|
"earlier_bans": {
|
||||||
"limit": 0,
|
"limit": 0,
|
||||||
@@ -78,6 +78,9 @@ on one line:
|
|||||||
|
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
one of the events above.
|
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
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
`source_failure`, `file_error` and `summary`.
|
`source_failure`, `file_error` and `summary`.
|
||||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
- `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
|
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
||||||
raised. They are empty when GeoJS had not answered yet.
|
raised. They are empty when GeoJS had not answered yet.
|
||||||
- `reason`: a short sentence, such as
|
- `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:
|
- `detail`: what is particular to the event:
|
||||||
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
||||||
under `bans.json` in [`state-files.md`](state-files.md));
|
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
|
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one
|
||||||
line for each of these that the alert has:
|
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 file, the source, the error and the mode from `detail`;
|
||||||
- the suppressed repeats, if any.
|
- 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,
|
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
|
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
|
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,
|
120 requests a minute and 172,800 a day, under the request limits. Two such
|
||||||
each busy all day, pass the day limit of 1,000,000 within two days; five stay
|
runners on one address, each busy all day, make 345,600 and pass the day limit
|
||||||
under it. Their address is then banned, their running jobs fail, and each
|
of 300,000. Three busy at once make 21,600 an hour and pass the hour limit of
|
||||||
repeat within a day makes the ban longer. Newer runners ask for work less
|
20,000. Five busy at once make 600 a minute and pass the minute limit of 500.
|
||||||
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
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
|
## What the Core Rule Set lets through
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,14 @@ By `file`:
|
|||||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
||||||
`smallwebwaf_alerts_dropped_total`;
|
`smallwebwaf_alerts_dropped_total`;
|
||||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
- 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.
|
||||||
|
|||||||
+101
-1
@@ -3,6 +3,7 @@
|
|||||||
The fields of the JSON object `smallwebwaf` writes for each request. "Request
|
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
|
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.
|
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
|
## 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
|
||||||
|
|
||||||
Every line has:
|
Every line but `rproxy`'s has:
|
||||||
|
|
||||||
- `type`: `request` (the process's own messages have `process`);
|
- `type`: `request` (the process's own messages have `process`);
|
||||||
- the standard web log fields;
|
- the standard web log fields;
|
||||||
@@ -117,3 +118,102 @@ All in milliseconds:
|
|||||||
connection;
|
connection;
|
||||||
- `duration_upstream_first_byte`: until the first byte of the answer;
|
- `duration_upstream_first_byte`: until the first byte of the answer;
|
||||||
- `duration_upstream_total`: until the end 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;
|
||||||
|
- the visitor went away while `servewaf` was being asked;
|
||||||
|
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
||||||
|
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
||||||
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"}
|
||||||
|
```
|
||||||
|
|||||||
+74
-4
@@ -99,9 +99,21 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
|||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
|
||||||
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
|
requests a client may make in a minute. A count or `off`. The default catches,
|
||||||
most requests a client may make in each window. A count or `off`.
|
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
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
rate limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
@@ -327,4 +339,62 @@ A few limits are fixed rather than settings:
|
|||||||
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
|
- 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 1,000 DNS blocklist queries are under way at once;
|
||||||
- at most 20,000 anomaly counters are kept;
|
- 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, and closes a
|
||||||
|
kept-open connection idle for 120 seconds;
|
||||||
|
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
||||||
|
`502` to a longer one.
|
||||||
|
|
||||||
|
## `servewaf`
|
||||||
|
|
||||||
|
`smallwebwaf servewaf` uses every `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 when
|
||||||
|
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
||||||
|
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||||
|
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
||||||
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
|
`off`.
|
||||||
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
|
`off`.
|
||||||
|
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
|
||||||
|
send its request line and headers. It then also bounds, from the end of the
|
||||||
|
headers, the time until the app has been sent the whole request. A time or
|
||||||
|
`off`.
|
||||||
|
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||||
|
from the end of the request to its last byte reaching the visitor. A time or
|
||||||
|
`off`.
|
||||||
|
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||||
|
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||||
|
`https` URL with a host and an optional port, and nothing more.
|
||||||
|
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
|
||||||
|
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
|
||||||
|
nothing.
|
||||||
|
|||||||
@@ -150,13 +150,16 @@ type Params struct {
|
|||||||
|
|
||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// 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
|
// 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
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
Client netip.Addr `json:"client"`
|
Client netip.Addr `json:"client"`
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
ASN string `json:"asn"`
|
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
|
// 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,
|
// line for each of its host, client, netblock and country, the file,
|
||||||
// error and mode its detail gives, and its suppressed repeats, that it
|
// source, error and mode its detail gives, and its suppressed repeats, that
|
||||||
// has.
|
// it has.
|
||||||
func text(alert *Alert) string {
|
func text(alert *Alert) string {
|
||||||
lines := []string{alert.Reason}
|
lines := []string{alert.Reason}
|
||||||
|
|
||||||
|
if alert.Host != "" {
|
||||||
|
lines = append(lines, "host: "+alert.Host)
|
||||||
|
}
|
||||||
|
|
||||||
if alert.Client.IsValid() {
|
if alert.Client.IsValid() {
|
||||||
lines = append(lines, "client: "+alert.Client.String())
|
lines = append(lines, "client: "+alert.Client.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1284,9 +1284,11 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
Reason: "refused by the Core Rule Set",
|
Reason: "refused by the Core Rule Set",
|
||||||
|
Host: "app.example",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: waf_block", "default shield",
|
"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{
|
alerts.Alert{
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ type Counter struct {
|
|||||||
|
|
||||||
// Request is a request that has ended, as the counters count it.
|
// Request is a request that has ended, as the counters count it.
|
||||||
type Request struct {
|
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
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
// 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{
|
return alerts.Alert{
|
||||||
Event: alerts.EventAnomaly,
|
Event: alerts.EventAnomaly,
|
||||||
|
Host: r.Host,
|
||||||
Client: r.Client,
|
Client: r.Client,
|
||||||
Netblock: k.netblock,
|
Netblock: k.netblock,
|
||||||
ASN: r.ASN,
|
ASN: r.ASN,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Package config reads smallwebwaf's settings. Every setting is an
|
// Package config reads smallwebwaf's settings, and is the one place they are
|
||||||
// environment variable whose name starts with SWWAF_, or a file such a
|
// read. Every setting is an environment variable. The settings of
|
||||||
// variable names, every setting has a default, and this package is the
|
// `smallwebwaf` and `smallwebwaf servewaf` start with SWWAF_, every one of them
|
||||||
// one place they are read.
|
// 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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -506,9 +508,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
||||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
||||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "6000"),
|
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
|
||||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
|
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
|
||||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
|
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
|
||||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
|
||||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
|
||||||
|
|||||||
@@ -262,9 +262,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
UpstreamResponseTimeout: 30 * time.Minute,
|
UpstreamResponseTimeout: 30 * time.Minute,
|
||||||
RequestMaxBytes: 100 << 20,
|
RequestMaxBytes: 100 << 20,
|
||||||
ResponseMaxBytes: 1 << 30,
|
ResponseMaxBytes: 1 << 30,
|
||||||
RateLimitPerMinute: 6000,
|
RateLimitPerMinute: 500,
|
||||||
RateLimitPerHour: 100000,
|
RateLimitPerHour: 20000,
|
||||||
RateLimitPerDay: 1000000,
|
RateLimitPerDay: 300000,
|
||||||
BanResponse: 403,
|
BanResponse: 403,
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: 24 * time.Hour,
|
LimitBanRepeatWindow: 24 * time.Hour,
|
||||||
@@ -2375,9 +2375,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
allowNets: "",
|
allowNets: "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
denyNets: "",
|
denyNets: "",
|
||||||
rateLimitPerMinute: "6000",
|
rateLimitPerMinute: "500",
|
||||||
rateLimitPerHour: "100000",
|
rateLimitPerHour: "20000",
|
||||||
rateLimitPerDay: "1000000",
|
rateLimitPerDay: "300000",
|
||||||
rateLimitExemptPaths: "",
|
rateLimitExemptPaths: "",
|
||||||
bytesLimitPerMinute: "4G",
|
bytesLimitPerMinute: "4G",
|
||||||
bytesLimitPerHour: "20G",
|
bytesLimitPerHour: "20G",
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
||||||
|
// is off.
|
||||||
|
type RProxy struct {
|
||||||
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
|
ListenAddr string
|
||||||
|
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
|
||||||
|
UpstreamURL *url.URL
|
||||||
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||||
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
|
TrustedProxies []netip.Prefix
|
||||||
|
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
||||||
|
WAFBackend *url.URL
|
||||||
|
// Timeout is how long rproxy waits for servewaf's answer to a question,
|
||||||
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
|
Timeout time.Duration
|
||||||
|
// DefaultDisposition is what is done with a request when servewaf cannot be
|
||||||
|
// connected to, does not answer at all, or does not answer within Timeout:
|
||||||
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
|
DefaultDisposition string
|
||||||
|
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
|
||||||
|
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
|
||||||
|
MaxRequestSize int64
|
||||||
|
MaxResponseSize int64
|
||||||
|
// MaxRequestTime bounds sending the whole request: the visitor's request
|
||||||
|
// line and headers, and then, from their end, its body on its way to the
|
||||||
|
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
||||||
|
// the end of the request to its last byte reaching the visitor
|
||||||
|
// (SWRP_MAX_RESPONSE_TIME_MS).
|
||||||
|
MaxRequestTime time.Duration
|
||||||
|
MaxResponseTime time.Duration
|
||||||
|
|
||||||
|
// settings are the values read, as given or by default, for the log line
|
||||||
|
// at start.
|
||||||
|
settings []slog.Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNeedsWAFBackend = errors.New(
|
||||||
|
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
||||||
|
errNotDisposition = errors.New("is not allow or deny")
|
||||||
|
errNotMilliseconds = errors.New(
|
||||||
|
"is not a whole number of milliseconds above zero, such as 2000")
|
||||||
|
errNotMillisecondsOrOff = errors.New(
|
||||||
|
"is not a whole number of milliseconds above zero, such as 60000, or off")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
|
||||||
|
// os.LookupEnv. A setting that is not set takes its default, and
|
||||||
|
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
|
||||||
|
// invalid is an error that names it. Since rproxy never touches disk, its
|
||||||
|
// settings have no _FILE form.
|
||||||
|
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
|
||||||
|
env := &environment{lookupEnv: func(name string) (string, bool) {
|
||||||
|
if strings.HasSuffix(name, "_FILE") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookupEnv(name)
|
||||||
|
}}
|
||||||
|
cfg := &RProxy{
|
||||||
|
ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr),
|
||||||
|
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
|
||||||
|
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
|
||||||
|
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
|
||||||
|
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
|
||||||
|
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
|
||||||
|
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
|
||||||
|
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
|
||||||
|
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
|
||||||
|
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if env.err != nil {
|
||||||
|
return nil, env.err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.settings = env.settings
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogValue makes an RProxy log as each setting's name with the value it was
|
||||||
|
// given, or its default.
|
||||||
|
func (c *RProxy) LogValue() slog.Value {
|
||||||
|
return slog.GroupValue(c.settings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wafBackend reads the setting that is servewaf's URL, which must be set.
|
||||||
|
func (e *environment) wafBackend(name string) *url.URL {
|
||||||
|
value := e.value(name, "")
|
||||||
|
if value == "" {
|
||||||
|
e.check(name, errNeedsWAFBackend)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backend, err := parseUpstreamURL(value)
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// disposition reads the setting that is what is done with a request when servewaf
|
||||||
|
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||||
|
// allow or deny.
|
||||||
|
func (e *environment) disposition(name, defaultValue string) string {
|
||||||
|
value := e.value(name, defaultValue)
|
||||||
|
if value != "allow" && value != "deny" {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotDisposition))
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// milliseconds reads a setting that is a whole number of milliseconds above
|
||||||
|
// zero.
|
||||||
|
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
|
||||||
|
value := e.value(name, defaultValue)
|
||||||
|
|
||||||
|
duration, valid := parseMilliseconds(value)
|
||||||
|
if !valid {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// millisecondsOrOff reads a setting that is a whole number of milliseconds
|
||||||
|
// above zero, or off, which is 0.
|
||||||
|
func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration {
|
||||||
|
value := e.value(name, defaultValue)
|
||||||
|
if value == off {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, valid := parseMilliseconds(value)
|
||||||
|
if !valid {
|
||||||
|
e.check(name, fmt.Errorf("%q %w", value, errNotMillisecondsOrOff))
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMilliseconds reads a whole number of milliseconds above zero, and
|
||||||
|
// reports whether it is one.
|
||||||
|
func parseMilliseconds(value string) (time.Duration, bool) {
|
||||||
|
n, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(n) * time.Millisecond, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rproxy's settings, by name, and their values in these tests.
|
||||||
|
const (
|
||||||
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
|
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
|
||||||
|
rproxyListen = "SWRP_LISTEN_ADDR"
|
||||||
|
rproxyUpstream = "SWRP_UPSTREAM_URL"
|
||||||
|
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
|
||||||
|
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
|
||||||
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
|
backendURL = "http://servewaf:8080"
|
||||||
|
rproxyDefaultAddr = ":8080"
|
||||||
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
|
notASize = "a lot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rproxyConfig reads rproxy's settings from env.
|
||||||
|
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
|
||||||
|
return config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ListenAddr != rproxyDefaultAddr ||
|
||||||
|
cfg.UpstreamURL.String() != rproxyDefaultApp ||
|
||||||
|
cfg.WAFBackend.String() != backendURL {
|
||||||
|
t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
|
||||||
|
t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 {
|
||||||
|
t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute {
|
||||||
|
t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("172.16.0.0/12"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
}
|
||||||
|
if !slices.Equal(cfg.TrustedProxies, want) {
|
||||||
|
t.Errorf("trusted proxies %v, want %v", cfg.TrustedProxies, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxySettingsAreRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := rproxyConfig(map[string]string{
|
||||||
|
wafBackend: "https://waf.example:8443",
|
||||||
|
rproxyListen: ":9000",
|
||||||
|
rproxyUpstream: "http://app:3000",
|
||||||
|
rproxyTrusted: "192.0.2.0/24",
|
||||||
|
rproxyTimeout: "150",
|
||||||
|
rproxyDefault: "deny",
|
||||||
|
rproxyMaxRequest: "1K",
|
||||||
|
rproxyMaxResponse: off,
|
||||||
|
rproxyRequestTime: off,
|
||||||
|
rproxyAnswerTime: "250",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ListenAddr != ":9000" || cfg.UpstreamURL.Host != "app:3000" ||
|
||||||
|
cfg.WAFBackend.Host != "waf.example:8443" ||
|
||||||
|
cfg.TrustedProxies[0] != netip.MustParsePrefix("192.0.2.0/24") {
|
||||||
|
t.Errorf("addresses %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" ||
|
||||||
|
cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 ||
|
||||||
|
cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond {
|
||||||
|
t.Errorf("limits %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, value string
|
||||||
|
}{
|
||||||
|
{wafBackend, ""},
|
||||||
|
{wafBackend, "servewaf:8080"},
|
||||||
|
{wafBackend, "http://servewaf:8080/path"},
|
||||||
|
{rproxyTimeout, "0"},
|
||||||
|
{rproxyTimeout, "-1"},
|
||||||
|
{rproxyTimeout, off},
|
||||||
|
{rproxyTimeout, "2s"},
|
||||||
|
{rproxyTimeout, "1.25"},
|
||||||
|
{rproxyTimeout, "99999999999999999"},
|
||||||
|
{rproxyDefault, "maybe"},
|
||||||
|
{rproxyListen, "8080"},
|
||||||
|
{rproxyUpstream, "app"},
|
||||||
|
{rproxyTrusted, "not a netblock"},
|
||||||
|
{rproxyMaxRequest, notASize},
|
||||||
|
{rproxyMaxResponse, notASize},
|
||||||
|
{rproxyRequestTime, "0"},
|
||||||
|
{rproxyRequestTime, "1m"},
|
||||||
|
{rproxyAnswerTime, "0"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value})
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
||||||
|
t.Errorf("error %v, want one naming %s", err, tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyNeedsItsBackend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := rproxyConfig(nil)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||||
|
t.Errorf("error %v, want one saying %s is required", err, wafBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyReadsNoFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "backend")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||||
|
t.Errorf("error %v, want %s still required", err, wafBackend)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := rproxyConfig(map[string]string{
|
||||||
|
wafBackend: backendURL,
|
||||||
|
rproxyTimeout + "_FILE": "/nonexistent/timeout",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v, with the file left unread", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout != 2*time.Second {
|
||||||
|
t.Errorf("timeout %v, want the default", cfg.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxySettingsAreLoggedByName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
group := cfg.LogValue().Group()
|
||||||
|
|
||||||
|
names := make([]string, 0, len(group))
|
||||||
|
for _, attr := range group {
|
||||||
|
names = append(names, attr.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
|
||||||
|
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
||||||
|
} {
|
||||||
|
if !slices.Contains(names, name) {
|
||||||
|
t.Errorf("%s is not logged, only %v", name, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-20
@@ -403,28 +403,21 @@ func (m *Metrics) RequestEnded(
|
|||||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
||||||
) {
|
) {
|
||||||
m.inFlight.Dec()
|
m.inFlight.Dec()
|
||||||
|
m.count(line, limit, duration, upstreamDuration)
|
||||||
|
}
|
||||||
|
|
||||||
class := statusClass(line.Status)
|
// RequestAllowed counts a question of rproxy's, answered allow, as no longer
|
||||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
// under way. RequestReported counts the request it was about.
|
||||||
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
func (m *Metrics) RequestAllowed() {
|
||||||
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
m.inFlight.Dec()
|
||||||
m.requestDuration.Observe(duration.Seconds())
|
}
|
||||||
|
|
||||||
if upstreamDuration > 0 {
|
// RequestReported counts a request rproxy passed to the app, as RequestEnded
|
||||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
// does, from what its report says, which line holds.
|
||||||
}
|
func (m *Metrics) RequestReported(
|
||||||
|
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||||
if limit != "" {
|
) {
|
||||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
m.count(line, "", duration, upstreamDuration)
|
||||||
}
|
|
||||||
|
|
||||||
if line.Country != "" {
|
|
||||||
m.countries.add(line.Country, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if line.ASN != "" {
|
|
||||||
m.asns.add(line.ASN, line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
// 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()
|
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
|
// addReputationQueries adds the counter of the queries to source, a DNSBL
|
||||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
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")
|
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
|
// answerAdmin answers a request for smallwebwaf itself, under
|
||||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||||
// token, sent as Authorization: Bearer <token>: the metrics
|
// 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
|
// anomaly counters, nor a clock, and the request no response: reading
|
||||||
// any of them to count the request panics.
|
// any of them to count the request panics.
|
||||||
rq := &request{
|
rq := &request{
|
||||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
decision: decision{
|
||||||
client: netip.MustParseAddr("203.0.113.9"),
|
client: netip.MustParseAddr("203.0.113.9"),
|
||||||
lookedUp: true,
|
lookedUp: true,
|
||||||
|
},
|
||||||
|
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ func (rq *request) alertBan(ban bans.Ban) {
|
|||||||
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: event,
|
Event: event,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: ban.Netblock,
|
Netblock: ban.Netblock,
|
||||||
ASN: ban.Notes.ASN,
|
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 {
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||||
return bans.Request{
|
return bans.Request{
|
||||||
Time: now,
|
Time: now,
|
||||||
Method: rq.in.Method,
|
Method: rq.line.Method,
|
||||||
Host: rq.in.Host,
|
Host: rq.line.Host,
|
||||||
Path: rq.in.URL.RequestURI(),
|
Path: rq.requestURI,
|
||||||
Status: status,
|
Status: status,
|
||||||
UserAgent: rq.in.UserAgent(),
|
UserAgent: rq.line.UserAgent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
|
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||||
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||||
@@ -237,7 +239,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
{"403", http.StatusForbidden},
|
{"403", http.StatusForbidden},
|
||||||
{"429", http.StatusTooManyRequests},
|
{"429", http.StatusTooManyRequests},
|
||||||
{"close", 0},
|
{banResponseClose, 0},
|
||||||
} {
|
} {
|
||||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
|||||||
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventWAFBlock,
|
Event: alerts.EventWAFBlock,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptNets: scraper,
|
rateLimitExemptNets: scraper,
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
banResponse: "close",
|
banResponse: banResponseClose,
|
||||||
limitBanDuration: "1h",
|
limitBanDuration: "1h",
|
||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|||||||
+52
-33
@@ -1,6 +1,8 @@
|
|||||||
// Package proxy passes each request to the app and the app's answer back,
|
// Package proxy runs the checks on each request and writes its request log
|
||||||
// unchanged, within the size and time limits, and writes one request log
|
// line. New passes each request it lets through to the app and the app's
|
||||||
// line for each request.
|
// 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
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
@@ -19,7 +23,6 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
@@ -53,7 +56,8 @@ const ClientsPath = "/_smallwebwaf/clients/"
|
|||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Config *config.Config
|
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
|
RequestLog io.Writer
|
||||||
// ProcessLog receives the process's own messages.
|
// ProcessLog receives the process's own messages.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
@@ -109,6 +113,23 @@ type Server struct {
|
|||||||
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
||||||
// applies the timeouts and size limits from then on.
|
// applies the timeouts and size limits from then on.
|
||||||
func New(params Params) *Server {
|
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)
|
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||||
@@ -171,29 +192,7 @@ func New(params Params) *Server {
|
|||||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||||
m.AddRules(params.Rules)
|
m.AddRules(params.Rules)
|
||||||
|
|
||||||
return &Server{
|
return h
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||||
@@ -274,6 +273,10 @@ type handler struct {
|
|||||||
rules *rules.Files
|
rules *rules.Files
|
||||||
coreRuleSet *waf.CoreRuleSet
|
coreRuleSet *waf.CoreRuleSet
|
||||||
alerts *alerts.Queue
|
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
|
// 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
|
// The health endpoint is answered at once, before any check, so that
|
||||||
// a health checker is never refused. It does not ask the app.
|
// a health checker is never refused. It does not ask the app.
|
||||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.answerHealth()
|
||||||
// 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")
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -339,3 +338,23 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rq.forward(r.Context())
|
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) {
|
) (*proxy.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
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{
|
settings := map[string]string{
|
||||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
wafMode: off,
|
wafMode: off,
|
||||||
@@ -334,7 +344,7 @@ func newProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
server := build(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
|||||||
rq.h.metrics.ReputationHit(hit.Source)
|
rq.h.metrics.ReputationHit(hit.Source)
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventReputationHit,
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
|
|||||||
+97
-55
@@ -39,20 +39,20 @@ type refusal struct {
|
|||||||
limit string
|
limit string
|
||||||
}
|
}
|
||||||
|
|
||||||
// request is one request on its way through smallwebwaf, from the moment
|
// decision is what the checks note of a request: its log line, and what
|
||||||
// its headers have been read to its log line.
|
// counting the request needs once it has ended. servewaf keeps a copy of it
|
||||||
type request struct {
|
// until the request's report comes. It holds no credentials, since the log line
|
||||||
h *handler
|
// never does, and neither the request's objects nor their bodies.
|
||||||
in *http.Request
|
type decision struct {
|
||||||
// rc sets the deadlines of the connection to the client.
|
line requestlog.Line
|
||||||
rc *http.ResponseController
|
client netip.Addr
|
||||||
out *responseWriter
|
// requestURI is the path with the query, as a ban's notes keep it.
|
||||||
body *requestBody // nil for a request without a body
|
requestURI string
|
||||||
line requestlog.Line
|
// site is, in servewaf, the host the visitor asked for, which the
|
||||||
|
// alerts name, since one servewaf serves many sites. It is "" otherwise.
|
||||||
client netip.Addr
|
site string
|
||||||
peer netip.Addr
|
// allowed is true for a question answered with DispositionAllow.
|
||||||
peerTrusted bool
|
allowed bool
|
||||||
// lookedUp is true once the client's AS number and country have been
|
// 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
|
// 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.
|
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
||||||
@@ -65,18 +65,37 @@ type request struct {
|
|||||||
counted bool
|
counted bool
|
||||||
limitPercent, bytesPercent percentage
|
limitPercent, bytesPercent percentage
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
// 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
|
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
|
||||||
// one the Core Rule Set refused, and tokenRefused for one refused for a
|
// one the Core Rule Set refused, each an offence its client's history
|
||||||
// missing or wrong token, each an offence its client's history counts.
|
// counts.
|
||||||
attack, ruleBlocked, wafBlocked, tokenRefused bool
|
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,
|
// blocklisted is true once a blocklist is found to list the client,
|
||||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||||
// AbuseIPDB's score of it is a hit.
|
// AbuseIPDB's score of it is a hit.
|
||||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||||
// reputation is the reputation sources that list the client, for the
|
start time.Time
|
||||||
// notes of a ban the request makes.
|
|
||||||
reputation []bans.ReputationHit
|
|
||||||
start time.Time
|
|
||||||
// checked is when the checks were done, and upstreamStart when the
|
// checked is when the checks were done, and upstreamStart when the
|
||||||
// request was handed to the app.
|
// request was handed to the app.
|
||||||
checked time.Time
|
checked time.Time
|
||||||
@@ -108,50 +127,62 @@ type request struct {
|
|||||||
answerStarted time.Time
|
answerStarted time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequest starts handling r: it notes the time, counts the request as
|
// newRequest starts handling r: it works out the client from the TCP peer
|
||||||
// under way, works out the client, and starts the log line with what is
|
// and X-Forwarded-For, and goes on as startRequest does.
|
||||||
// known of the request.
|
|
||||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
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()
|
h.metrics.RequestStarted()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
peer := peerAddress(r)
|
|
||||||
trusted := h.config.TrustedProxies
|
|
||||||
peerTrusted := isInside(peer, trusted)
|
|
||||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||||
client := clientAddress(peer, forwardedFor, trusted)
|
|
||||||
|
|
||||||
rq := &request{
|
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,
|
h: h,
|
||||||
in: r,
|
in: r,
|
||||||
rc: http.NewResponseController(w),
|
rc: http.NewResponseController(w),
|
||||||
out: &responseWriter{ResponseWriter: w},
|
out: &responseWriter{ResponseWriter: w},
|
||||||
client: client,
|
|
||||||
peer: peer,
|
peer: peer,
|
||||||
peerTrusted: peerTrusted,
|
peerTrusted: peerTrusted,
|
||||||
start: start,
|
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.
|
// 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
|
// Counted before the log line is written, so that the metrics count
|
||||||
// every request whose line is out.
|
// every request whose line is out. An allowed request is the exception:
|
||||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
// 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 {
|
if err != nil {
|
||||||
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
||||||
}
|
}
|
||||||
@@ -634,6 +675,7 @@ func (rq *request) countAnomalies() {
|
|||||||
answer, _ := rq.answerAtTheEnd()
|
answer, _ := rq.answerAtTheEnd()
|
||||||
|
|
||||||
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
ClientGroup: rq.h.clientGroup(rq.client),
|
ClientGroup: rq.h.clientGroup(rq.client),
|
||||||
ASN: answer.ASN,
|
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
|
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
||||||
// JSON object per request, marked "type":"request", and the process's own
|
// JSON object per request, two from servewaf and a short one from rproxy, marked
|
||||||
// messages as JSON lines marked "type":"process".
|
// "type":"request", and the process's own messages as JSON lines marked
|
||||||
|
// "type":"process".
|
||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -60,6 +61,16 @@ const OffenceLimit = "limit"
|
|||||||
// that broke the error burst.
|
// that broke the error burst.
|
||||||
const LimitHitErrorBurst = "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.
|
// timeLayout is RFC 3339 with milliseconds.
|
||||||
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
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
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||||
type Line struct {
|
type Line struct {
|
||||||
Type string `json:"type"`
|
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
|
// The standard web log fields. Scheme is how the client reached
|
||||||
// smallwebwaf, or the trusted proxy in front of it.
|
// smallwebwaf, or the trusted proxy in front of it.
|
||||||
@@ -180,10 +194,51 @@ type Line struct {
|
|||||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RProxyLine is rproxy's short line in the request log about one request. The
|
||||||
|
// field names, and their order, are those of docs/request-log.md. A field that
|
||||||
|
// does not apply to a request is left out of its line.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||||
|
type RProxyLine struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
RequestBytes int64 `json:"request_bytes"`
|
||||||
|
ResponseBytes int64 `json:"response_bytes"`
|
||||||
|
// DurationRequest runs in milliseconds from the request's headers being read
|
||||||
|
// until the app has been sent the whole request, or the answer has started
|
||||||
|
// if that is sooner. DurationResponse runs from then until the answer ends.
|
||||||
|
DurationRequest float64 `json:"duration_request"`
|
||||||
|
DurationResponse float64 `json:"duration_response"`
|
||||||
|
// Disposition is the valid disposition servewaf answered or, while
|
||||||
|
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||||
|
// when neither gave one.
|
||||||
|
Disposition string `json:"disposition,omitempty"`
|
||||||
|
DefaultDisposition bool `json:"default_disposition,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Write writes line to w as one JSON line marked "type":"request".
|
// Write writes line to w as one JSON line marked "type":"request".
|
||||||
func Write(w io.Writer, line *Line) error {
|
func Write(w io.Writer, line *Line) error {
|
||||||
line.Type = "request"
|
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)
|
encoded, err := json.Marshal(line)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode the request log line: %w", err)
|
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) {
|
func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errResponseTooLarge ends an app's response body that is longer than
|
||||||
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
|
var errResponseTooLarge = errors.New(
|
||||||
|
"the response body is over SWRP_MAX_RESPONSE_SIZE")
|
||||||
|
|
||||||
|
// requestBody is the visitor's request body. rproxy first reads up to the first
|
||||||
|
// 64 KiB of it into held, and then, on its way to the app, the transport reads
|
||||||
|
// it on a goroutine of its own.
|
||||||
|
type requestBody struct {
|
||||||
|
// body is the visitor's body, ending in an *http.MaxBytesError past
|
||||||
|
// SWRP_MAX_REQUEST_SIZE.
|
||||||
|
body io.ReadCloser
|
||||||
|
rq *request
|
||||||
|
// held is the part read before the request goes to the app, which Read gives
|
||||||
|
// first.
|
||||||
|
held []byte
|
||||||
|
// waiting is true while a Read waits for the visitor to send more.
|
||||||
|
waiting atomic.Bool
|
||||||
|
// received is true once the visitor has sent the whole body.
|
||||||
|
received atomic.Bool
|
||||||
|
// bytes is how much of the body has been read.
|
||||||
|
bytes atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the visitor's body, after the part held, which has been
|
||||||
|
// counted already.
|
||||||
|
func (b *requestBody) Read(p []byte) (int, error) {
|
||||||
|
if len(b.held) > 0 {
|
||||||
|
n := copy(p, b.held)
|
||||||
|
b.held = b.held[n:]
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b.waiting.Store(true)
|
||||||
|
n, err := b.body.Read(p)
|
||||||
|
b.waiting.Store(false)
|
||||||
|
b.bytes.Add(int64(n))
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
b.received.Store(true)
|
||||||
|
case errors.As(err, &tooLarge):
|
||||||
|
b.rq.refuse(http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the visitor's body.
|
||||||
|
func (b *requestBody) Close() error {
|
||||||
|
return b.body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseBody is the app's response body on its way to the visitor.
|
||||||
|
type responseBody struct {
|
||||||
|
// body is the app's body, ending in an *http.MaxBytesError past
|
||||||
|
// SWRP_MAX_RESPONSE_SIZE.
|
||||||
|
body io.ReadCloser
|
||||||
|
rq *request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the app's body.
|
||||||
|
func (b *responseBody) Read(p []byte) (int, error) {
|
||||||
|
n, err := b.body.Read(p)
|
||||||
|
if err == nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
b.rq.responseReceived()
|
||||||
|
case errors.As(err, &tooLarge):
|
||||||
|
b.rq.refuse(http.StatusBadGateway)
|
||||||
|
|
||||||
|
return n, errResponseTooLarge
|
||||||
|
case b.rq.in.Context().Err() == nil:
|
||||||
|
// The answer broke off, not because the visitor went away. If a time
|
||||||
|
// limit cut it, that refusal came first and is the one kept.
|
||||||
|
b.rq.refuse(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the app's body.
|
||||||
|
func (b *responseBody) Close() error {
|
||||||
|
return b.body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// upgradedConn is the connection to the app once the app has switched
|
||||||
|
// protocols, as for a WebSocket. ReverseProxy writes to it what the visitor
|
||||||
|
// sends and reads from it what the app sends, on goroutines of its own, until
|
||||||
|
// the connection closes; it counts the bytes each way, for the report.
|
||||||
|
type upgradedConn struct {
|
||||||
|
io.ReadWriteCloser
|
||||||
|
|
||||||
|
// fromApp is how many bytes the app has sent, and toApp how many the
|
||||||
|
// visitor has.
|
||||||
|
fromApp atomic.Int64
|
||||||
|
toApp atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads what the app sends.
|
||||||
|
func (c *upgradedConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Read(p)
|
||||||
|
c.fromApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends the app what the visitor sent.
|
||||||
|
func (c *upgradedConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadWriteCloser.Write(p)
|
||||||
|
c.toApp.Add(int64(n))
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite tells the app that the visitor sends no more, while what the app
|
||||||
|
// sends still passes. ReverseProxy calls it once the visitor has stopped
|
||||||
|
// sending, and closes the connection there if it is not supported.
|
||||||
|
func (c *upgradedConn) CloseWrite() error {
|
||||||
|
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return http.ErrNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.CloseWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitBody returns body, cut off with an *http.MaxBytesError after maxBytes, or
|
||||||
|
// unchanged if maxBytes is zero, which is off.
|
||||||
|
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
|
||||||
|
if maxBytes == 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a ResponseWriter, MaxBytesReader only counts and cuts off.
|
||||||
|
return http.MaxBytesReader(nil, body, maxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseWriter is the response to the visitor. It notes the status and size
|
||||||
|
// for the log line and the report.
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
|
||||||
|
// status is the final status sent, or zero before one is.
|
||||||
|
status int
|
||||||
|
bytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader sends the status and headers. An informational 1xx status is
|
||||||
|
// passed on and the final status still comes later.
|
||||||
|
func (w *responseWriter) WriteHeader(status int) {
|
||||||
|
if status >= http.StatusOK && w.status == 0 {
|
||||||
|
w.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends part of the body.
|
||||||
|
func (w *responseWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.status == 0 {
|
||||||
|
w.status = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given
|
||||||
|
w.bytes += int64(n)
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushError sends what has been written so far. http.ResponseController calls
|
||||||
|
// it, as ReverseProxy does after each write.
|
||||||
|
func (w *responseWriter) FlushError() error {
|
||||||
|
return http.NewResponseController(w.ResponseWriter).Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hijack takes over the connection to the visitor, which ReverseProxy does only
|
||||||
|
// to pass on the app's 101 Switching Protocols: that is then the status sent.
|
||||||
|
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
|
||||||
|
if err == nil {
|
||||||
|
w.status = http.StatusSwitchingProtocols
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, buffered, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap lets http.ResponseController reach net/http's own ResponseWriter for
|
||||||
|
// what responseWriter does not do itself.
|
||||||
|
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
||||||
|
return w.ResponseWriter
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The headers rproxy sets on the requests it passes to the app. Go writes every
|
||||||
|
// header name in this form, as it sends it and as it receives it, so
|
||||||
|
// X-Client-ASN arrives as X-Client-Asn, and Del removes a visitor's own whatever
|
||||||
|
// their case; header names are not case-sensitive.
|
||||||
|
const (
|
||||||
|
requestIDHeader = "X-Request-ID"
|
||||||
|
asnHeader = "X-Client-Asn"
|
||||||
|
countryHeader = "X-Client-Country"
|
||||||
|
)
|
||||||
|
|
||||||
|
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||||
|
// any IPv6 zone, which servewaf refuses.
|
||||||
|
func peerAddress(r *http.Request) netip.Addr {
|
||||||
|
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addrPort.Addr().Unmap().WithZone("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientAddress works out who the visitor is. A peer outside the trusted
|
||||||
|
// proxies is the visitor, and what it says in X-Forwarded-For is ignored. For a
|
||||||
|
// peer inside them, X-Forwarded-For is read from the right, and the first
|
||||||
|
// address outside them is the visitor; if every address in it is inside, the
|
||||||
|
// leftmost is, and with no header, the peer. An entry that is not an address
|
||||||
|
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||||
|
// address never has an IPv6 zone, which servewaf refuses.
|
||||||
|
func clientAddress(
|
||||||
|
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||||
|
) netip.Addr {
|
||||||
|
client := peer
|
||||||
|
if !isInside(peer, trusted) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := strings.Split(strings.Join(forwardedFor, ","), ",")
|
||||||
|
for _, entry := range slices.Backward(entries) {
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
client = addr.Unmap().WithZone("")
|
||||||
|
if !isInside(client, trusted) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheme is how the visitor reached traefik, as a trusted proxy says in
|
||||||
|
// X-Forwarded-Proto, or otherwise http, the only scheme rproxy serves.
|
||||||
|
func scheme(r *http.Request, peerTrusted bool) string {
|
||||||
|
proto := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if !peerTrusted || proto == "" {
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
return proto
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInside reports whether addr is in one of the netblocks.
|
||||||
|
func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
|
||||||
|
return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool {
|
||||||
|
return netblock.Contains(addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setForwardedHeaders sets the headers in which the app learns about the
|
||||||
|
// visitor, so that it sees what it would see from traefik directly. A trusted
|
||||||
|
// proxy's forwarded headers pass on, with the proxy's own address added to
|
||||||
|
// X-Forwarded-For. Those of any other peer are its own claims and are replaced:
|
||||||
|
// X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and
|
||||||
|
// X-Forwarded-Proto plain http, which is how it reached rproxy.
|
||||||
|
func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
|
||||||
|
forwardedFor := peer.String()
|
||||||
|
|
||||||
|
if trusted {
|
||||||
|
// ReverseProxy removes these from out before Rewrite.
|
||||||
|
for _, name := range []string{"Forwarded", "X-Forwarded-Host", "X-Forwarded-Proto"} {
|
||||||
|
values, ok := in.Header[name]
|
||||||
|
if ok {
|
||||||
|
out.Header[name] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prior := in.Header.Values("X-Forwarded-For")
|
||||||
|
if len(prior) > 0 {
|
||||||
|
forwardedFor = strings.Join(prior, ", ") + ", " + forwardedFor
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Header.Set("X-Forwarded-For", forwardedFor)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseProxy has removed Forwarded and the three set below; these are the
|
||||||
|
// other headers in which traefik tells the app about the visitor and its
|
||||||
|
// request.
|
||||||
|
for _, name := range []string{
|
||||||
|
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
|
||||||
|
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
|
||||||
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||||
|
} {
|
||||||
|
out.Header.Del(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Header.Set("X-Forwarded-For", forwardedFor)
|
||||||
|
out.Header.Set("X-Forwarded-Host", in.Host)
|
||||||
|
out.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
}
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
package rproxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// post is the start of a POST whose headers end with what follows it.
|
||||||
|
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
|
||||||
|
|
||||||
|
// announcedLength is the header that announces a body of size bytes, and the
|
||||||
|
// end of the headers.
|
||||||
|
func announcedLength(size int) string {
|
||||||
|
return "Content-Length: " + strconv.Itoa(size) + "\r\n\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerHasTheFixedLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||||
|
if name == "SWRP_WAF_BACKEND" {
|
||||||
|
return nowhere, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := rproxy.New(rproxy.Params{
|
||||||
|
Config: cfg,
|
||||||
|
RequestLog: io.Discard,
|
||||||
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo),
|
||||||
|
})
|
||||||
|
|
||||||
|
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
||||||
|
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
|
||||||
|
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
|
||||||
|
"header timeout %s", server.Addr, server.MaxHeaderBytes,
|
||||||
|
server.IdleTimeout, server.ReadHeaderTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, limit string
|
||||||
|
size, asked int
|
||||||
|
}{
|
||||||
|
{"with the limit within the first part", "1K", 1025, 1024},
|
||||||
|
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("the app was asked")
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestSize: tc.limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
|
bytes.NewReader(make([]byte, tc.size))))
|
||||||
|
if got.status != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("answered %+v, want 413", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked := servewaf.question(t); len(asked.body) != tc.asked {
|
||||||
|
t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := servewaf.report(t)
|
||||||
|
if report.Status != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("report %+v, want status 413", report)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestSize: "1K",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
|
bytes.NewReader(make([]byte, 1025))))
|
||||||
|
if got.status != http.StatusForbidden {
|
||||||
|
t.Errorf("answered %+v, want servewaf's 403", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, limit string
|
||||||
|
size int
|
||||||
|
// mayReachApp is true when the part of the body the limit lets through
|
||||||
|
// goes on to the app before the limit is met. The app may still not have
|
||||||
|
// started on the request when it is cut off.
|
||||||
|
mayReachApp bool
|
||||||
|
}{
|
||||||
|
{"within the first part", "1K", 2 << 10, false},
|
||||||
|
{"past the first part", "128K", 129 << 10, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var asked atomic.Bool
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asked.Store(true)
|
||||||
|
greet(w, r)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestSize: tc.limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+
|
||||||
|
strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+
|
||||||
|
"\r\n0\r\n\r\n")
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status %d, want 413", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked.Load() && !tc.mayReachApp {
|
||||||
|
t.Error("the app was asked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAResponseOverItsLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
large := strings.Repeat("x", 2<<10)
|
||||||
|
|
||||||
|
t.Run("announced, is refused", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||||
|
_, _ = io.WriteString(w, large)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseSize: "1K",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := get(t, addr, "/")
|
||||||
|
if got.status != http.StatusBadGateway {
|
||||||
|
t.Errorf("answered %d, want 502", got.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.Status != http.StatusBadGateway {
|
||||||
|
t.Errorf("report %+v, want status 502", report)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, large[:1])
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
_, _ = io.WriteString(w, large[1:])
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseSize: "1K",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
|
text, err := io.ReadAll(conn)
|
||||||
|
if err != nil || bytes.Contains(text, []byte(large)) {
|
||||||
|
t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := servewaf.report(t)
|
||||||
|
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
|
||||||
|
t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const announced = "2048"
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set(contentLengthHeader, announced)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseSize: "1K",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
||||||
|
if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced {
|
||||||
|
t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, setting := range []string{
|
||||||
|
maxRequestSize, maxResponseSize, maxRequestTime, maxResponseTime,
|
||||||
|
} {
|
||||||
|
t.Run(setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
setting: "off",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A body and an answer that a limit of zero would cut off, as it would the
|
||||||
|
// next answer, which rproxy gives itself.
|
||||||
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
|
strings.NewReader("a body"))))
|
||||||
|
|
||||||
|
if got := get(t, addr, proxy.HealthPath); got.status != http.StatusOK {
|
||||||
|
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, rest string
|
||||||
|
}{
|
||||||
|
{"with its length announced", announcedLength(100) + "some of it"},
|
||||||
|
{"with no length announced", "Transfer-Encoding: chunked\r\n\r\na\r\nsome of it\r\n"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "500",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+tc.rest)
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
|
||||||
|
t.Errorf("status %d, want 408", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line["disposition"] != nil || line["request_bytes"] != 10.0 {
|
||||||
|
t.Errorf("line %v, want no disposition and 10 bytes", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servewaf.questions) != 0 {
|
||||||
|
t.Error("servewaf was asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
servewaf.wantNoReport(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(100)+"some of it")
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line["status"] != 0.0 || line["disposition"] != nil {
|
||||||
|
t.Errorf("line %v, want no answer and no disposition", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servewaf.questions) != 0 {
|
||||||
|
t.Error("servewaf was asked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, request string
|
||||||
|
stopsSending bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"a malformed chunk", post + "Transfer-Encoding: chunked\r\n\r\nnot a chunk\r\n",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{"a visitor that stops sending", post + announcedLength(100) + "some of it", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, tc.request)
|
||||||
|
|
||||||
|
if tc.stopsSending {
|
||||||
|
tcpConn, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the connection is a %T", conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tcpConn.CloseWrite()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop sending: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := io.ReadAll(conn)
|
||||||
|
if err != nil || len(text) != 0 {
|
||||||
|
t.Errorf("read %q (%v), want the connection closed without an answer",
|
||||||
|
text, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line["status"] != 0.0 || line["disposition"] != nil {
|
||||||
|
t.Errorf("line %v, want no answer and no disposition", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servewaf.questions) != 0 {
|
||||||
|
t.Error("servewaf was asked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadlineRecorder notes the time limits rproxy sets on reading the visitor's body
|
||||||
|
// and on sending its answer.
|
||||||
|
type deadlineRecorder struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
|
||||||
|
readDeadline time.Time
|
||||||
|
writeDeadline time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReadDeadline notes deadline.
|
||||||
|
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
|
||||||
|
w.readDeadline = deadline
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWriteDeadline notes deadline.
|
||||||
|
func (w *deadlineRecorder) SetWriteDeadline(deadline time.Time) error {
|
||||||
|
w.writeDeadline = deadline
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
limit = 5 * time.Minute
|
||||||
|
// announced is over what Go's server reads of a body left unread, so that
|
||||||
|
// the app's own server answers without waiting for the rest.
|
||||||
|
announced = 100 * questionBodyMaxBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
_, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The visitor sends the first part and a little more, and then nothing.
|
||||||
|
stalled, stop := io.Pipe()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = stop.Close() })
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
|
||||||
|
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
|
||||||
|
req.ContentLength = announced
|
||||||
|
req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
|
||||||
|
|
||||||
|
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("status %d, want the app's 204", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.readDeadline.Before(before.Add(limit)) ||
|
||||||
|
w.readDeadline.After(time.Now().Add(limit)) {
|
||||||
|
t.Errorf("the reading of the body ends at %s, want %s from the request",
|
||||||
|
w.readDeadline, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 5 * time.Minute
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, target string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"servewaf's deny", "/", http.StatusForbidden},
|
||||||
|
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
_, _, server := startServer(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target,
|
||||||
|
http.NoBody)
|
||||||
|
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != tc.status {
|
||||||
|
t.Errorf("status %d, want %d", w.Code, tc.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.writeDeadline.Before(before.Add(limit)) ||
|
||||||
|
w.writeDeadline.After(time.Now().Add(limit)) {
|
||||||
|
t.Errorf("the time limit on sending ends at %s, want %s from the answer",
|
||||||
|
w.writeDeadline, limit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "3000",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
|
||||||
|
t.Errorf("status %d, want 408", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked := servewaf.question(t); len(asked.body) != questionBodyMaxBytes {
|
||||||
|
t.Errorf("the question carries %d bytes", len(asked.body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.Status != http.StatusRequestTimeout {
|
||||||
|
t.Errorf("report %+v, want status 408", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAppThatTakesNothing starts an app that accepts connections and never
|
||||||
|
// reads from them, and returns its URL.
|
||||||
|
func startAppThatTakesNothing(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(stopped)
|
||||||
|
|
||||||
|
_ = listener.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-stopped
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return "http://" + listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// More than the connections hold while nobody reads, written a little at a
|
||||||
|
// time, so that rproxy soon waits for the app.
|
||||||
|
const (
|
||||||
|
bodySize = 64 << 20
|
||||||
|
writeSize = 32 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, startAppThatTakesNothing(t),
|
||||||
|
map[string]string{maxRequestTime: "3000"})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(bodySize))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
part := make([]byte, writeSize)
|
||||||
|
|
||||||
|
for range bodySize / writeSize {
|
||||||
|
_, err := conn.Write(part)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusGatewayTimeout {
|
||||||
|
t.Errorf("status %d, want 504", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
<-release
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseTime: "300",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := get(t, addr, "/")
|
||||||
|
if got.status != http.StatusGatewayTimeout {
|
||||||
|
t.Errorf("answered %d, want 504", got.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.Status != http.StatusGatewayTimeout {
|
||||||
|
t.Errorf("report %+v, want status 504", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, "the start")
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
|
||||||
|
<-release
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseTime: "300",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
|
// dial's deadline ends this read with an error if the connection stays open.
|
||||||
|
_, err := io.ReadAll(conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read: %v, want the connection closed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.Status != http.StatusOK {
|
||||||
|
t.Errorf("report %+v, want the status the visitor got", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// More than the connections hold while nobody reads.
|
||||||
|
const (
|
||||||
|
bodySize = 64 << 20
|
||||||
|
writeSize = 32 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
part := make([]byte, writeSize)
|
||||||
|
|
||||||
|
for range bodySize / writeSize {
|
||||||
|
_, err := w.Write(part)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxResponseTime: "500",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The visitor asks, and never reads the answer.
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
|
||||||
|
responseBytes, _ := line["response_bytes"].(float64)
|
||||||
|
if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize {
|
||||||
|
t.Errorf("line %v, want status 200 and the answer cut off", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := servewaf.report(t)
|
||||||
|
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
|
||||||
|
t.Errorf("report %+v, want status 200 and the answer cut off", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||||
|
|
||||||
|
got := get(t, addr, "/")
|
||||||
|
if got.status != http.StatusBadGateway {
|
||||||
|
t.Errorf("answered %d, want 502", got.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.Status != http.StatusBadGateway {
|
||||||
|
t.Errorf("report %+v, want status 502", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, greeting)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "3000",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Far more announced than sent, so that Go's server answers for the app
|
||||||
|
// without waiting for the rest.
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusOK {
|
||||||
|
t.Errorf("status %d, want the app's 200", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
working := make(chan struct{})
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
close(working)
|
||||||
|
<-r.Context().Done()
|
||||||
|
})
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-working:
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Fatal("the app was not asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpConn, ok := conn.(*net.TCPConn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("the connection is a %T", conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tcpConn.CloseWrite()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop sending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := io.ReadAll(conn)
|
||||||
|
if err != nil || len(text) != 0 {
|
||||||
|
t.Errorf("read %q (%v), want the connection closed without an answer", text, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line := out.requestLine(t); line["status"] != 0.0 {
|
||||||
|
t.Errorf("line %v, want status 0", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const answer = "the end of a long answer"
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
|
||||||
|
time.Sleep(2100 * time.Millisecond)
|
||||||
|
|
||||||
|
_, _ = io.WriteString(w, answer)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "2000",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))
|
||||||
|
if got.status != http.StatusOK || got.body != answer {
|
||||||
|
t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, start string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"a deny", post, http.StatusForbidden},
|
||||||
|
{
|
||||||
|
"the health check",
|
||||||
|
"GET " + proxy.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||||
|
http.StatusOK,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "500",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Go's server waits for a little of the body left before it sends an answer,
|
||||||
|
// and the time for sending the request ends that wait.
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != tc.status {
|
||||||
|
t.Errorf("status %d, want %d", status, tc.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
package rproxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// questionBodyMaxBytes is the most of a body a question carries.
|
||||||
|
const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes
|
||||||
|
|
||||||
|
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||||
|
const chunked = "chunked"
|
||||||
|
|
||||||
|
// pathGoReencodes is a path Go's client would decode and escape again, %2F turned
|
||||||
|
// into / and | into %7C, unless told to send it as it is. A test sets it as the
|
||||||
|
// visitor's URL.Opaque, so that the visitor sends it unchanged too.
|
||||||
|
const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
||||||
|
|
||||||
|
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodDelete, addr, target, http.NoBody)
|
||||||
|
req.URL.Opaque = pathGoReencodes
|
||||||
|
req.Host = site
|
||||||
|
req.Header.Set("X-Test", "from the visitor")
|
||||||
|
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||||
|
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||||
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||||
|
req.Header.Set(proxy.ClientHeader, "198.51.100.2")
|
||||||
|
req.Header.Set(proxy.PeerHeader, "198.51.100.3")
|
||||||
|
req.Header.Set(proxy.ContentLengthHeader, "12345")
|
||||||
|
// Go's client then sends no User-Agent, so that one in the question would be
|
||||||
|
// Go's own.
|
||||||
|
req.Header["User-Agent"] = nil
|
||||||
|
|
||||||
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
if asked.method != http.MethodDelete || asked.target != target ||
|
||||||
|
asked.host != site {
|
||||||
|
t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked.header.Get(requestIDHeader) == "the-visitor's" {
|
||||||
|
t.Error("the question carries the visitor's id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly these: visit's connection has the visitor send Connection: close.
|
||||||
|
want := http.Header{}
|
||||||
|
want.Set("Connection", "close")
|
||||||
|
want.Set("X-Test", "from the visitor")
|
||||||
|
want.Set(forwardedForHeader, "198.51.100.1")
|
||||||
|
want.Set(forwardedProtoHeader, "http")
|
||||||
|
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||||
|
want.Set(proxy.ClientHeader, localhost)
|
||||||
|
want.Set(proxy.PeerHeader, localhost)
|
||||||
|
|
||||||
|
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||||
|
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const visitor = "203.0.113.9"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, trusted, client, proto, forwarded string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"from a trusted proxy", localhost + "/32", visitor, "https",
|
||||||
|
visitor + ", " + localhost,
|
||||||
|
},
|
||||||
|
{"from a peer not trusted", "10.0.0.0/8", localhost, "http", localhost},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
forwarded := make(chan http.Header, 1)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
forwarded <- r.Header.Clone()
|
||||||
|
|
||||||
|
greet(w, r)
|
||||||
|
})
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
trustedProxies: tc.trusted,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
|
req.Header.Set(forwardedForHeader, visitor)
|
||||||
|
req.Header.Set(forwardedProtoHeader, "https")
|
||||||
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
||||||
|
asked.header.Get(proxy.PeerHeader) != localhost ||
|
||||||
|
asked.header.Get(forwardedForHeader) != visitor ||
|
||||||
|
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||||
|
t.Errorf("the question's headers are %v", asked.header)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line := out.requestLine(t); line["client_ip"] != tc.client {
|
||||||
|
t.Errorf("line %v, want client_ip %s", line, tc.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded {
|
||||||
|
t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomBytes returns n random bytes.
|
||||||
|
func randomBytes(n int) []byte {
|
||||||
|
body := make([]byte, n)
|
||||||
|
_, _ = rand.Read(body) // it never returns an error
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, size := range []int{
|
||||||
|
1, questionBodyMaxBytes - 1, questionBodyMaxBytes, questionBodyMaxBytes + 1,
|
||||||
|
5 * questionBodyMaxBytes,
|
||||||
|
} {
|
||||||
|
for _, announced := range []bool{true, false} {
|
||||||
|
name := strconv.Itoa(size) + " bytes, length announced: " +
|
||||||
|
strconv.FormatBool(announced)
|
||||||
|
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := randomBytes(size)
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
received := make(chan []byte, 1)
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
whole, _ := io.ReadAll(r.Body)
|
||||||
|
received <- whole
|
||||||
|
|
||||||
|
greet(w, r)
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body))
|
||||||
|
if !announced {
|
||||||
|
// Go's client sends a body of unknown length in chunks.
|
||||||
|
req.ContentLength = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
want := body[:min(size, questionBodyMaxBytes)]
|
||||||
|
wantQuestionBody(t, asked, want, size, announced)
|
||||||
|
|
||||||
|
if !bytes.Equal(<-received, body) {
|
||||||
|
t.Error("the app did not get the whole body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.RequestBytes != int64(size) {
|
||||||
|
t.Errorf("report %+v, of %d bytes", report, size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantQuestionBody checks the body a question carries, want, and how it gives
|
||||||
|
// the length of the visitor's body, of size bytes.
|
||||||
|
func wantQuestionBody(
|
||||||
|
t *testing.T, asked question, want []byte, size int, announced bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !bytes.Equal(asked.body, want) {
|
||||||
|
t.Errorf("the question carries %d bytes, want the first %d", len(asked.body),
|
||||||
|
len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
values := asked.header.Values(proxy.ContentLengthHeader)
|
||||||
|
|
||||||
|
if announced {
|
||||||
|
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||||
|
asked.contentLength != int64(len(want)) {
|
||||||
|
t.Errorf("announced %v and length %d, want %d and %d", values,
|
||||||
|
asked.contentLength, size, len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) {
|
||||||
|
t.Errorf("announced %v and encoded %v, want none and chunked", values,
|
||||||
|
asked.transferEncoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
// Far more announced than sent. Go's server does not wait for that much, so
|
||||||
|
// the answer comes at once.
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
|
t.Errorf("status %d, want servewaf's 403", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if report := servewaf.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||||
|
t.Errorf("report %+v, want the first part's bytes", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, framing, body string
|
||||||
|
encoding []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
chunked, "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", "body",
|
||||||
|
[]string{chunked},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chunked and empty", "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "",
|
||||||
|
[]string{chunked},
|
||||||
|
},
|
||||||
|
{"with its length", announcedLength(4) + "body", "body", nil},
|
||||||
|
} {
|
||||||
|
for _, method := range []string{
|
||||||
|
http.MethodGet, http.MethodDelete, http.MethodPost,
|
||||||
|
} {
|
||||||
|
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
|
t.Errorf("status %d, want servewaf's 403", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
if string(asked.body) != tc.body ||
|
||||||
|
!slices.Equal(asked.transferEncoding, tc.encoding) {
|
||||||
|
t.Errorf("the question carries %q, encoded %v, want %q, encoded %v",
|
||||||
|
asked.body, asked.transferEncoding, tc.body, tc.encoding)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, method := range []string{http.MethodTrace, "PROPFIND", "LOCK", "BREW"} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("the app was asked")
|
||||||
|
})
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
|
||||||
|
if got.status != http.StatusMethodNotAllowed ||
|
||||||
|
got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" {
|
||||||
|
t.Errorf("answered %+v, want 405 naming the methods passed on", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line["status"] != float64(http.StatusMethodNotAllowed) ||
|
||||||
|
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||||
|
t.Errorf("line %v, want status 405 and no disposition", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servewaf.questions) != 0 {
|
||||||
|
t.Error("servewaf was asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
servewaf.wantNoReport(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, method := range []string{
|
||||||
|
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
|
||||||
|
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
|
||||||
|
} {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reached := make(chan string, 1)
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
reached <- r.Method
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
|
||||||
|
if got.status != http.StatusOK {
|
||||||
|
t.Errorf("answered %+v, want the app's 200", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked := servewaf.question(t); asked.method != method {
|
||||||
|
t.Errorf("servewaf was asked about %s", asked.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
if atApp := <-reached; atApp != method {
|
||||||
|
t.Errorf("the app was asked %s", atApp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "Host: site.example\r\n\r\n"
|
||||||
|
|
||||||
|
for name, request := range map[string]string{
|
||||||
|
"a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host,
|
||||||
|
"a target of a URL": "GET http://site.example/ HTTP/1.1\r\n" + host,
|
||||||
|
"a host Go will not send": "GET http://a<b/ HTTP/1.1\r\n" + host,
|
||||||
|
"a target of a host": "GET site.example:443 HTTP/1.1\r\n" + host,
|
||||||
|
"a target of *": "GET * HTTP/1.1\r\n" + host,
|
||||||
|
"a target of //": "GET //x/y HTTP/1.1\r\n" + host,
|
||||||
|
"a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host,
|
||||||
|
"an empty Host": "GET / HTTP/1.1\r\nHost:\r\n\r\n",
|
||||||
|
"HTTP/1.0 with no Host": "GET / HTTP/1.0\r\n\r\n",
|
||||||
|
"a Host with a zone": "GET / HTTP/1.1\r\nHost: [fe80::1%25eth0]:80\r\n\r\n",
|
||||||
|
"two User-Agents": "GET / HTTP/1.1\r\nUser-Agent: a\r\nUser-Agent: b\r\n" +
|
||||||
|
host,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, request)
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusBadRequest {
|
||||||
|
t.Errorf("status %d, want 400", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.requestLine(t)
|
||||||
|
if line["status"] != float64(http.StatusBadRequest) ||
|
||||||
|
line["disposition"] != nil || line["default_disposition"] != nil {
|
||||||
|
t.Errorf("line %v, want status 400 and no disposition", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servewaf.questions) != 0 {
|
||||||
|
t.Error("servewaf was asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
servewaf.wantNoReport(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const host = "a%b"
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n")
|
||||||
|
|
||||||
|
if status := readStatus(t, conn); status != http.StatusOK {
|
||||||
|
t.Errorf("status %d, want the app's 200", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asked := servewaf.question(t); asked.host != host {
|
||||||
|
t.Errorf("asked about the host %q, want %q", asked.host, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const want = "http"
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, allow)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
trustedProxies: localhost + "/32",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
if got := asked.header.Get(forwardedProtoHeader); got != want {
|
||||||
|
t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAQuestionNamesNoZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, remoteAddr, forwardedFor, client, peer string
|
||||||
|
}{
|
||||||
|
{"from a link-local peer", "[fe80::1%eth0]:1234", "", "fe80::1", "fe80::1"},
|
||||||
|
{
|
||||||
|
"from X-Forwarded-For through a trusted peer", localhost + ":1234",
|
||||||
|
"2001:db8::1%eth0", "2001:db8::1", localhost,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
servewaf := startServeWAF(t, deny)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
_, out, server := startServer(t, servewaf.URL, app.URL, map[string]string{
|
||||||
|
trustedProxies: localhost + "/32",
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
|
req.RemoteAddr = tc.remoteAddr
|
||||||
|
req.Header.Set(forwardedForHeader, tc.forwardedFor)
|
||||||
|
|
||||||
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
asked := servewaf.question(t)
|
||||||
|
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
||||||
|
asked.header.Get(proxy.PeerHeader) != tc.peer {
|
||||||
|
t.Errorf("the question's headers are %v", asked.header)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line := out.requestLine(t); line["client_ip"] != tc.client {
|
||||||
|
t.Errorf("line %v, want client_ip %s", line, tc.client)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||||
|
// soon as it arrives.
|
||||||
|
const flushAfterEachWrite time.Duration = -1
|
||||||
|
|
||||||
|
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
||||||
|
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||||
|
const reportStatusMax = 599
|
||||||
|
|
||||||
|
// plainText is the Content-Type of the answers rproxy gives itself.
|
||||||
|
const plainText = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
|
// request is one request on its way through rproxy, from the moment its headers
|
||||||
|
// have been read to its log line and report.
|
||||||
|
type request struct {
|
||||||
|
h *handler
|
||||||
|
in *http.Request
|
||||||
|
// rc sets the deadlines of the connection to the visitor.
|
||||||
|
rc *http.ResponseController
|
||||||
|
out *responseWriter
|
||||||
|
body *requestBody // nil for a request without a body
|
||||||
|
|
||||||
|
// id is the fresh id made for the request when it arrived.
|
||||||
|
id string
|
||||||
|
client netip.Addr
|
||||||
|
peer netip.Addr
|
||||||
|
peerTrusted bool
|
||||||
|
start time.Time
|
||||||
|
|
||||||
|
// disposition is what is done with the request: servewaf's valid disposition,
|
||||||
|
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||||
|
disposition string
|
||||||
|
// answered is true when servewaf gave disposition, which makes a report due,
|
||||||
|
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||||
|
answered bool
|
||||||
|
byDefault bool
|
||||||
|
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
||||||
|
// app.
|
||||||
|
asn, country string
|
||||||
|
|
||||||
|
// upstreamStart is when the request was handed to the app.
|
||||||
|
upstreamStart time.Time
|
||||||
|
// cancel ends the request to the app.
|
||||||
|
cancel context.CancelFunc
|
||||||
|
// refused is the status of the first refusal, from whichever goroutine meets
|
||||||
|
// it: what the visitor is answered if the response has not started yet.
|
||||||
|
refused atomic.Int64
|
||||||
|
// upgraded is the connection to the app once the app has switched protocols,
|
||||||
|
// as for a WebSocket, and nil otherwise.
|
||||||
|
upgraded *upgradedConn
|
||||||
|
|
||||||
|
// mu guards what follows. The time limits run on goroutines of their own,
|
||||||
|
// and the transport starts and stops them, and notes the times below, from
|
||||||
|
// its own; once timersStopped is set, neither time limit acts any more.
|
||||||
|
mu sync.Mutex
|
||||||
|
timersStopped bool
|
||||||
|
requestTimer *time.Timer
|
||||||
|
responseTimer *time.Timer
|
||||||
|
// requestEnd is when the app had been sent the whole request, or the answer
|
||||||
|
// started, if that was sooner.
|
||||||
|
requestEnd time.Time
|
||||||
|
// answerStarted is when the first byte of the app's answer arrived.
|
||||||
|
answerStarted time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRequest starts handling r: it notes the time, works out the visitor from
|
||||||
|
// the TCP peer and X-Forwarded-For, and makes the request's id.
|
||||||
|
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
||||||
|
peer := peerAddress(r)
|
||||||
|
trusted := h.config.TrustedProxies
|
||||||
|
|
||||||
|
rq := &request{
|
||||||
|
h: h,
|
||||||
|
in: r,
|
||||||
|
rc: http.NewResponseController(w),
|
||||||
|
out: &responseWriter{ResponseWriter: w},
|
||||||
|
id: rand.Text(),
|
||||||
|
client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
|
||||||
|
peer: peer,
|
||||||
|
peerTrusted: isInside(peer, trusted),
|
||||||
|
start: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Body != http.NoBody {
|
||||||
|
rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerHealth answers the health check.
|
||||||
|
func (rq *request) answerHealth() {
|
||||||
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||||
|
rq.out.Header().Set("Content-Type", plainText)
|
||||||
|
rq.send(http.StatusOK, []byte("ok\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
|
||||||
|
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
|
||||||
|
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||||
|
// visitor that went away or stopped its sending side, the connection is closed
|
||||||
|
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||||
|
// read, so that servewaf is asked first; passToApp then refuses it.
|
||||||
|
func (rq *request) readFirstPartOfBody() bool {
|
||||||
|
if rq.body == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||||
|
held, err := io.ReadAll(io.LimitReader(rq.body, proxy.QuestionBodyMaxBytes))
|
||||||
|
// From here on, the request timer ends a read of the rest of the body, so that
|
||||||
|
// the answer can name the side rproxy was waiting on.
|
||||||
|
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
rq.body.held = held
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil || rq.refused.Load() != 0:
|
||||||
|
return true
|
||||||
|
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||||
|
rq.answer(http.StatusRequestTimeout)
|
||||||
|
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
// This panic has Go's server close the connection without an answer, and
|
||||||
|
// log nothing; the deferred finish still runs.
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPathAsSent has out, the URL of a request made from in, send the path as the
|
||||||
|
// visitor sent it. Go sends the path URL.EscapedPath gives, which, for a path
|
||||||
|
// holding a character Go escapes, such as |, is the whole path decoded and
|
||||||
|
// escaped again, %2F turned into /. Opaque is sent as it is.
|
||||||
|
func setPathAsSent(out *url.URL, in *http.Request) {
|
||||||
|
out.Opaque = rules.Path(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward passes the request to the app, the part of the body held and then the
|
||||||
|
// rest, and the app's answer back. ctx is the request's own context.
|
||||||
|
func (rq *request) forward(ctx context.Context) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rq.cancel = cancel
|
||||||
|
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||||
|
WroteRequest: rq.wroteRequest,
|
||||||
|
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||||
|
})
|
||||||
|
|
||||||
|
out := rq.in.WithContext(ctx)
|
||||||
|
if rq.body != nil {
|
||||||
|
out.Body = rq.body
|
||||||
|
}
|
||||||
|
|
||||||
|
reverseProxy := &httputil.ReverseProxy{
|
||||||
|
Rewrite: rq.rewrite,
|
||||||
|
Transport: rq.h.transport,
|
||||||
|
FlushInterval: flushAfterEachWrite,
|
||||||
|
ErrorLog: rq.h.errorLog,
|
||||||
|
ModifyResponse: rq.modifyResponse,
|
||||||
|
ErrorHandler: rq.answerError,
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.startRequestTimer()
|
||||||
|
rq.upstreamStart = time.Now()
|
||||||
|
reverseProxy.ServeHTTP(rq.out, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||||
|
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||||
|
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||||
|
// servewaf's.
|
||||||
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
|
upstream := rq.h.config.UpstreamURL
|
||||||
|
pr.Out.URL.Scheme = upstream.Scheme
|
||||||
|
pr.Out.URL.Host = upstream.Host
|
||||||
|
// ReverseProxy drops query parameters it cannot parse; the app gets the
|
||||||
|
// query as the visitor sent it.
|
||||||
|
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
||||||
|
setPathAsSent(pr.Out.URL, pr.In)
|
||||||
|
|
||||||
|
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||||
|
pr.Out.Header.Set(requestIDHeader, rq.id)
|
||||||
|
pr.Out.Header.Del(asnHeader)
|
||||||
|
pr.Out.Header.Del(countryHeader)
|
||||||
|
|
||||||
|
if rq.asn != "" {
|
||||||
|
pr.Out.Header.Set(asnHeader, rq.asn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.country != "" {
|
||||||
|
pr.Out.Header.Set(countryHeader, rq.country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyResponse looks at the app's answer before ReverseProxy passes it on.
|
||||||
|
func (rq *request) modifyResponse(res *http.Response) error {
|
||||||
|
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||||
|
// An upgraded connection, such as a WebSocket, is not cut by the time
|
||||||
|
// limits. ReverseProxy writes this answer straight to the connection it
|
||||||
|
// takes over, not through rq.out, and then copies what passes each way
|
||||||
|
// through res.Body, the connection to the app. It may still refuse the
|
||||||
|
// switch, as when the app's Upgrade is not the visitor's, so the status
|
||||||
|
// is noted only once it has taken over the connection, by rq.out's
|
||||||
|
// Hijack.
|
||||||
|
rq.stopTimers()
|
||||||
|
|
||||||
|
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||||
|
if ok {
|
||||||
|
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
||||||
|
res.Body = rq.upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxBytes := rq.h.config.MaxResponseSize
|
||||||
|
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
|
||||||
|
rq.refuse(http.StatusBadGateway)
|
||||||
|
|
||||||
|
return errResponseTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's server adds a Content-Type it guesses from the body to a response
|
||||||
|
// whose headers have no Content-Type key. The key with no value sends none,
|
||||||
|
// as the app sent none.
|
||||||
|
if res.Header.Get("Content-Type") == "" {
|
||||||
|
rq.out.Header()["Content-Type"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
||||||
|
rq.setResponseDeadline()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerError is ReverseProxy's ErrorHandler: the request could not be passed to
|
||||||
|
// the app, or the app's answer cannot be passed on.
|
||||||
|
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
|
||||||
|
if rq.upgraded != nil {
|
||||||
|
// The app switched protocols, but the switch was not passed on, and
|
||||||
|
// ReverseProxy leaves the app's connection open when it refuses it.
|
||||||
|
_ = rq.upgraded.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
status := int(rq.refused.Load())
|
||||||
|
if status == 0 {
|
||||||
|
if rq.in.Context().Err() != nil {
|
||||||
|
// The visitor went away or stopped its sending side. This panic has Go's
|
||||||
|
// server close the connection without an answer, and log nothing; the
|
||||||
|
// deferred finish still runs.
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
|
||||||
|
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.answer(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer sends rproxy's own answer, unless the response has already started.
|
||||||
|
func (rq *request) answer(status int) {
|
||||||
|
rq.refused.CompareAndSwap(0, int64(status))
|
||||||
|
|
||||||
|
if rq.out.status != 0 {
|
||||||
|
return // too late to answer: the connection can only be cut
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visitor found too slow is read no more; any other may go on sending until
|
||||||
|
// its time is up, so that Go's server can read the rest of the body and end
|
||||||
|
// the request cleanly.
|
||||||
|
deadline := rq.requestDeadline()
|
||||||
|
if status == http.StatusRequestTimeout {
|
||||||
|
deadline = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.prepareOwnAnswer(deadline)
|
||||||
|
|
||||||
|
header := rq.out.Header()
|
||||||
|
header.Set("Content-Type", plainText)
|
||||||
|
header.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
rq.send(status, []byte(http.StatusText(status)+"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// send gives the visitor an answer rproxy holds whole: status, the headers set so
|
||||||
|
// far and body. Go's server would otherwise send so short an answer only once the
|
||||||
|
// handler has returned, after finish has written the log line and sent the report.
|
||||||
|
// Without Content-Length, the flush would send the body in chunks, the last of
|
||||||
|
// which would still wait for the handler. A HEAD's answer has no body, so any
|
||||||
|
// Content-Length it has stays as servewaf gave it.
|
||||||
|
func (rq *request) send(status int, body []byte) {
|
||||||
|
if rq.in.Method != http.MethodHead {
|
||||||
|
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.out.WriteHeader(status)
|
||||||
|
_, _ = rq.out.Write(body)
|
||||||
|
_ = rq.rc.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
|
||||||
|
// reading of a body that has not arrived whole ends at deadline, and the answer
|
||||||
|
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
||||||
|
// used that time up first.
|
||||||
|
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
||||||
|
rq.stopReadingBody(deadline)
|
||||||
|
rq.endRequest()
|
||||||
|
|
||||||
|
limit := rq.h.config.MaxResponseTime
|
||||||
|
if limit > 0 {
|
||||||
|
_ = rq.rc.SetWriteDeadline(time.Now().Add(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuse records status, unless an earlier refusal was, and ends the request to
|
||||||
|
// the app, if one was made.
|
||||||
|
func (rq *request) refuse(status int) {
|
||||||
|
rq.refused.CompareAndSwap(0, int64(status))
|
||||||
|
|
||||||
|
if rq.cancel != nil {
|
||||||
|
rq.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish ends the request's time limits, writes its log line and, if servewaf
|
||||||
|
// answered the question, sends the report. ctx is the request's own context.
|
||||||
|
func (rq *request) finish(ctx context.Context) {
|
||||||
|
rq.stopTimers()
|
||||||
|
|
||||||
|
if rq.refused.Load() == 0 {
|
||||||
|
rq.stopReadingBody(rq.requestDeadline())
|
||||||
|
}
|
||||||
|
|
||||||
|
requestEnd := rq.endRequest()
|
||||||
|
end := time.Now()
|
||||||
|
requestBytes, responseBytes := rq.bytesEachWay()
|
||||||
|
|
||||||
|
err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{
|
||||||
|
Time: requestlog.FormatTime(rq.start),
|
||||||
|
RequestID: rq.id,
|
||||||
|
ClientIP: rq.client.String(),
|
||||||
|
Host: rq.in.Host,
|
||||||
|
Method: rq.in.Method,
|
||||||
|
Path: rules.Path(rq.in),
|
||||||
|
Query: rq.in.URL.RawQuery,
|
||||||
|
Status: rq.out.status,
|
||||||
|
RequestBytes: requestBytes,
|
||||||
|
ResponseBytes: responseBytes,
|
||||||
|
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
|
||||||
|
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||||
|
Disposition: rq.disposition,
|
||||||
|
DefaultDisposition: rq.byDefault,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.answered {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report := &proxy.Report{
|
||||||
|
RequestID: rq.id,
|
||||||
|
Status: min(rq.out.status, reportStatusMax),
|
||||||
|
RequestBytes: requestBytes,
|
||||||
|
ResponseBytes: responseBytes,
|
||||||
|
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
|
||||||
|
}
|
||||||
|
if !rq.upstreamStart.IsZero() {
|
||||||
|
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.h.sendReport(ctx, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesEachWay returns the body bytes of the request and of the answer. For an
|
||||||
|
// upgraded connection, such as a WebSocket, which has closed by then, what it
|
||||||
|
// carried from the visitor counts with the request's and what it carried from the
|
||||||
|
// app with the answer's.
|
||||||
|
func (rq *request) bytesEachWay() (int64, int64) {
|
||||||
|
var requestBytes int64
|
||||||
|
if rq.body != nil {
|
||||||
|
requestBytes = rq.body.bytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBytes := rq.out.bytes
|
||||||
|
|
||||||
|
if rq.upgraded != nil {
|
||||||
|
requestBytes += rq.upgraded.toApp.Load()
|
||||||
|
responseBytes += rq.upgraded.fromApp.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestBytes, responseBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDeadline is when the visitor must have sent its whole request, or zero
|
||||||
|
// when SWRP_MAX_REQUEST_TIME_MS is off.
|
||||||
|
func (rq *request) requestDeadline() time.Time {
|
||||||
|
limit := rq.h.config.MaxRequestTime
|
||||||
|
if limit == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.start.Add(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopReadingBody ends, at deadline, the reading of a visitor's body that has not
|
||||||
|
// arrived whole: Go's server then reads no more of it, and closes the connection
|
||||||
|
// after the answer.
|
||||||
|
func (rq *request) stopReadingBody(deadline time.Time) {
|
||||||
|
if rq.body == nil || rq.body.received.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = rq.rc.SetReadDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
|
||||||
|
// request's headers being read until the app has been sent the whole request.
|
||||||
|
func (rq *request) startRequestTimer() {
|
||||||
|
deadline := rq.requestDeadline()
|
||||||
|
if deadline.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
|
||||||
|
// request is still on its way to the app. The answer names the side rproxy was
|
||||||
|
// waiting on at that moment: 408 when it was waiting for the visitor to send more
|
||||||
|
// of its body, 504 when it was waiting for the app to be reached or to take what
|
||||||
|
// it had.
|
||||||
|
func (rq *request) requestTimedOut() {
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
if rq.timersStopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rq.body == nil || !rq.body.waiting.Load() {
|
||||||
|
// Once the app has answered, the transport, ReverseProxy and Go's server
|
||||||
|
// each close the visitor's body, and closing it reads what is left of a
|
||||||
|
// body under 256 KiB without requestBody.Read, so waiting is false while
|
||||||
|
// that read waits for the visitor. That read ends now, before refuse:
|
||||||
|
// should rproxy still answer itself, answer then sets the deadline it
|
||||||
|
// gives its own answers.
|
||||||
|
if !rq.answerStarted.IsZero() {
|
||||||
|
rq.stopReadingBody(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.refuse(http.StatusGatewayTimeout)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.refuse(http.StatusRequestTimeout)
|
||||||
|
// The transport gives up on the app only once its Read of the visitor's body
|
||||||
|
// returns, so that Read is ended now. The lock keeps this from reaching the
|
||||||
|
// connection after the request is handled.
|
||||||
|
_ = rq.rc.SetReadDeadline(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// gotFirstResponseByte is called once the first byte of the app's answer has
|
||||||
|
// arrived.
|
||||||
|
func (rq *request) gotFirstResponseByte() {
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
rq.answerStarted = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wroteRequest is called once the app has been sent the whole request:
|
||||||
|
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
|
||||||
|
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||||
|
if info.Err != nil {
|
||||||
|
return // the transport gives up, or tries again
|
||||||
|
}
|
||||||
|
|
||||||
|
requestEnd := rq.endRequest()
|
||||||
|
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
if rq.timersStopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTimer(rq.requestTimer)
|
||||||
|
|
||||||
|
limit := rq.h.config.MaxResponseTime
|
||||||
|
if limit > 0 {
|
||||||
|
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
|
||||||
|
rq.responseTimedOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the
|
||||||
|
// app has sent its whole answer.
|
||||||
|
func (rq *request) responseTimedOut() {
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
if !rq.timersStopped {
|
||||||
|
rq.refuse(http.StatusGatewayTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseReceived is called once the app has sent its whole answer. An app may
|
||||||
|
// answer before the visitor has sent its whole body, as with a 404 it sends
|
||||||
|
// without reading it. The time limits then run on: until the transport's read of
|
||||||
|
// the body returns, Go's server cannot send the answer, and
|
||||||
|
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
|
||||||
|
// sending.
|
||||||
|
func (rq *request) responseReceived() {
|
||||||
|
if rq.body != nil && !rq.body.received.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.stopTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
|
||||||
|
// visitor: the app's answer must reach the visitor within it of the end of the
|
||||||
|
// request.
|
||||||
|
func (rq *request) setResponseDeadline() {
|
||||||
|
requestEnd := rq.endRequest()
|
||||||
|
|
||||||
|
limit := rq.h.config.MaxResponseTime
|
||||||
|
if limit > 0 {
|
||||||
|
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endRequest notes now as the end of the request, unless an end is noted, and
|
||||||
|
// returns the end noted.
|
||||||
|
func (rq *request) endRequest() time.Time {
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
if rq.requestEnd.IsZero() {
|
||||||
|
rq.requestEnd = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return rq.requestEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopTimers stops the request's time limits and keeps any from starting later:
|
||||||
|
// the app's answer is complete, the connection upgraded, or the request handled.
|
||||||
|
func (rq *request) stopTimers() {
|
||||||
|
rq.mu.Lock()
|
||||||
|
defer rq.mu.Unlock()
|
||||||
|
|
||||||
|
rq.timersStopped = true
|
||||||
|
stopTimer(rq.requestTimer)
|
||||||
|
stopTimer(rq.responseTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopTimer stops t, which is nil when its time limit is off.
|
||||||
|
func stopTimer(t *time.Timer) {
|
||||||
|
if t != nil {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
||||||
|
// about each request. It passes a request servewaf allows to the app and the
|
||||||
|
// app's answer back, within the size and time limits. Any other answer from
|
||||||
|
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
|
||||||
|
// decides only when servewaf cannot be connected to, does not answer at all, or
|
||||||
|
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
|
// method it does not pass on, or one it cannot put to servewaf as the visitor
|
||||||
|
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||||
|
// SPEC.md says what it does.
|
||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How rproxy keeps connections to the app and to servewaf open between
|
||||||
|
// requests.
|
||||||
|
const (
|
||||||
|
idleConns = 100
|
||||||
|
idleConnTimeout = 90 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// The limits on a connection from a visitor that have no setting.
|
||||||
|
const (
|
||||||
|
// requestHeaderMaxBytes is the largest request line and headers a visitor
|
||||||
|
// may send.
|
||||||
|
requestHeaderMaxBytes = 32 << 10
|
||||||
|
// goServerExtraHeaderBytes is how far past MaxHeaderBytes Go's server reads
|
||||||
|
// before it refuses.
|
||||||
|
goServerExtraHeaderBytes = 4 << 10
|
||||||
|
// idleTimeout is how long a kept-open connection may wait for its next
|
||||||
|
// request.
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
||||||
|
// never reaches the app.
|
||||||
|
const adminPrefix = "/_smallwebwaf/"
|
||||||
|
|
||||||
|
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
||||||
|
// them. It answers any other method 405 itself.
|
||||||
|
const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY"
|
||||||
|
|
||||||
|
// Params are what New needs.
|
||||||
|
type Params struct {
|
||||||
|
Config *config.RProxy
|
||||||
|
// RequestLog receives one short JSON line per request.
|
||||||
|
RequestLog io.Writer
|
||||||
|
// ProcessLog receives the process's own messages.
|
||||||
|
ProcessLog *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the server `smallwebwaf rproxy` runs.
|
||||||
|
type Server struct {
|
||||||
|
*http.Server
|
||||||
|
|
||||||
|
h *handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
||||||
|
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||||
|
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||||
|
// the headers arrive. The handler applies the time and size limits from then
|
||||||
|
// on.
|
||||||
|
func New(params Params) *Server {
|
||||||
|
backend := params.Config.WAFBackend
|
||||||
|
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||||
|
h := &handler{
|
||||||
|
config: params.Config,
|
||||||
|
requestLog: params.RequestLog,
|
||||||
|
processLog: params.ProcessLog,
|
||||||
|
errorLog: errorLog,
|
||||||
|
transport: newTransport(),
|
||||||
|
serveWAFTransport: newServeWAFTransport(),
|
||||||
|
reportURL: (&url.URL{
|
||||||
|
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath,
|
||||||
|
}).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
Server: &http.Server{
|
||||||
|
Addr: params.Config.ListenAddr,
|
||||||
|
Handler: h,
|
||||||
|
ErrorLog: errorLog,
|
||||||
|
ReadHeaderTimeout: params.Config.MaxRequestTime,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
MaxHeaderBytes: requestHeaderMaxBytes - goServerExtraHeaderBytes,
|
||||||
|
},
|
||||||
|
h: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForReports returns once the reports being sent have gone or failed,
|
||||||
|
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
||||||
|
// a WebSocket that closes while rproxy stops, for one.
|
||||||
|
func (s *Server) WaitForReports() {
|
||||||
|
s.h.reportsMu.Lock()
|
||||||
|
s.h.reportsStopped = true
|
||||||
|
s.h.reportsMu.Unlock()
|
||||||
|
|
||||||
|
s.h.reports.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handler is the proxy. It holds what every request shares; what belongs to
|
||||||
|
// one request is in a request.
|
||||||
|
type handler struct {
|
||||||
|
config *config.RProxy
|
||||||
|
requestLog io.Writer
|
||||||
|
processLog *slog.Logger
|
||||||
|
errorLog *log.Logger
|
||||||
|
// transport carries requests to the app, and serveWAFTransport questions and
|
||||||
|
// reports to servewaf.
|
||||||
|
transport http.RoundTripper
|
||||||
|
serveWAFTransport http.RoundTripper
|
||||||
|
// reportURL is where the reports go.
|
||||||
|
reportURL string
|
||||||
|
// reports waits for the reports being sent. reportsMu guards starting one
|
||||||
|
// against reportsStopped, which is set once nothing is to wait for another.
|
||||||
|
reports sync.WaitGroup
|
||||||
|
reportsMu sync.Mutex
|
||||||
|
reportsStopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTransport returns what carries requests to the app. It never goes through
|
||||||
|
// a proxy named in the environment, and leaves answers compressed or not as they
|
||||||
|
// were sent.
|
||||||
|
func newTransport() *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
MaxIdleConns: idleConns,
|
||||||
|
MaxIdleConnsPerHost: idleConns,
|
||||||
|
IdleConnTimeout: idleConnTimeout,
|
||||||
|
DisableCompression: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServeWAFTransport returns what carries questions and reports to servewaf.
|
||||||
|
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade
|
||||||
|
// header, or a Connection header other than close or keep-alive, and that would
|
||||||
|
// read as servewaf giving no answer.
|
||||||
|
func newServeWAFTransport() *http.Transport {
|
||||||
|
transport := newTransport()
|
||||||
|
transport.Protocols = new(http.Protocols)
|
||||||
|
transport.Protocols.SetHTTP1(true)
|
||||||
|
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
||||||
|
// request with a method it does not pass on or one it cannot put to servewaf as
|
||||||
|
// the visitor sent it, and for any other request reads the first part of the
|
||||||
|
// body, asks servewaf, and does what the answer says. Once the answer has ended,
|
||||||
|
// it writes the request's log line and sends its report.
|
||||||
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rq := h.newRequest(w, r)
|
||||||
|
defer rq.finish(r.Context())
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == proxy.HealthPath {
|
||||||
|
rq.answerHealth()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(strings.Split(allowedMethods, ", "), r.Method) {
|
||||||
|
rq.out.Header().Set("Allow", allowedMethods)
|
||||||
|
rq.answer(http.StatusMethodNotAllowed)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canBeAsked(r) {
|
||||||
|
rq.answer(http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.readFirstPartOfBody() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, err := rq.ask(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
rq.handleNoUsableAnswer(r.Context(), err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.noteAnswer(answer)
|
||||||
|
|
||||||
|
switch rq.disposition {
|
||||||
|
case proxy.DispositionAllow:
|
||||||
|
rq.passToApp(r.Context())
|
||||||
|
case proxy.DispositionClose:
|
||||||
|
// This panic has Go's server close the connection without an answer,
|
||||||
|
// and log nothing; the deferred finish still runs.
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
default: // a deny, an error, or an answer without a valid disposition
|
||||||
|
rq.answerWith(answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||||
|
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
||||||
|
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||||
|
// request's own context.
|
||||||
|
func (rq *request) passToApp(ctx context.Context) {
|
||||||
|
maxBytes := rq.h.config.MaxRequestSize
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
|
||||||
|
rq.answer(http.StatusRequestEntityTooLarge)
|
||||||
|
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
|
||||||
|
rq.answer(http.StatusNotFound)
|
||||||
|
default:
|
||||||
|
rq.forward(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
|
||||||
|
// It is far above any answer's.
|
||||||
|
const answerBodyMaxBytes = 1 << 20
|
||||||
|
|
||||||
|
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||||
|
// which has no body when servewaf takes the report.
|
||||||
|
const reportAnswerMaxBytes = 4 << 10
|
||||||
|
|
||||||
|
var (
|
||||||
|
errCannotPassOn = errors.New("the answer cannot be passed to the visitor")
|
||||||
|
errReportRefused = errors.New("the report was answered")
|
||||||
|
)
|
||||||
|
|
||||||
|
// servewafAnswer is what servewaf answered to a question.
|
||||||
|
type servewafAnswer struct {
|
||||||
|
status int
|
||||||
|
header http.Header
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
||||||
|
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
||||||
|
// on.
|
||||||
|
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
||||||
|
disposition := answer.header.Get(proxy.DispositionHeader)
|
||||||
|
|
||||||
|
switch disposition {
|
||||||
|
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose:
|
||||||
|
rq.disposition = disposition
|
||||||
|
rq.answered = true
|
||||||
|
rq.asn = answer.header.Get(asnHeader)
|
||||||
|
rq.country = answer.header.Get(countryHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNoUsableAnswer deals with a request whose question ended in err, which
|
||||||
|
// leaves no answer to act on. ctx is the request's own context.
|
||||||
|
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
// The visitor went away while servewaf was being asked. servewaf has not
|
||||||
|
// failed, and nothing is noted. This panic has Go's server close the
|
||||||
|
// connection without an answer, and log nothing; the deferred finish still
|
||||||
|
// runs.
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
case errors.Is(err, errCannotPassOn):
|
||||||
|
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
|
||||||
|
rq.answer(http.StatusBadGateway)
|
||||||
|
default:
|
||||||
|
// servewaf cannot be connected to, does not answer at all, or does not answer
|
||||||
|
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
|
||||||
|
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
|
||||||
|
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||||
|
// whenever servewaf is slow. Making such requests hard to send is
|
||||||
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||||
|
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
||||||
|
|
||||||
|
rq.disposition = rq.h.config.DefaultDisposition
|
||||||
|
rq.byDefault = true
|
||||||
|
|
||||||
|
if rq.disposition == proxy.DispositionAllow {
|
||||||
|
rq.passToApp(ctx)
|
||||||
|
} else {
|
||||||
|
rq.answer(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends servewaf the question, and returns its answer, which must have come
|
||||||
|
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||||
|
// away.
|
||||||
|
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
question, err := rq.newQuestion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send the question: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// The body of a 101 is the connection itself, and the time limit does not end
|
||||||
|
// a read of that. Go's client also takes a status below 100, which Go's server
|
||||||
|
// panics when asked to send.
|
||||||
|
if res.StatusCode < http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read the answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) > answerBodyMaxBytes {
|
||||||
|
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBeAsked reports whether Go's client can send servewaf r as the visitor sent
|
||||||
|
// it. It cannot send these as they came:
|
||||||
|
// - a target that does not start with /, or that starts with //;
|
||||||
|
// - an empty Host, for which it sends servewaf's own address;
|
||||||
|
// - a Host holding an IPv6 zone, which it removes;
|
||||||
|
// - more than one User-Agent, of which it sends the first;
|
||||||
|
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||||
|
// servewaf.
|
||||||
|
func canBeAsked(r *http.Request) bool {
|
||||||
|
target, host := r.RequestURI, r.Host
|
||||||
|
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||||
|
|
||||||
|
return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") &&
|
||||||
|
host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQuestion returns the question about the request, as "The question" in
|
||||||
|
// SPEC.md gives it: the visitor's request as the visitor sent it, with the part
|
||||||
|
// of the body it holds and rproxy's headers.
|
||||||
|
func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||||
|
in := rq.in
|
||||||
|
|
||||||
|
var held []byte
|
||||||
|
if rq.body != nil {
|
||||||
|
held = rq.body.held
|
||||||
|
}
|
||||||
|
|
||||||
|
question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
||||||
|
ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("make the question: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := question.URL
|
||||||
|
target := *in.URL
|
||||||
|
target.Scheme = backend.Scheme
|
||||||
|
target.Host = backend.Host
|
||||||
|
setPathAsSent(&target, in)
|
||||||
|
question.URL = &target
|
||||||
|
question.Host = in.Host
|
||||||
|
|
||||||
|
// The length NewRequestWithContext set is that of what is held. This line makes
|
||||||
|
// a body whose length the visitor did not announce go chunked, as "The question"
|
||||||
|
// in SPEC.md requires.
|
||||||
|
question.TransferEncoding = in.TransferEncoding
|
||||||
|
|
||||||
|
question.Header = in.Header.Clone()
|
||||||
|
question.Header.Set(proxy.ClientHeader, rq.client.String())
|
||||||
|
question.Header.Set(proxy.PeerHeader, rq.peer.String())
|
||||||
|
question.Header.Del(proxy.ContentLengthHeader)
|
||||||
|
|
||||||
|
announced := in.Header.Get("Content-Length")
|
||||||
|
if announced != "" {
|
||||||
|
question.Header.Set(proxy.ContentLengthHeader, announced)
|
||||||
|
}
|
||||||
|
|
||||||
|
question.Header.Set(requestIDHeader, rq.id)
|
||||||
|
question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted))
|
||||||
|
|
||||||
|
// Go's client adds a User-Agent of its own to a request without the key.
|
||||||
|
_, sent := question.Header["User-Agent"]
|
||||||
|
if !sent {
|
||||||
|
question.Header["User-Agent"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return question, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerWith gives the visitor servewaf's answer, less its disposition.
|
||||||
|
func (rq *request) answerWith(answer *servewafAnswer) {
|
||||||
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||||
|
|
||||||
|
header := rq.out.Header()
|
||||||
|
maps.Copy(header, answer.header)
|
||||||
|
header.Del(proxy.DispositionHeader)
|
||||||
|
|
||||||
|
// Go's server adds a Content-Type it guesses from the body to a response whose
|
||||||
|
// headers have no Content-Type key. The key with no value sends none.
|
||||||
|
if header.Get("Content-Type") == "" {
|
||||||
|
header["Content-Type"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.send(answer.status, answer.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendReport sends servewaf report on a goroutine of its own, so that the
|
||||||
|
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||||
|
// context, has ended.
|
||||||
|
func (h *handler) sendReport(ctx context.Context, report *proxy.Report) {
|
||||||
|
h.reportsMu.Lock()
|
||||||
|
defer h.reportsMu.Unlock()
|
||||||
|
|
||||||
|
if h.reportsStopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.reports.Go(func() {
|
||||||
|
err := h.postReport(context.WithoutCancel(ctx), report)
|
||||||
|
if err != nil {
|
||||||
|
h.processLog.Warn("a report is dropped", "error", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// postReport posts report to servewaf, and waits for its answer for up to
|
||||||
|
// SWRP_TIMEOUT_MS.
|
||||||
|
func (h *handler) postReport(ctx context.Context, report *proxy.Report) error {
|
||||||
|
body, err := json.Marshal(report)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode the report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
|
||||||
|
ctx, http.MethodPost, h.reportURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("make the request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, err := h.serveWAFTransport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send the report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body of a 101 is the connection itself, and the time limit does not end
|
||||||
|
// a read of that.
|
||||||
|
if res.StatusCode >= http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("%w %s", errReportRefused, res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package smallwebwaf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunRProxy is `smallwebwaf rproxy`: it reads rproxy's settings, then serves
|
||||||
|
// requests until ctx is done. Then it gives the requests in progress
|
||||||
|
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
|
||||||
|
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
||||||
|
// messages name the host's name as instance, and it writes those at info or
|
||||||
|
// more severe: rproxy has no setting for either.
|
||||||
|
func RunRProxy(ctx context.Context, params Params) int {
|
||||||
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
|
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
|
||||||
|
|
||||||
|
cfg, err := config.RProxyFromEnvironment(params.LookupEnv)
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("invalid setting", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
server := rproxy.New(rproxy.Params{
|
||||||
|
Config: cfg,
|
||||||
|
RequestLog: params.Stdout,
|
||||||
|
ProcessLog: processLog,
|
||||||
|
})
|
||||||
|
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
processLog.Info("starting",
|
||||||
|
"version", params.Version,
|
||||||
|
"address", listener.Addr().String(),
|
||||||
|
"settings", cfg)
|
||||||
|
|
||||||
|
return serveRProxy(ctx, server, listener, processLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRProxy serves requests on listener until ctx is done, and then stops as
|
||||||
|
// RunRProxy says.
|
||||||
|
func serveRProxy(
|
||||||
|
ctx context.Context, server *rproxy.Server, listener net.Listener,
|
||||||
|
processLog *slog.Logger,
|
||||||
|
) int {
|
||||||
|
served := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
served <- server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-served:
|
||||||
|
processLog.Error("serving failed", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
processLog.Info("stopping")
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx),
|
||||||
|
shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := server.Shutdown(shutdownCtx)
|
||||||
|
if err != nil {
|
||||||
|
processLog.Warn("requests still in progress were cut off",
|
||||||
|
"error", err.Error())
|
||||||
|
|
||||||
|
_ = server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = <-served
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
processLog.Error("serving failed", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
server.WaitForReports()
|
||||||
|
processLog.Info("stopped")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
package smallwebwaf_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// wafBackend is the setting that names servewaf to rproxy.
|
||||||
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
|
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||||
|
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||||
|
// rproxyTimeout is the setting that says how long rproxy waits for servewaf.
|
||||||
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
|
// bannedProbe is a path the default rule file bans a client for.
|
||||||
|
bannedProbe = "/.env"
|
||||||
|
// otherClient is a client the tests do not ban.
|
||||||
|
otherClient = "198.51.100.7"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runRProxy runs `smallwebwaf rproxy` with the settings in env until ctx is
|
||||||
|
// done, and returns its exit status.
|
||||||
|
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
||||||
|
return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{
|
||||||
|
Version: testVersion,
|
||||||
|
LookupEnv: func(name string) (string, bool) {
|
||||||
|
value, ok := env[name]
|
||||||
|
|
||||||
|
return value, ok
|
||||||
|
},
|
||||||
|
Stdout: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRProxy runs rproxy with servewaf at backend and the app at appURL, and
|
||||||
|
// returns its URL and what it writes. It believes X-Forwarded-For from the
|
||||||
|
// tests. It stops as the test ends, and that it stopped in order is checked.
|
||||||
|
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
out := &output{}
|
||||||
|
exited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exited <- runRProxy(ctx, map[string]string{
|
||||||
|
rproxyListenAddr: localhost + ":0",
|
||||||
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
|
"SWRP_TRUSTED_PROXIES": localhost + "/32",
|
||||||
|
wafBackend: backend,
|
||||||
|
}, out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case status := <-exited:
|
||||||
|
if status != 0 {
|
||||||
|
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Error("rproxy still running after being told to stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
out.line(t, "msg", "stopped")
|
||||||
|
})
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
|
return "http://" + addr, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServeWAF runs servewaf with the settings in env, and returns its address
|
||||||
|
// and URL, what it writes, and what stops it, which checks that it stopped in
|
||||||
|
// order.
|
||||||
|
func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
out := &output{}
|
||||||
|
exited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exited <- runAs(ctx, true, env, out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
|
return addr, out, func() {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case status := <-exited:
|
||||||
|
if status != 0 {
|
||||||
|
t.Fatalf("servewaf's exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Fatal("servewaf still running after being told to stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWAFSettings are servewaf's settings with the default rule file, to listen
|
||||||
|
// at any free port.
|
||||||
|
func serveWAFSettings(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
listenAddr: localhost + ":0",
|
||||||
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
backend := "http://" + addr
|
||||||
|
first, firstOut := startRProxy(t, backend, startApp(t))
|
||||||
|
second, _ := startRProxy(t, backend, startApp(t))
|
||||||
|
|
||||||
|
wantStatus(t, first+"/", placed, http.StatusOK)
|
||||||
|
wantStatus(t, second+"/", placed, http.StatusOK)
|
||||||
|
|
||||||
|
// The default rule file bans a probe for this.
|
||||||
|
wantStatus(t, first+bannedProbe, placed, http.StatusForbidden)
|
||||||
|
wantStatus(t, second+"/", placed, http.StatusForbidden)
|
||||||
|
wantStatus(t, second+"/", otherClient, http.StatusOK)
|
||||||
|
|
||||||
|
// One request's three lines carry one id, rproxy's with servewaf's answer.
|
||||||
|
line := firstOut.line(t, "path", bannedProbe)
|
||||||
|
if line["disposition"] != proxy.DispositionDeny || line["client_ip"] != placed {
|
||||||
|
t.Errorf("rproxy's line is %v", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := line["request_id"].(string)
|
||||||
|
decision := serveWAFLine(t, serveWAFOut, id, "decision")
|
||||||
|
report := serveWAFLine(t, serveWAFOut, id, "report")
|
||||||
|
|
||||||
|
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
|
||||||
|
report["status"] != float64(http.StatusForbidden) {
|
||||||
|
t.Errorf("servewaf's lines are %v and %v", decision, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWAFLine waits for servewaf's line with request id and servewaf_line kind.
|
||||||
|
func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
line := out.findLine(t, func(fields map[string]any) bool {
|
||||||
|
return fields["request_id"] == id && fields["servewaf_line"] == kind
|
||||||
|
})
|
||||||
|
if line != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("no %s line for %s in:\n%s", kind, id, out.text())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := serveWAFSettings(t)
|
||||||
|
addr, _, stop := startServeWAF(t, env)
|
||||||
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
|
|
||||||
|
// A visitor who keeps asking throughout, and is never refused.
|
||||||
|
visitorDone := make(chan struct{})
|
||||||
|
visitorStop := make(chan struct{})
|
||||||
|
|
||||||
|
var refused atomic.Int64
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(visitorDone)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-visitorStop:
|
||||||
|
return
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
if !answersNormally(t.Context(), site+"/", otherClient) {
|
||||||
|
refused.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
||||||
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
|
stop()
|
||||||
|
|
||||||
|
// With servewaf down, the site answers, and rproxy says the default did it.
|
||||||
|
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||||
|
|
||||||
|
line := out.line(t, "path", "/after-stop")
|
||||||
|
if line["disposition"] != proxy.DispositionAllow ||
|
||||||
|
line["default_disposition"] != true {
|
||||||
|
t.Errorf("rproxy's line is %v", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back at its address, with its state files, it applies the ban again.
|
||||||
|
restarted := maps.Clone(env)
|
||||||
|
restarted[listenAddr] = addr
|
||||||
|
_, _, stop = startServeWAF(t, restarted)
|
||||||
|
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
|
|
||||||
|
close(visitorStop)
|
||||||
|
<-visitorDone
|
||||||
|
|
||||||
|
if refused.Load() != 0 {
|
||||||
|
t.Errorf("the site refused its visitor %d times", refused.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// answersNormally reports whether a request to url from the client at from, as
|
||||||
|
// X-Forwarded-For names it, gets the app's answer.
|
||||||
|
func answersNormally(ctx context.Context, url, from string) bool {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("X-Forwarded-For", from)
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
|
res, err := transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRaw sends request, as it is, to rproxy at site, and returns the status of
|
||||||
|
// the answer.
|
||||||
|
func sendRaw(t *testing.T, site, request string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp",
|
||||||
|
strings.TrimPrefix(site, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set the deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(conn, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the answer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
return res.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
site, _ := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
|
|
||||||
|
// Read as a further request, this body would ban otherClient on every site.
|
||||||
|
const smuggledID = "smuggled-request"
|
||||||
|
|
||||||
|
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
|
||||||
|
proxy.ClientHeader + ": " + otherClient + "\r\n" +
|
||||||
|
"X-Request-ID: " + smuggledID + "\r\n\r\n"
|
||||||
|
|
||||||
|
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
|
||||||
|
"X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+
|
||||||
|
strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status %d, want the app's 200", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read as a further request, it would act straight after the answer.
|
||||||
|
for range 20 {
|
||||||
|
wantStatus(t, site+"/", otherClient, http.StatusOK)
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := serveWAFOut.findLine(t, func(fields map[string]any) bool {
|
||||||
|
return fields["request_id"] == smuggledID
|
||||||
|
})
|
||||||
|
if line != nil {
|
||||||
|
t.Errorf("servewaf read the body as a request: %v", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATargetThatCannotGoToServeWAFIsRefusedForABannedClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
|
|
||||||
|
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
||||||
|
|
||||||
|
status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+
|
||||||
|
"X-Forwarded-For: "+placed+"\r\n\r\n")
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Errorf("status %d, want 400", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.line(t, "path", "admin")
|
||||||
|
if line["disposition"] != nil || line["default_disposition"] != nil {
|
||||||
|
t.Errorf("rproxy's line is %v, want no disposition", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const zoned = "2001:db8::1%eth0"
|
||||||
|
|
||||||
|
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
|
|
||||||
|
wantStatus(t, site+bannedProbe, zoned, http.StatusForbidden)
|
||||||
|
wantStatus(t, site+"/", zoned, http.StatusForbidden)
|
||||||
|
wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden)
|
||||||
|
|
||||||
|
line := out.line(t, "path", bannedProbe)
|
||||||
|
if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil {
|
||||||
|
t.Errorf("rproxy's line is %v", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const status = 799
|
||||||
|
|
||||||
|
app := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}))
|
||||||
|
t.Cleanup(app.Close)
|
||||||
|
|
||||||
|
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
site, out := startRProxy(t, "http://"+addr, app.URL)
|
||||||
|
|
||||||
|
wantStatus(t, site+"/", placed, status)
|
||||||
|
|
||||||
|
line := out.line(t, "path", "/")
|
||||||
|
if line["status"] != float64(status) {
|
||||||
|
t.Errorf("rproxy's line is %v, want the status the visitor got", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, _ := line["request_id"].(string)
|
||||||
|
|
||||||
|
report := serveWAFLine(t, serveWAFOut, id, "report")
|
||||||
|
if report["status"] != 599.0 {
|
||||||
|
t.Errorf("servewaf's line is %v, want status 599", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRProxyProcess runs `smallwebwaf rproxy` in a process of its own, with the
|
||||||
|
// settings in env, each as NAME=value. It returns where rproxy listens, the
|
||||||
|
// process, and what rproxy writes.
|
||||||
|
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
binary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find the test binary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
out := &output{}
|
||||||
|
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||||
|
cmd.Stdout = out
|
||||||
|
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
commandForMain+"=rproxy",
|
||||||
|
"SWRP_LISTEN_ADDR="+localhost+":0")
|
||||||
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
|
return addr, cmd, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
protocols := make(chan string, 10)
|
||||||
|
backend := httptest.NewUnstartedServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||||
|
protocols <- r.Proto
|
||||||
|
|
||||||
|
w.Header().Set(proxy.DispositionHeader, proxy.DispositionDeny)
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
backend.EnableHTTP2 = true
|
||||||
|
backend.StartTLS()
|
||||||
|
t.Cleanup(backend.Close)
|
||||||
|
|
||||||
|
// rproxy has no setting for the certificates it trusts, so it is told of this
|
||||||
|
// one as Go's client is told of any, in a process of its own.
|
||||||
|
certificates := filepath.Join(t.TempDir(), "servewaf.pem")
|
||||||
|
|
||||||
|
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
|
||||||
|
}), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write the certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, _, _ := startRProxyProcess(t,
|
||||||
|
wafBackend+"="+backend.URL,
|
||||||
|
"SWRP_UPSTREAM_URL="+startApp(t),
|
||||||
|
"SSL_CERT_FILE="+certificates)
|
||||||
|
|
||||||
|
for name, headers := range map[string]string{
|
||||||
|
"no such header": "",
|
||||||
|
"Upgrade": "Upgrade: anything\r\n",
|
||||||
|
"Connection": "Connection: anything\r\n",
|
||||||
|
"a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n",
|
||||||
|
} {
|
||||||
|
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Errorf("with %s: status %d, want servewaf's 403", name, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case protocol := <-protocols:
|
||||||
|
if protocol != "HTTP/1.1" {
|
||||||
|
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Errorf("with %s: servewaf was not asked", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyProcessLinesNameTheHostAndComeAtInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the host's name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing listens at the backend's address; no request asks it.
|
||||||
|
_, out := startRProxy(t, "http://127.0.0.1:9", startApp(t))
|
||||||
|
|
||||||
|
line := out.line(t, "msg", "starting")
|
||||||
|
if line["instance"] != hostname || line["level"] != "INFO" {
|
||||||
|
t.Errorf("rproxy's line is %v, want instance %s at INFO", line, hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reportGotThere := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get(proxy.ClientHeader) != "" {
|
||||||
|
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close(reportGotThere)
|
||||||
|
<-release
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
t.Cleanup(backend.Close)
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
rproxyListenAddr: localhost + ":0",
|
||||||
|
"SWRP_UPSTREAM_URL": startApp(t),
|
||||||
|
rproxyTimeout: "60000",
|
||||||
|
wafBackend: backend.URL,
|
||||||
|
}
|
||||||
|
out := &output{}
|
||||||
|
exited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exited <- runRProxy(ctx, env, out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
|
||||||
|
wantStatus(t, "http://"+addr+"/", placed, http.StatusOK)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-reportGotThere:
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Fatal("no report")
|
||||||
|
}
|
||||||
|
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-exited:
|
||||||
|
t.Error("rproxy stopped with a report on its way")
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
return
|
||||||
|
case <-time.After(10 * pollInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case status := <-exited:
|
||||||
|
if status != 0 {
|
||||||
|
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Error("rproxy still running, the report having been answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAndKeepOpen sends request to rproxy at addr, and returns the connection. A
|
||||||
|
// read from it ends with an error once waitLimit is up.
|
||||||
|
func sendAndKeepOpen(t *testing.T, addr, request string) net.Conn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set the deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(conn, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
cutOffAfter = 5 * time.Second
|
||||||
|
upstreamSetting = "SWRP_UPSTREAM_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
asked := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
app := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(http.ResponseWriter, *http.Request) {
|
||||||
|
close(asked)
|
||||||
|
<-release
|
||||||
|
}))
|
||||||
|
t.Cleanup(app.Close)
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set(proxy.DispositionHeader, proxy.DispositionAllow)
|
||||||
|
}))
|
||||||
|
t.Cleanup(backend.Close)
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
rproxyListenAddr: localhost + ":0",
|
||||||
|
upstreamSetting: app.URL,
|
||||||
|
wafBackend: backend.URL,
|
||||||
|
}
|
||||||
|
out := &output{}
|
||||||
|
exited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exited <- runRProxy(ctx, env, out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-asked:
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Fatal("the app was not asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := time.Now()
|
||||||
|
|
||||||
|
stop()
|
||||||
|
|
||||||
|
text, err := io.ReadAll(conn)
|
||||||
|
if err != nil || len(text) != 0 {
|
||||||
|
t.Errorf("read %q (%v), want the connection cut off", text, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if elapsed := time.Since(stopped); elapsed < cutOffAfter {
|
||||||
|
t.Errorf("cut off after %s, want %s", elapsed, cutOffAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case status := <-exited:
|
||||||
|
if status != 0 {
|
||||||
|
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Error("rproxy still running after the cut-off")
|
||||||
|
}
|
||||||
|
|
||||||
|
out.line(t, "msg", "requests still in progress were cut off")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, setting string
|
||||||
|
env map[string]string
|
||||||
|
}{
|
||||||
|
{"no backend", wafBackend, nil},
|
||||||
|
{
|
||||||
|
"another invalid", rproxyTimeout,
|
||||||
|
map[string]string{wafBackend: "http://servewaf:8080", rproxyTimeout: "soon"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
out := &output{}
|
||||||
|
|
||||||
|
if status := runRProxy(t.Context(), tc.env, out); status != 1 {
|
||||||
|
t.Errorf("exit status %d, want 1", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.line(t, "msg", "invalid setting")
|
||||||
|
message, _ := line["error"].(string)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(message, tc.setting+": ") {
|
||||||
|
t.Errorf("start refused with %v, want it to name %s", line, tc.setting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = taken.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
out := &output{}
|
||||||
|
|
||||||
|
status := runRProxy(t.Context(), map[string]string{
|
||||||
|
wafBackend: "http://servewaf:8080",
|
||||||
|
rproxyListenAddr: taken.Addr().String(),
|
||||||
|
}, out)
|
||||||
|
if status != 1 {
|
||||||
|
t.Errorf("exit status %d, want 1", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRunsRProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Nothing listens there; the health check does not ask.
|
||||||
|
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"http://"+addr+proxy.HealthPath, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
|
res, err := transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("health check: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if err != nil || string(body) != "ok\n" {
|
||||||
|
t.Errorf("health check answered %q (%v)", body, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
||||||
// the rule files, the lookup database and the state files, serves requests
|
// 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
|
// 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
|
package smallwebwaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -45,11 +48,17 @@ type Params struct {
|
|||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
// Stdout receives the request log and the process's own messages.
|
// Stdout receives the request log and the process's own messages.
|
||||||
Stdout io.Writer
|
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
|
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
|
||||||
// process's exit status. Run as `smallwebwaf healthcheck`, it is the
|
// process's exit status. Run as `smallwebwaf servewaf`, it answers
|
||||||
// container's health check instead.
|
// 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 {
|
func Main(version string) int {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||||
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
|
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
|
||||||
@@ -59,11 +68,18 @@ func Main(version string) int {
|
|||||||
syscall.SIGTERM, os.Interrupt)
|
syscall.SIGTERM, os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
return Run(ctx, Params{
|
params := Params{
|
||||||
Version: version,
|
Version: version,
|
||||||
LookupEnv: os.LookupEnv,
|
LookupEnv: os.LookupEnv,
|
||||||
Stdout: os.Stdout,
|
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
|
// 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
|
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 {
|
if err != nil {
|
||||||
processLog.Error("cannot use the lookup database", "error", err.Error())
|
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)
|
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServer returns the server smallwebwaf runs, with the metrics of the
|
// newServer returns the server smallwebwaf runs, which newProxy makes, with
|
||||||
// alerts, after reading the lookup database while SWWAF_LOOKUP_SOURCE is
|
// the metrics of the alerts, after reading the lookup database while
|
||||||
// file. A lookup database that cannot be read is an error.
|
// SWWAF_LOOKUP_SOURCE is file. A lookup database that cannot be read is an
|
||||||
|
// error.
|
||||||
func newServer(
|
func newServer(
|
||||||
|
newProxy func(proxy.Params) *proxy.Server,
|
||||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
) (*proxy.Server, error) {
|
) (*proxy.Server, error) {
|
||||||
@@ -174,7 +197,7 @@ func newServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
server := newProxy(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: stdout,
|
RequestLog: stdout,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
|
|||||||
@@ -76,21 +76,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
|||||||
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
o.mu.Lock()
|
fields := o.findLine(t, func(fields map[string]any) bool {
|
||||||
text := o.buf.String()
|
return fields[key] == value
|
||||||
o.mu.Unlock()
|
})
|
||||||
|
if fields != nil {
|
||||||
for line := range strings.Lines(text) {
|
return fields
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
@@ -101,6 +91,29 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
|
|||||||
return nil
|
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.
|
// text returns everything written so far.
|
||||||
func (o *output) text() string {
|
func (o *output) text() string {
|
||||||
o.mu.Lock()
|
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,
|
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
||||||
// so that no test sends GeoJS its clients' addresses.
|
// so that no test sends GeoJS its clients' addresses.
|
||||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
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{
|
return smallwebwaf.Run(ctx, smallwebwaf.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
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
|
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": "",
|
"SWWAF_ALLOW_NETS": "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
"SWWAF_DENY_NETS": "",
|
"SWWAF_DENY_NETS": "",
|
||||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "6000",
|
"SWWAF_RATE_LIMIT_PER_MINUTE": "500",
|
||||||
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
|
"SWWAF_RATE_LIMIT_PER_HOUR": "20000",
|
||||||
rateLimitPerDay: "1000000",
|
rateLimitPerDay: "300000",
|
||||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWWAF_DENIED_COUNTRIES": "",
|
"SWWAF_DENIED_COUNTRIES": "",
|
||||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user