6 Commits
Author SHA1 Message Date
clawbot 17c5dd2438 rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Successful in 7m25s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over
HTTP/1.1, and passes what servewaf allows to the app within its limits. Any
other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
decides only when servewaf cannot be connected to, does not answer at all, or
does not answer within SWRP_TIMEOUT_MS. A method SPEC.md does not list gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; gosec at
internal/smallwebwaf/rproxy_test.go:431; tagliatelle at
internal/requestlog/requestlog.go:203; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 200, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
2026-11-05 06:32:08 +01:00
clawbot 4a4874ae2f servewaf always answers rproxy well within its time limit (closes #182)
check / check (push) Successful in 8m31s
A question waits on GeoJS at most 800ms, the new default of
SWWAF_LOOKUP_TIMEOUT. In enforce mode it does not wait at all when it is
over the header limits, on a fetched blocklist with deny, on the CrowdSec
decision list, or over a whole rate limit. Not waiting never lowers a
client's limit. Just before the Core Rule Set, servewaf refuses more than
50 cookies or 100 header fields with 431.

Shared default changed: SWWAF_LOOKUP_TIMEOUT also applies to smallwebwaf
with no command.
Judgement call: SWWAF_REPUTATION_TIMEOUT stays 2s, since no request waits
on the DNS blocklists or AbuseIPDB.

Model: opus-5-5
2026-11-05 05:48:50 +01:00
clawbot 2eeea136e8 servewaf answers rproxy's question about each request and takes its report (closes #170)
check / check (push) Failing after 14m16s
`smallwebwaf servewaf` runs today's checks on the visitor's request each
question carries, answers rproxy, and passes nothing to an app. rproxy reports
every request servewaf answered. A report counts an allowed request's bytes and
writes servewaf's second log line. The table holds a copy of what a report
needs, never the request, so a report cannot reach a request being handled.

Judgement call: a question's body is read before any check; over 64 KiB gets 400.
Judgement call: a report's bytes and durations have ceilings, so they cannot overflow.
Judgement call: a `close` disposition for SWWAF_BAN_RESPONSE=close.

Model: opus-5-5
2026-11-05 00:32:08 +01:00
clawbot 718a2c94b2 Default request limits per minute, hour and day, sized for API use (closes #176)
check / check (push) Failing after 17m45s
The default request limits now suit programmatic use of an API, such
as an automation fleet on another host, and still catch abusive bots:

- per minute: 500, room for a fleet's busiest minute and a few first
  visits with an empty cache, and passed within seconds by a client
  firing as fast as it can;
- per hour: 20000, passed by a client that keeps up 6 a second;
- per day: 300000, passed by a scraper that keeps up 4 a second.

The byte limits and ban lengths are unchanged. SPEC.md, README.md and
docs/settings.md give the reasons; docs/gitea.md redoes the Actions
runner sums.

Judgement call: the docs/alerts.md ban example shows the minute limit.

Model: opus-5-5
2026-11-04 23:02:39 +01:00
clawbot fdabf4974b Health check test uses addresses nothing can connect to (closes #158)
check / check (push) Canceled after 0s
TestHealthCheck closed the app's server, and later smallwebwaf, and
expected connections to the freed ports to fail. Another test could take
such a port in between, so the connection succeeded and the test failed.

The failing app check now uses the multicast address 224.0.0.1, to which
TCP cannot connect. The failing smallwebwaf check uses port 0, on which
nothing listens. Neither result depends on other processes. The test now
stops smallwebwaf after its last check, so that it exits before the
test's temporary directories are removed. The code under test is
unchanged.

Model: opus-5-5
2026-10-27 21:00:16 +01:00
clawbot a9dc59546b New defaults for the request and byte limits (closes #161)
check / check (push) Canceled after 0s
The request limits and the hour and day byte limits now sit about twice
above what 40 people busy with heavy web apps make together on one
address:

- requests: 6000 a minute, 100,000 an hour, 1,000,000 a day;
- bytes: 20 GiB an hour, 200 GiB a day.

The largest response drops from 5 GiB to 1 GiB. The minute byte limit
of 4 GiB is sized to hold two of the largest responses with their
requests. SPEC.md gives the new reasoning; README.md and the reference
documents give the new figures.

Model: opus-5-5
2026-10-27 19:28:09 +01:00
55 changed files with 10141 additions and 300 deletions
+30 -11
View File
@@ -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 1000 requests a minute, 10,000 an hour and 50,000 a day. Byte limits client 500 requests a minute, 20,000 an hour and 300,000 a day. That leaves
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a room for programmatic use of an API, such as an automation fleet on another
limit is refused and banned. The first ban lasts an hour. Each repeat within a host. A fleet of about 20 agents makes about 120 requests a minute at its
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`.
+541 -27
View File
@@ -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.
@@ -368,16 +378,39 @@ Conventions:
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the - `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the
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 clients share one address (mobile carriers, offices, Tor), and so share - Many people can share one address, as in an office, on a mobile carrier or
its limits and its bans. `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS` through Tor. They are one client, and share its limits and its bans. A known
take known shared addresses out. shared address busier than the limits allow belongs in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
### Request rate limits, per client ### Request rate limits, per client
Breaking one bans the client, so the defaults sit several times above what one Breaking one bans the client. The defaults suit programmatic use of an API, such
busy person produces. A browser loading a heavy page makes a few hundred as an automation fleet on another host, and still stop abusive bots. A fleet of
requests, and a git clone a handful. A machine that talks to the app all day can 20 agents, each making a request every 10 seconds at its busiest, makes 120
still pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
a day. It stays under all three limits.
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
a minute and a few quick first visits with an empty cache. A gitea page opened
with an empty cache loads about 40 files. A client firing requests as fast as
it can passes the limit within seconds.
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
under the minute limit. The fleet's 7,200 an hour is about a third of it.
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and
14,400 an hour, it stays under the other two limits.
Counting cannot catch a client that stays under all three limits, such as one
that keeps up 3 requests a second. Only the other checks can catch an abuser
that slow. They include the trap paths, the rule files, the Core Rule Set and
the blocklists. An abuser that trips none of them is not caught. A machine that
keeps up more than 500 requests a minute passes the minute limit. One that keeps
up more than about 5.5 a second for an hour passes the hour limit. One that
keeps up more than about 3.5 a second all day passes the day limit, as two busy
Actions runners on one address do. Such a machine's address belongs in
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as `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
@@ -390,9 +423,12 @@ matched the same way.
### Byte limits, per client ### Byte limits, per client
A response's bytes are counted when it ends (see step 15). So every default sits The hour and day limits sit about twice above what 40 people busy with heavy web
above the largest request and answer together, and no single download breaks a apps move together on one address. Each moves about 256 MiB an hour, for ten
limit. hours a day. A response's bytes are counted when it ends (see step 15). So the
minute limit holds two of the largest responses, with their requests, ending in
the same minute. No single download then breaks a limit. An operator who raises
`SWWAF_RESPONSE_MAX_BYTES` raises the byte limits with it.
### Size and time limits ### Size and time limits
@@ -996,6 +1032,485 @@ 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 some of the visitor's headers, such as:
- Go's client does not send an empty `User-Agent`.
- Go's server adds `Cache-Control: no-cache` to a request with
`Pragma: no-cache` and no `Cache-Control`, before `rproxy` sees it.
`rproxy` itself refuses any request it cannot send this way (see "Requests
`rproxy` refuses itself"). So a question always has:
- a path starting with one `/`;
- a `Host` that is not empty and holds no IPv6 zone;
- at most one `User-Agent`.
A body whose length the visitor did not announce comes chunked. Any other body
comes with a `Content-Length` of the part it carries.
`rproxy` sets these headers, replacing any the visitor sent, and adds no others:
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out,
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,
with the header limits just before step 12 (see "Answering in time"). 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;
- `431`, for a request over the header limits;
- `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.
### Answering in time
`rproxy` stops waiting for `servewaf` after `SWRP_TIMEOUT_MS`, by default 2
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
`servewaf` answers a request well within that time, whatever the request and
whatever its outside sources do.
GeoJS is the only outside source a question can wait on. A question waits only
when step 5 says so, and for at most `SWWAF_LOOKUP_TIMEOUT`, by default 800
milliseconds. A client without an answer by then counts as unknown. No question
waits on these, which run in the background:
- the DNS blocklists and AbuseIPDB;
- fetching the blocklists, the CrowdSec decision list and the file
`SWWAF_ASN_LIMIT_PERCENT_URL` names.
In `enforce` mode, a question does not wait for GeoJS when it is a request:
- over the header limits;
- from a client on a fetched blocklist, while `SWWAF_BLOCKLIST_ACTION` is
`deny`;
- from a client on the CrowdSec decision list;
- that would take its client over a rate limit at 100% of it, since a percentage
can only lower a limit.
Such a request still goes through the steps in their order. Its client's AS
number and country stay unknown until GeoJS has answered. So while
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first.
`SWWAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
never lowers its limit.
The static lists and bans come before the lookup, so they never wait. These can
still wait for GeoJS:
- a country list, which needs the answer;
- a rate limit a percentage has lowered, which needs the answer;
- a DNS blocklist or AbuseIPDB, while `SWWAF_REPUTATION_ACTION` is `deny`;
- a trap path;
- a rule file;
- the Core Rule Set.
The header limits are fixed, with no setting. `servewaf` refuses a request over
either one:
- more than 50 cookies. Each part of its `Cookie` headers between semicolons
counts as one, unless it is empty or only spaces and tabs.
- more than 100 header fields. A header sent twice counts twice. `Host` and
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
Real clients never reach these limits. The answer is `deny` with `431`, and the
log line's action is `too_large`. The refusal makes no ban and is no offence.
The rate limits count the request as usual, so a client that keeps sending such
requests breaks a limit.
The limits apply on every path and in every `SWWAF_WAF_MODE`. A client in
`SWWAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
such a request is answered `allow` with `would_action`, and the Core Rule Set
does not read it.
The Core Rule Set runs each of its rules on each cookie, so thousands of small
cookies would keep it busy for over a second. Within the limits, one long cookie
or query string can still keep it busy for a fraction of a second. So enough
such requests at once can overload `servewaf` past `SWRP_TIMEOUT_MS`.
### 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. Apart from what Go's HTTP code changes, the request reaches the app
unchanged but for these headers:
- `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that
peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass
on.
- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own
claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host`
the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers
in which traefik tells an app about a visitor are removed.
- `X-Request-ID`, which carries the fresh id.
- `X-Client-ASN` and `X-Client-Country`, which carry what `servewaf` answered. A
header it did not give is left out. Any of the two the visitor sent is
removed.
- The headers that concern only the connection to `rproxy` never reach the app:
- `Connection`, and any header it names;
- `Keep-Alive`;
- `Proxy-Authenticate`;
- `Proxy-Authorization`;
- `Proxy-Connection`;
- `Te`, though one holding `trailers` goes on as `Te: trailers`;
- `Upgrade`.
A chunked body's trailers reach the app, but not `servewaf`.
A WebSocket upgrade passes through, with `Connection: Upgrade` and its
`Upgrade`. The report of a WebSocket comes when it closes, with what it carried
each way.
### `rproxy`'s limits
The limits work as "Size and time limits" says, with these settings in place of
those:
- `SWRP_MAX_REQUEST_SIZE` for `SWWAF_REQUEST_MAX_BYTES`.
- `SWRP_MAX_RESPONSE_SIZE` for `SWWAF_RESPONSE_MAX_BYTES`.
- `SWRP_MAX_REQUEST_TIME_MS` for `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`.
- `SWRP_MAX_RESPONSE_TIME_MS` for `SWWAF_CLIENT_RESPONSE_TIMEOUT` and
`SWWAF_UPSTREAM_RESPONSE_TIMEOUT`.
The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting.
`SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their end,
it then bounds the time until the app has been sent the whole request. The wait
for `servewaf` counts toward it. A visitor too slow to send the first 64 KiB is
answered `408`, with no question and no report.
`SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte of
the answer reaching the visitor. The request ends when the app has been sent all
of it, or when the app's answer arrives if that is sooner. An answer `rproxy`
gives itself has the whole time from when it starts.
`rproxy` applies the request size limit only after `servewaf` has allowed the
request. So a request `servewaf` denies gets `servewaf`'s answer, whatever its
size. A request `servewaf` allows is answered `413` when its body is over the
limit: at once for a length it announced, otherwise when the limit is reached.
If part of the body has gone to the app by then, the request to the app is cut
off.
### Starting and stopping
An invalid setting, or an address it cannot listen on, stops the start with a
process line. The process lines give the host's name as `instance`, and only
those at `info` or more severe are written.
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
progress five seconds to finish. It then waits for the reports on their way,
each for up to `SWRP_TIMEOUT_MS`. A report that comes due after that is dropped,
such as that of a WebSocket that closes later.
## Deployment ## 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
@@ -1161,8 +1676,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
+4 -2
View File
@@ -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 (
+11
View File
@@ -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
View File
@@ -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 1000", "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": 1000, "limit": 500,
"window": "minute", "window": "minute",
"count": 1001, "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": 5210, "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 1000` 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.
+13 -8
View File
@@ -11,8 +11,10 @@ checked again when gitea or the Core Rule Set changes version.
- SSH access to gitea does not pass through `smallwebwaf` and is not protected - SSH access to gitea does not pass through `smallwebwaf` and is not protected
by it. by it.
- A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all - A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
but the largest repositories and slowest links. repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it
("Byte limits, per client" in `SPEC.md`).
- A push is a request, and so is every other upload: - A push is a request, and so is every other upload:
- a git push; - a git push;
- an LFS object; - an LFS object;
@@ -30,12 +32,15 @@ checked again when gitea or the Core Rule Set changes version.
request limits do most of the work there. request limits do most of the work there.
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all - An Actions runner that reaches gitea through `smallwebwaf` sends requests all
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, and reports a running job's log and state every second. 43,200 requests a day. While it runs a job, it reports the job's log and state
So it passes the day limit of 50,000 after about an hour and a quarter of every second instead, two requests a second. So one runner busy all day sends
jobs. It is then banned, the job it is running fails, and each repeat within a 120 requests a minute and 172,800 a day, under the request limits. Two such
day makes the ban longer. Newer runners ask less often, but a busy one, or runners on one address, each busy all day, make 345,600 and pass the day limit
several on one address, can still pass it. Put the runners' addresses in of 300,000. Three busy at once make 21,600 an hour and pass the hour limit of
`SWWAF_RATE_LIMIT_EXEMPT_NETS`. 20,000. Five busy at once make 600 a minute and pass the minute limit of 500.
Their address is then banned, their running jobs fail, and each repeat within
a day makes the ban longer. Newer runners ask for work less often. Put the
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through ## What the Core Rule Set lets through
+13 -1
View File
@@ -17,7 +17,8 @@ label as `exported_instance` unless the scrape sets `honor_labels: true`.
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`, - `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
`bytes` or `refusals`); `bytes` or `refusals`);
- `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting passed; - `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting passed.
`servewaf`'s header limits have no setting, so it leaves them out;
- `smallwebwaf_offences_total`, by `kind`; - `smallwebwaf_offences_total`, by `kind`;
- `smallwebwaf_bans_made_total`, by `cause`; - `smallwebwaf_bans_made_total`, by `cause`;
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. - `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
@@ -79,3 +80,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.
+103 -2
View File
@@ -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;
@@ -67,7 +68,8 @@ Every line has:
CrowdSec decision, each of which bans the client; CrowdSec decision, each of which bans the client;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
refused by that check; refused by that check;
- `too_large` and `timed_out`: a size or time limit was passed; - `too_large`: a size limit was passed, or a header limit in `servewaf`;
- `timed_out`: a time limit was passed;
- `upstream_error`: the app could not be reached, or its answer broke off; - `upstream_error`: the app could not be reached, or its answer broke off;
- `admin`: answered at one of `smallwebwaf`'s own endpoints. - `admin`: answered at one of `smallwebwaf`'s own endpoints.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
@@ -117,3 +119,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"}
```
+87 -8
View File
@@ -99,16 +99,28 @@ 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 `1000`), `SWWAF_RATE_LIMIT_PER_HOUR` - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most requests a client may make in a minute. A count or `off`. The default catches,
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/`.
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most (default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
bytes counted for a client in each window. A size or `off`. bytes counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: - `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`. `response`, `request` or `both`.
@@ -131,7 +143,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
to send its whole answer. A duration or `off`. to send its whole answer. A duration or `off`.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
or `off`. or `off`.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size - `SWWAF_RESPONSE_MAX_BYTES` (default `1G`): the largest response body. A size
or `off`. or `off`.
## Lookup of AS number and country ## Lookup of AS number and country
@@ -145,7 +157,7 @@ that take `off`, the `_FILE` form, and what each group does and why.
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its - `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the - `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the
@@ -327,4 +339,71 @@ 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;
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
fields (see "Answering in time" under "`servewaf`" in
[`SPEC.md`](../SPEC.md));
- `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)).
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
asked in the background (see "Answering in time" under "`servewaf`" in
[`SPEC.md`](../SPEC.md)).
## `rproxy`
`smallwebwaf rproxy` reads the settings below and no others, and nothing else
reads them. They are read once at start, and an invalid value stops the start.
None can be given as a file, since `rproxy` never touches disk. "`rproxy`" in
[`SPEC.md`](../SPEC.md) says what they do.
A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
`servewaf` cannot be connected to, does not answer at all, or does not answer
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
`503`. It does not apply to an answer `servewaf` gives, such as an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
`off`.
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
send its request line and headers. It then also bounds, from the end of the
headers, the time until the app has been sent the whole request. A time or
`off`.
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
from the end of the request to its last byte reaching the visitor. A time or
`off`.
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
`https` URL with a host and an optional port, and nothing more.
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
nothing.
+11 -4
View File
@@ -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())
} }
+3 -1
View File
@@ -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{
+3
View File
@@ -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,
+13 -11
View File
@@ -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 (
@@ -502,21 +504,21 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"), UpstreamRequestTimeout: env.duration("SWWAF_UPSTREAM_REQUEST_TIMEOUT", "60s"),
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"), UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"), RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "1G"),
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", "1000"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"), 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", "10G"), 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"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "50G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "1s"), LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries( ExclusivelyAllowedCountries: env.countries(
+15 -15
View File
@@ -261,10 +261,10 @@ func TestDefaults(t *testing.T) {
UpstreamRequestTimeout: time.Minute, UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute, UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20, RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 5 << 30, ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 1000, RateLimitPerMinute: 500,
RateLimitPerHour: 10000, RateLimitPerHour: 20000,
RateLimitPerDay: 50000, RateLimitPerDay: 300000,
BanResponse: 403, BanResponse: 403,
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
@@ -281,11 +281,11 @@ func TestDefaults(t *testing.T) {
RulesEnabled: true, RulesEnabled: true,
}) })
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
LookupSource: defaultLookupSource, LookupTimeout: time.Second, LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
}) })
wantByteLimitSettings(t, cfg, config.Config{ wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 10 << 30, BytesLimitPerHour: 20 << 30, BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 50 << 30, BytesCount: "both", BytesLimitPerDay: 200 << 30, BytesCount: "both",
}) })
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" { if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -1165,7 +1165,7 @@ func TestLookupSettingsAsSet(t *testing.T) {
cfg = fromEnvironment(t, environment{lookupSource: off}) cfg = fromEnvironment(t, environment{lookupSource: off})
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
LookupSource: off, LookupTimeout: time.Second, LookupSource: off, LookupTimeout: 800 * time.Millisecond,
}) })
} }
@@ -2371,21 +2371,21 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamRequestTimeout: "60s", upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m", upstreamResponseTimeout: "30m",
requestMaxBytes: "100M", requestMaxBytes: "100M",
responseMaxBytes: "5G", responseMaxBytes: "1G",
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "1000", rateLimitPerMinute: "500",
rateLimitPerHour: "10000", rateLimitPerHour: "20000",
rateLimitPerDay: "50000", rateLimitPerDay: "300000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "10G", bytesLimitPerMinute: "4G",
bytesLimitPerHour: "20G", bytesLimitPerHour: "20G",
bytesLimitPerDay: "50G", bytesLimitPerDay: "200G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
lookupTimeout: "1s", lookupTimeout: "800ms",
addLookupHeaders: "false", addLookupHeaders: "false",
deniedCountries: "", deniedCountries: "",
allowedCountries: "", allowedCountries: "",
+167
View File
@@ -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
}
+206
View File
@@ -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)
}
}
}
+12 -4
View File
@@ -179,7 +179,7 @@ func New(params Params) *GeoJS {
// own address for IPv4, and an address in the same place for an IPv6 // own address for IPv4, and an address in the same place for an IPv6
// netblock. // netblock.
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer { func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
answer, asked := g.answerOrWait(ctx, client) answer, asked := g.answerOrWait(ctx, client, g.wait)
if asked == nil { if asked == nil {
return answer return answer
} }
@@ -209,6 +209,14 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
return answer return answer
} }
// LookUpWithoutWaiting is LookUp for a request that does not need the answer,
// whatever Wait says: it returns the zero Answer at once when none is kept.
func (g *GeoJS) LookUpWithoutWaiting(ctx context.Context, client netip.Prefix) Answer {
answer, _ := g.answerOrWait(ctx, client, false)
return answer
}
// Kept returns client's answer, if one is kept, without asking GeoJS. // Kept returns client's answer, if one is kept, without asking GeoJS.
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) { func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
g.mu.Lock() g.mu.Lock()
@@ -263,9 +271,9 @@ func (g *GeoJS) Load(answers []Answer) {
// answerOrWait returns client's kept answer if it has one. Otherwise it // answerOrWait returns client's kept answer if it has one. Otherwise it
// puts the client among those waiting if there is room, has GeoJS asked // puts the client among those waiting if there is room, has GeoJS asked
// about them if it can be, and returns what to wait on for the answer, or // about them if it can be, and returns what to wait on for the answer, or
// nil when there is nothing to wait for. // nil when there is nothing to wait for or mustWait is false.
func (g *GeoJS) answerOrWait( func (g *GeoJS) answerOrWait(
ctx context.Context, client netip.Prefix, ctx context.Context, client netip.Prefix, mustWait bool,
) (Answer, <-chan struct{}) { ) (Answer, <-chan struct{}) {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
@@ -283,7 +291,7 @@ func (g *GeoJS) answerOrWait(
g.ask(ctx) g.ask(ctx)
if !g.wait { if !mustWait {
return Answer{}, nil // the answer is not needed before the request goes on return Answer{}, nil // the answer is not needed before the request goes on
} }
+35 -2
View File
@@ -72,6 +72,39 @@ func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
}) })
} }
func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
client := netip.MustParsePrefix("203.0.113.9/32")
// Wait is set, and GeoJS does not answer.
geojs.set(hanging)
began := time.Now()
answer := g.LookUpWithoutWaiting(t.Context(), client)
if waited := time.Since(began); waited != 0 || answer != (lookup.Answer{}) {
t.Errorf("got %+v after %s, want no answer at once", answer, waited)
}
waitForRequests(t, geojs, 1)
wantAsked(t, geojs, 0, "203.0.113.9")
// That request is abandoned, and has failed once this returns. GeoJS
// answers when it is asked again, and the answer is kept.
time.Sleep(timeout)
synctest.Wait()
geojs.set(answering)
waitForCountry(t, g, clock, client, germany)
if got := g.LookUpWithoutWaiting(t.Context(), client).Country; got != germany {
t.Errorf("the kept answer gives the country %q, want %q", got, germany)
}
})
}
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) { func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
t.Parallel() t.Parallel()
@@ -123,8 +156,8 @@ func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// A timeout longer than the default second, and a GeoJS that does // A timeout longer than the default, and a GeoJS that does not
// not answer. // answer.
const longerTimeout = 3 * time.Second const longerTimeout = 3 * time.Second
m := metrics.New(1, "app") m := metrics.New(1, "app")
+38 -18
View File
@@ -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)
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 != "" { // RequestAllowed counts a question of rproxy's, answered allow, as no longer
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc() // under way. RequestReported counts the request it was about.
func (m *Metrics) RequestAllowed() {
m.inFlight.Dec()
} }
if line.Country != "" { // RequestReported counts a request rproxy passed to the app, as RequestEnded
m.countries.add(line.Country, line) // does, from what its report says, which line holds.
} func (m *Metrics) RequestReported(
line *requestlog.Line, duration, upstreamDuration time.Duration,
if line.ASN != "" { ) {
m.asns.add(line.ASN, line) m.count(line, "", duration, upstreamDuration)
}
} }
// 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) {
+9
View File
@@ -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
+3 -1
View File
@@ -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() {
+600
View File
@@ -0,0 +1,600 @@
package proxy_test
import (
"fmt"
"maps"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"strconv"
"strings"
"testing"
"testing/synctest"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// servewaf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
// cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie"
// tooLarge is how servewaf refuses a request over the header limits.
tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that unansweredGeoJSURL names too.
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
// wholeLimit is the percentage of a rate limit that lowers it not at all.
wholeLimit = 100
// clientsDecision is the CrowdSec decision list holding client alone.
clientsDecision = `[{"duration": "4h0m0s", "scenario": "crowdsecurity/ssh-bf", ` +
`"scope": "Ip", "type": "ban", "value": "` + client + `"}]`
)
// cookies returns a Cookie header's value with count cookies.
func cookies(count int) string {
parts := make([]string, 0, count)
for i := range count {
parts = append(parts, "c"+strconv.Itoa(i)+"=1")
}
return strings.Join(parts, "; ")
}
// fillHeaders adds headers to a question until the checks see total header
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's
// client sends nothing else but Host, which the limit does not count, once
// User-Agent is set.
func fillHeaders(req *http.Request, total int) {
req.Header.Set("User-Agent", "test")
for i := len(req.Header) - 2; i < total; i++ {
req.Header.Set("X-Filler-"+strconv.Itoa(i), "1")
}
}
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
began := time.Now()
got := do(t, req)
if took := time.Since(began); took >= answerTimeLimit/2 {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
}
wantDisposition(t, got, tooLarge, proxy.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
if line.DurationWAF != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
}
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Parallel()
repeated := make([]string, headerFieldLimit)
for i := range repeated {
repeated[i] = "1"
}
for _, tc := range []struct {
name string
make func(*http.Request)
// refused is true for a request over a limit, and false for one at it.
refused bool
}{
{"cookies at the limit", func(req *http.Request) {
req.Header.Set(cookieHeader, cookies(cookieLimit))
}, false},
{"cookies over the limit", func(req *http.Request) {
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
}, true},
{"parts empty or only spaces and tabs are no cookies", func(req *http.Request) {
req.Header.Set(cookieHeader, cookies(cookieLimit)+"; \t;;")
}, false},
{"parts of no-break spaces count", func(req *http.Request) {
req.Header.Set(cookieHeader, strings.Repeat(" ;", cookieLimit+1))
}, true},
{"cookies over the limit across two headers", func(req *http.Request) {
req.Header[cookieHeader] = []string{cookies(cookieLimit), "last=1"}
}, true},
{"cookies without a value count", func(req *http.Request) {
req.Header.Set(cookieHeader, strings.Repeat("a;", cookieLimit+1))
}, true},
{"header fields at the limit", func(req *http.Request) {
fillHeaders(req, headerFieldLimit)
}, false},
{"header fields over the limit", func(req *http.Request) {
fillHeaders(req, headerFieldLimit+1)
}, true},
{"a header sent many times counts each time", func(req *http.Request) {
req.Header["X-Filler"] = repeated
}, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/")
tc.make(req)
got := do(t, req)
line := s.out.requestLine(t)
if tc.refused {
wantDisposition(t, got, tooLarge, proxy.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
return
}
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationWAF == nil {
t.Error("the Core Rule Set did not read a request within the limits")
}
})
}
}
func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
t.Parallel()
for _, env := range []map[string]string{
{wafMode: off},
{wafMode: "detect"},
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
} {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", env)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny)
})
}
}
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
}
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
began := time.Now()
got := do(t, req)
if took := time.Since(began); took >= answerTimeLimit/2 {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
}
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
t.Errorf("would_action %q and duration_waf %v, want %q and none",
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
}
}
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
ask := func() answer {
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
return do(t, req)
}
wantDisposition(t, ask(), tooLarge, proxy.DispositionDeny)
wantDisposition(t, ask(), http.StatusForbidden, proxy.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
}
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, path, action string
}{
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
})
req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
})
}
}
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
if line.LimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
}
// The client is under no ban, and GeoJS places it in a country.
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, proxy.DispositionAllow)
}
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
Answered: s.clock.Now(), Used: s.clock.Now(),
}})
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
}
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != asnDE || country != "DE" {
t.Errorf("answered with the AS number %q and the country %q, want %q and DE",
asn, country, asnDE)
}
}
func TestWithoutACommandTheHeaderLimitsDoNotApply(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, nil)
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
fillHeaders(req, headerFieldLimit+1)
wantStatus(t, do(t, req), http.StatusOK)
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
}
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
// answers, a setting needs its answer, and everything is at its default but
// for env. No request to it may use the network.
func newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *output) {
t.Helper()
settings := map[string]string{deniedCountries: "KP"}
maps.Copy(settings, env)
server, out, _ := newServerWith(t, proxy.NewServeWAF, "http://app.invalid",
unansweredGeoJSURL, time.Now, settings)
return server, out
}
// askDirectly hands a question about a request from client straight to server,
// as change leaves it, and returns the answer's status and how long it took.
func askDirectly(
t *testing.T, server *proxy.Server, change func(*http.Request),
) (int, time.Duration) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(proxy.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id")
change(req)
recorder := httptest.NewRecorder()
began := time.Now()
server.Handler.ServeHTTP(recorder, req)
return recorder.Code, time.Since(began)
}
// banClient bans client at now.
func banClient(_ *testing.T, server *proxy.Server, now time.Time) {
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
}
// countClient counts one request from client at now for the rate limits.
func countClient(_ *testing.T, server *proxy.Server, now time.Time) {
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
}
// listClient puts client on the blocklist at dropURL.
func listClient(t *testing.T, server *proxy.Server, now time.Time) {
t.Helper()
err := server.Lists.Load([]reputation.List{{
URL: dropURL, Tried: now, Fetched: now, Lines: []string{client},
}})
if err != nil {
t.Fatalf("load the blocklist: %v", err)
}
}
// decideOnClient puts client on the CrowdSec decision list.
func decideOnClient(t *testing.T, server *proxy.Server, now time.Time) {
t.Helper()
loadDecisions(t, server, now, clientsDecision)
}
// leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *proxy.Server, time.Time) {}
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Parallel()
const forbidden = http.StatusForbidden
for _, tc := range []struct {
name string
env map[string]string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *proxy.Server, now time.Time)
// cookie is the request's Cookie header.
cookie string
status int
action string
}{
{
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
},
{
name: "a ban",
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
},
{
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
},
{
name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
},
{
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
},
{
name: "the CrowdSec decision list",
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
},
{
name: "the header limits", cookie: cookies(cookieLimit + 1),
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) {
server, out := newServeWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set(cookieHeader, tc.cookie)
})
if status != tc.status || took != 0 {
t.Errorf("answered %d after %s, want %d at once; output:\n%s",
status, took, tc.status, out.text())
}
wantLine(t, out.requestLine(t), tc.status, tc.action)
// GeoJS's request is abandoned before the bubble ends.
time.Sleep(defaultLookupWait)
})
})
}
}
func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *proxy.Server, now time.Time)
status int
action string
}{
{"nothing refuses it", nil, leaveAsItIs, http.StatusOK, requestlog.ActionForward},
{
"a country list needs the answer", map[string]string{allowedCountries: "DE"},
leaveAsItIs, http.StatusForbidden, requestlog.ActionCountryDenied,
},
{
"a blocklist that only logs",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionLog},
listClient, http.StatusOK, requestlog.ActionForward,
},
{
"a blocklist that only limits",
map[string]string{blocklistURLs: dropURL, blocklistAction: limitQuarter},
listClient, http.StatusOK, requestlog.ActionForward,
},
{
"over a rate limit on an exempt path",
map[string]string{rateLimitPerMinute: "1", rateLimitExemptPaths: "/"},
countClient, http.StatusOK, requestlog.ActionForward,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
server, out := newServeWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(*http.Request) {})
if status != tc.status || took != defaultLookupWait {
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
status, took, tc.status, defaultLookupWait, out.text())
}
wantLine(t, out.requestLine(t), tc.status, tc.action)
})
})
}
}
func TestWithoutACommandARefusedRequestStillWaitsForGeoJS(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
time.Now, map[string]string{deniedCountries: "KP", blocklistURLs: dropURL})
listClient(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) {
req.RemoteAddr = net.JoinHostPort(client, "1234")
})
if status != http.StatusForbidden || took != defaultLookupWait {
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
status, took, http.StatusForbidden, defaultLookupWait, out.text())
}
wantLine(t, out.requestLine(t), http.StatusForbidden, requestlog.ActionDenied)
})
}
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
t.Parallel()
held, askedGeoJS, _ := startHeldGeoJS(t)
// A resolver that takes every query and answers none.
resolver, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = resolver.Close() })
server, out, _ := newServerWith(t, func(params proxy.Params) *proxy.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL
return proxy.NewServeWAF(params)
}, "http://app.invalid", held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
crowdSecURL: held, crowdSecKey: bouncerKey,
})
// AbuseIPDB is asked only about a client with an offence.
group := netip.MustParsePrefix(client + "/32")
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
go server.Lists.Run(t.Context())
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set("User-Agent", "test")
req.Header.Set("Accept", "*/*")
req.Host = siteA
})
if status != http.StatusOK || took >= answerTimeLimit {
t.Errorf("answered %d after %s, want %d within %s; output:\n%s",
status, took, http.StatusOK, answerTimeLimit, out.text())
}
// Each outside source was asked, and none has answered. The stand-in for
// GeoJS is the lists' too, which name no address.
if !slices.Contains(askedGeoJS(), client) {
t.Errorf("GeoJS was asked about %q, not %s", askedGeoJS(), client)
}
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
}
if checked := server.AbuseIPDB.Checked(); checked != 1 {
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
}
}
+5 -4
View File
@@ -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,
} }
} }
+3 -1
View File
@@ -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()
+8 -8
View File
@@ -34,19 +34,19 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file // percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one // SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, // country, unless its request skipped the wait for GeoJS,
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a // SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, // percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the // a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT // percentage of SWWAF_REPUTATION_ACTION while it is limit. For the byte
// take the place of the first three for an AS number or a country they // limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the
// list. // place of the first three for an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country asn, country := rq.line.ASN, rq.line.Country
unknown := percentage{percent: whole} unknown := percentage{percent: whole}
if country == "" { if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"} unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
} }
+1 -1
View File
@@ -284,7 +284,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL, server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
time.Now, map[string]string{unknownLimitPercent: "0"}) time.Now, map[string]string{unknownLimitPercent: "0"})
// Once the second the request waits for its answer is up, the client // Once the time the request waits for its answer is up, the client
// counts as without a country, and its zero allowance refuses the // counts as without a country, and its zero allowance refuses the
// request before it reaches the app. // request before it reaches the app.
serveFromDE(t, server, http.MethodGet, http.NoBody) serveFromDE(t, server, http.MethodGet, http.NoBody)
+1
View File
@@ -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,
+3 -3
View File
@@ -284,9 +284,9 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// each in an AS of its own, and no other address. It returns its URL, and // each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs // what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an // the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default // hour, whether or not a request waits for the answer: on the default, a
// second, a hold-up of the test process can abandon the request to the // hold-up of the test process can abandon the request to the stand-in, and
// stand-in, and leave the client unknown. // leave the client unknown.
func startGeoJS(t *testing.T) (string, func() []string) { func startGeoJS(t *testing.T) (string, func() []string) {
t.Helper() t.Helper()
+15 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/netip" "net/netip"
"time"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
) )
@@ -23,17 +24,24 @@ const (
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or // SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
// country list or a biased threshold, a new client's request waits for it. // country list or a biased threshold, a new client's request waits for it,
// ctx is the request's own context. // unless refusedWhateverTheAnswer reports true. ctx is the request's own
func (rq *request) lookUp(ctx context.Context) { // context.
func (rq *request) lookUp(ctx context.Context, now time.Time) {
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) { if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
return return
} }
if rq.h.config.LookupSource == "file" { client := rq.h.clientGroup(rq.client)
rq.lookupAnswer = rq.h.lookupFile.LookUp(rq.h.clientGroup(rq.client))
} else { switch {
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, rq.h.clientGroup(rq.client)) case rq.h.config.LookupSource == "file":
rq.lookupAnswer = rq.h.lookupFile.LookUp(client)
case rq.h.answersQuestions() && rq.refusedWhateverTheAnswer(now):
rq.lookupAnswer = rq.h.geojs.LookUpWithoutWaiting(ctx, client)
rq.skippedLookupWait = rq.lookupAnswer.Answered.IsZero()
default:
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, client)
} }
rq.lookedUp = true rq.lookedUp = true
+3 -3
View File
@@ -319,7 +319,7 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{ addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
addLookupHeaders: "true", addLookupHeaders: on,
}) })
s := &sender{t: t, addr: addr, out: out} s := &sender{t: t, addr: addr, out: out}
@@ -383,8 +383,8 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// network, which would keep that clock from moving on: the request is // network, which would keep that clock from moving on: the request is
// handed to the proxy's handler, and GeoJS is one that never answers. // handed to the proxy's handler, and GeoJS is one that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// Not the default second. The exclusive list needs the answer, and // Not the default. The exclusive list needs the answer, and the app
// the app is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL, server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
+3 -3
View File
@@ -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",
}) })
@@ -424,8 +424,8 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS after a second, so GeoJS's // The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so
// failure can come after its request has ended. // GeoJS's failure can come after its request has ended.
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, addr) metrics := scrape(t, addr)
+52 -33
View File
@@ -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,
}
}
+14 -1
View File
@@ -91,6 +91,9 @@ const (
// off is the value that switches a setting off. // off is the value that switches a setting off.
const off = "off" const off = "off"
// on is the value that switches a setting that takes true or false on.
const on = "true"
// output collects what smallwebwaf writes on stdout. // output collects what smallwebwaf writes on stdout.
type output struct { type output struct {
mu sync.Mutex mu sync.Mutex
@@ -284,6 +287,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 +347,7 @@ func newProxy(
} }
} }
server := proxy.New(proxy.Params{ server := build(proxy.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
ProcessLog: processLog, ProcessLog: processLog,
+1
View File
@@ -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,
+109 -47
View File
@@ -39,25 +39,29 @@ 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.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
line requestlog.Line line requestlog.Line
client netip.Addr client netip.Addr
peer netip.Addr // requestURI is the path with the query, as a ban's notes keep it.
peerTrusted bool requestURI string
// site is, in servewaf, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. It is "" otherwise.
site string
// allowed is true for a question answered with DispositionAllow.
allowed bool
// lookedUp is true once the client's AS number and country have been // 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.
lookedUp bool lookedUp bool
lookupAnswer lookup.Answer lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits.
skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes // counted is true for a request the rate limits counted, whose bytes
// the byte limits count once it has ended. limitPercent and // the byte limits count once it has ended. limitPercent and
// bytesPercent are then its client's limit percentages for the rate // bytesPercent are then its client's limit percentages for the rate
@@ -65,17 +69,36 @@ 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
// notes of a ban the request makes.
reputation []bans.ReputationHit
start time.Time start time.Time
// checked is when the checks were done, and upstreamStart when the // checked is when the checks were done, and upstreamStart when the
// request was handed to the app. // request was handed to the app.
@@ -108,28 +131,32 @@ 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{
h: h, decision: decision{
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
client: client, client: client,
peer: peer, requestURI: r.URL.RequestURI(),
peerTrusted: peerTrusted,
start: start,
line: requestlog.Line{ line: requestlog.Line{
Time: requestlog.FormatTime(start), Time: requestlog.FormatTime(start),
Instance: h.config.InstanceName, Instance: h.config.InstanceName,
@@ -152,6 +179,14 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
HasCookie: len(r.Header.Values("Cookie")) > 0, HasCookie: len(r.Header.Values("Cookie")) > 0,
Action: requestlog.ActionForward, Action: requestlog.ActionForward,
}, },
},
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
peer: peer,
peerTrusted: peerTrusted,
start: start,
} }
// A length of -1 is a body whose length was not announced. // A length of -1 is a body whose length was not announced.
@@ -191,12 +226,12 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
// is known, before anything reaches the app, and before its body is read, // is known, before anything reaches the app, and before its body is read,
// but for the part the Core Rule Set reads. It returns nil to let the // but for the part the Core Rule Set reads. It returns nil to let the
// request through. The checks of checkClient come first, answered with // request through. The checks of checkClient come first, answered with
// SWWAF_BAN_RESPONSE, or 403 for a block rule or the Core Rule Set, and // SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, or 431 for
// then the size limit, so that a request the rate limits count is counted // servewaf's header limits, and then the size limit, so that a request the
// even when it is refused for its size. In observe mode a request // rate limits count is counted even when it is refused for its size. In
// checkClient refuses goes on to the size limit like any other. A size or // observe mode a request checkClient refuses goes on to the size limit like
// time limit the Core Rule Set's reading of the body meets ends the // any other. A size or time limit the Core Rule Set's reading of the body
// request in either mode. ctx is the request's own context. // meets ends the request in either mode. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) action := rq.checkClient(ctx)
@@ -212,6 +247,8 @@ func (rq *request) check(ctx context.Context) *refusal {
rq.line.WouldAction = action rq.line.WouldAction = action
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked: case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
default: default:
return rq.banResponse(action) return rq.banResponse(action)
} }
@@ -240,9 +277,10 @@ func (rq *request) check(ctx context.Context) *refusal {
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the // rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that // request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit // every other request is counted, each of them by the client's limit
// percentages, then SWWAF_TRAP_PATHS, then the rule files, and last the // percentages, then SWWAF_TRAP_PATHS, then the rule files, then, in
// Core Rule Set. A request exempt from the rate limits is exempt from the // servewaf, the header limits, and last the Core Rule Set. A request exempt
// byte limits too. ctx is the request's own context. // from the rate limits is exempt from the byte limits too. ctx is the
// request's own context.
func (rq *request) checkClient(ctx context.Context) string { func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) { if isInside(rq.client, cfg.AllowNets) {
@@ -259,7 +297,7 @@ func (rq *request) checkClient(ctx context.Context) string {
return requestlog.ActionBanned return requestlog.ActionBanned
} }
rq.lookUp(ctx) rq.lookUp(ctx, now)
// The lookup can wait for GeoJS, so the checks after it read the time // The lookup can wait for GeoJS, so the checks after it read the time
// again: a request counted at the time it arrived could be counted // again: a request counted at the time it arrived could be counted
@@ -283,8 +321,7 @@ func (rq *request) checkClient(ctx context.Context) string {
return requestlog.ActionDenied return requestlog.ActionDenied
} }
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) && rq.counted = rq.countedByRateLimits()
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
if rq.counted { if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages() rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged() rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
@@ -304,9 +341,23 @@ func (rq *request) checkClient(ctx context.Context) string {
return action return action
} }
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet() return rq.checkCoreRuleSet()
} }
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
}
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its // SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
// path as sent, the path the app receives, not percent-decoded, starts // path as sent, the path the app receives, not percent-decoded, starts
@@ -569,10 +620,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:
// 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) 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 +695,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,
+527
View File
@@ -0,0 +1,527 @@
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
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
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)
}
// answersQuestions reports whether h is servewaf, which alone holds requests
// for their reports.
func (h *handler) answersQuestions() bool {
return h.awaitingReport != nil
}
// refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: it is over the header limits, a blocklist lists its client while
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// over a whole rate limit. In observe mode, which refuses nothing, none is.
// rproxy lets a request through when servewaf answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe {
return false
}
lists := rq.h.lists
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || rq.overHeaderLimits() ||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
(rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether servewaf refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
if !rq.h.answersQuestions() {
return false
}
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
}
// 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
+24
View File
@@ -228,6 +228,30 @@ func (l *Limiter) Count(
return l.count(client, now, 1, 0, percent) return l.count(client, now, 1, 0, percent)
} }
// OverARateLimit reports whether one more request from client at now would
// take it over a whole rate limit. A client's percentage can only lower a
// limit, so that request is over the limit at any percentage. It counts
// nothing, and does not make the client the most recently seen.
func (l *Limiter) OverARateLimit(client netip.Prefix, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
c, seen := l.clients.Peek(client)
if !seen {
return false
}
buckets := c.buckets()
for i, w := range l.windows {
if w.limit > 0 && buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) {
return true
}
}
return false
}
// CountBytes counts bytes, those of a request from client that has ended, // CountBytes counts bytes, those of a request from client that has ended,
// at now, in every window, and returns the client's counts in each window. // at now, in every window, and returns the client's counts in each window.
// It reports whether the bytes take the client over a byte limit, of which // It reports whether the bytes take the client over a byte limit, of which
+44
View File
@@ -26,6 +26,50 @@ const (
hour = "hour" hour = "hour"
) )
func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing.T) {
t.Parallel()
client := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit + 1},
tableSize)
if limiter.OverARateLimit(client, now) {
t.Error("a client never seen is over a rate limit")
}
if limiter.Len() != 0 {
t.Error("asking about a client never seen added it to the table")
}
for range limit {
if limiter.OverARateLimit(client, now) {
t.Error("a client with room for another request is over a rate limit")
}
wantCount(t, limiter, client, now, "")
}
if !limiter.OverARateLimit(client, now) {
t.Error("a client at its limit is not over a rate limit")
}
held, _ := limiter.Client(client)
if held.Minute.Current != limit {
t.Errorf("the client's minute holds %d requests, want %d", held.Minute.Current, limit)
}
wantCount(t, limiter, client, now, minute)
// A limit that is off is never gone over.
off := ratelimit.New(ratelimit.Limits{}, tableSize)
off.Count(client, now, whole)
if off.OverARateLimit(client, now) {
t.Error("a client is over a rate limit that is off")
}
}
func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) { func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
t.Parallel() t.Parallel()
+61 -4
View File
@@ -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 (
@@ -18,7 +19,8 @@ import (
const ( const (
// ActionForward is a request passed to the app. // ActionForward is a request passed to the app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request or response over its size limit. // ActionTooLarge is a request or response over its size limit, or, in
// servewaf, a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionTimedOut is a request or response that ran out of time. // ActionTimedOut is a request or response that ran out of time.
ActionTimedOut = "timed_out" ActionTimedOut = "timed_out"
@@ -60,6 +62,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 +82,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.
@@ -126,7 +141,8 @@ type Line struct {
// WouldAction is, in observe mode, the action enforce mode would have // WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied, // taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied, ActionRateLimited, // ActionBanned, ActionCountryDenied, ActionRateLimited,
// ActionRuleBlocked or ActionWAFBlocked. // ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
// for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// LimitPercent and LimitPercentSetting are, for a request the rate // LimitPercent and LimitPercentSetting are, for a request the rate
// limits counted whose client a biased threshold gives a percentage of // limits counted whose client a biased threshold gives a percentage of
@@ -180,10 +196,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)
+22
View File
@@ -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()
+211
View File
@@ -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
}
+122
View File
@@ -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")
}
+811
View File
@@ -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)
}
})
}
}
+477
View File
@@ -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)
}
})
}
}
+593
View File
@@ -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()
}
}
+228
View File
@@ -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
+266
View File
@@ -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
}
+13 -6
View File
@@ -60,8 +60,19 @@ func TestHealthCheck(t *testing.T) {
}, 0, "") }, 0, "")
} }
app.Close() // Another test can take a closed server's port at once, so the app's
wantHealthCheck(t, env, 1, "unhealthy: connect to the app: ") // address here is a multicast address, to which TCP cannot connect.
wantHealthCheck(t, map[string]string{
listenAddr: ":" + port,
upstreamURL: "http://224.0.0.1",
}, 1, "unhealthy: connect to the app: ")
// Listening on port 0 takes any free port, so nothing listens on port 0
// itself.
wantHealthCheck(t, map[string]string{listenAddr: ":0"}, 1,
"unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
stop() stop()
@@ -70,10 +81,6 @@ func TestHealthCheck(t *testing.T) {
case <-time.After(waitLimit): case <-time.After(waitLimit):
t.Fatal("still running after being told to stop") t.Fatal("still running after being told to stop")
} }
wantHealthCheck(t, env, 1, "unhealthy: ask smallwebwaf: ")
wantHealthCheck(t, map[string]string{listenAddr: "8080"}, 1,
"unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
} }
func TestHealthCheckRefusesAnArgument(t *testing.T) { func TestHealthCheckRefusesAnArgument(t *testing.T) {
+99
View File
@@ -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
}
+787
View File
@@ -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())
}
}
+210
View File
@@ -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))
}
})
}
+33 -10
View File
@@ -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,
+35 -14
View File
@@ -76,11 +76,29 @@ 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 {
return fields
}
for line := range strings.Lines(text) { time.Sleep(pollInterval)
}
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text())
return nil
}
// findLine returns the first line written so far for which matches is true, or
// nil.
func (o *output) findLine(
t *testing.T, matches func(map[string]any) bool,
) map[string]any {
t.Helper()
for line := range strings.Lines(o.text()) {
var fields map[string]any var fields map[string]any
err := json.Unmarshal([]byte(line), &fields) err := json.Unmarshal([]byte(line), &fields)
@@ -88,16 +106,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
t.Fatalf("output line %q is not JSON: %v", line, err) t.Fatalf("output line %q is not JSON: %v", line, err)
} }
if fields[key] == value { if matches(fields) {
return fields return fields
} }
} }
time.Sleep(pollInterval)
}
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.text())
return nil return nil
} }
@@ -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) {
@@ -124,6 +144,7 @@ func run(ctx context.Context, env map[string]string, out *output) int {
return value, ok return value, ok
}, },
Stdout: out, Stdout: out,
ServeWAF: serveWAF,
}) })
} }
@@ -1240,13 +1261,13 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s", "SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m", "SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M", "SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G", "SWWAF_RESPONSE_MAX_BYTES": "1G",
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000", "SWWAF_RATE_LIMIT_PER_MINUTE": "500",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000", "SWWAF_RATE_LIMIT_PER_HOUR": "20000",
rateLimitPerDay: "50000", 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": "",