Compare commits
3
Commits
main
...
ed6ba53747
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6ba53747 | ||
|
|
fdabf4974b | ||
|
|
a9dc59546b |
@@ -17,10 +17,10 @@ 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 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte
|
||||||
give it 10 GiB a minute, 20 GiB an hour and 50 GiB a day. A client over a
|
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over
|
||||||
limit is refused and banned. The first ban lasts an hour. Each repeat within a
|
a limit is refused and banned. The first ban lasts an hour. Each repeat within
|
||||||
day makes the next ban three times as long. A ban that would last more than
|
a day makes the next ban three times as long. A ban that would last more than
|
||||||
seven days is permanent.
|
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
|
||||||
@@ -339,6 +339,9 @@ pinned.
|
|||||||
[`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)).
|
||||||
- `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`.
|
||||||
@@ -351,6 +354,8 @@ what each does.
|
|||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes:
|
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes:
|
||||||
|
- `smallwebwaf rproxy`, the reverse proxy in front of each app that asks
|
||||||
|
`smallwebwaf servewaf` about each request;
|
||||||
- sharing bans between the instances of several apps;
|
- sharing bans between the instances of several apps;
|
||||||
- sending the log over RELP.
|
- sending the log over RELP.
|
||||||
|
|
||||||
|
|||||||
@@ -47,14 +47,16 @@ 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 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/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,
|
||||||
@@ -368,16 +370,23 @@ 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. The
|
||||||
take known shared addresses out.
|
default limits are sized for about 40 busy people on one address. A known
|
||||||
|
shared address busier than that belongs in `SWWAF_ALLOW_NETS` or
|
||||||
|
`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, so the defaults sit about twice above what 40
|
||||||
busy person produces. A browser loading a heavy page makes a few hundred
|
people busy with heavy web apps make together on one address. Each such person
|
||||||
requests, and a git clone a handful. A machine that talks to the app all day can
|
makes about 75 requests in a busy minute, and 1,250 an hour for ten hours a day.
|
||||||
still pass the day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
|
||||||
|
A client slower than those 40 people cannot be told from them by counting. Only
|
||||||
|
the other checks can catch an abuser that slow. They include the trap paths, the
|
||||||
|
rule files, the Core Rule Set and the blocklists. An abuser that trips none of
|
||||||
|
them is not caught. A machine that talks to the app all day can still pass the
|
||||||
|
day limit. Its address belongs in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||||
|
|
||||||
`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 +399,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
|
Each of the same 40 people moves about 256 MiB an hour, for ten hours a day. The
|
||||||
above the largest request and answer together, and no single download breaks a
|
hour and day limits sit about twice above what they move together. A response's
|
||||||
limit.
|
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 +1008,156 @@ 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.
|
||||||
|
|
||||||
|
It 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.
|
||||||
|
|
||||||
|
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` removes these headers if the visitor sent them, then sets them. It adds
|
||||||
|
no other header:
|
||||||
|
|
||||||
|
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out.
|
||||||
|
`servewaf` takes it as the client, and groups an IPv6 address as "What happens
|
||||||
|
to a request" says.
|
||||||
|
- `X-Smallwebwaf-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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
### The answer
|
||||||
|
|
||||||
|
`servewaf` runs steps 3 to 12 of "What happens to a request", in that order.
|
||||||
|
Every answer but the last below carries `X-Smallwebwaf-Disposition`.
|
||||||
|
|
||||||
|
- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the
|
||||||
|
request to the app. The answer also carries:
|
||||||
|
- `X-Request-ID`: the id `rproxy` passes to the app, logs and names in the
|
||||||
|
report;
|
||||||
|
- `X-Client-ASN` and `X-Client-Country`, with `SWWAF_ADD_LOOKUP_HEADERS`,
|
||||||
|
which `rproxy` adds to the request it passes on. A header whose value is
|
||||||
|
unknown is left out.
|
||||||
|
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the
|
||||||
|
visitor is to get. `rproxy` gives them to the visitor, less this header. The
|
||||||
|
status is one of:
|
||||||
|
- `SWWAF_BAN_RESPONSE`, for a refusal;
|
||||||
|
- `403`, for a `block` rule or the Core Rule Set;
|
||||||
|
- `404`, for a path under `/_smallwebwaf/` that passes the checks, so that
|
||||||
|
the app never sees that prefix.
|
||||||
|
- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the
|
||||||
|
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
|
||||||
|
visitor's connection without an answer.
|
||||||
|
- No such header, status `400`, when `X-Smallwebwaf-Client` is not an address.
|
||||||
|
`rproxy` treats this answer as it treats no answer at all.
|
||||||
|
|
||||||
|
The `X-Request-ID` of an `allow` is the question's id, unless `servewaf` has to
|
||||||
|
make a new one. A visitor behind a trusted proxy can choose its id, so it makes
|
||||||
|
a new one when the question's id is:
|
||||||
|
|
||||||
|
- absent;
|
||||||
|
- the id of another request awaiting its report;
|
||||||
|
- not valid UTF-8, which a JSON report cannot carry.
|
||||||
|
|
||||||
|
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 reads the body a question carries only while
|
||||||
|
`SWWAF_WAF_BODY_LIMIT` is set. It reads at most 64 KiB less one byte. So it does
|
||||||
|
not read a JSON or XML body of 64 KiB or more, which may have been cut short.
|
||||||
|
|
||||||
|
### The report
|
||||||
|
|
||||||
|
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
|
||||||
|
for each request it passed to the app on an `allow`. The report carries no
|
||||||
|
`X-Smallwebwaf-Client`. Its body is a JSON object with these fields, named as
|
||||||
|
the request log's fields of the same meaning:
|
||||||
|
|
||||||
|
- `request_id`: the `X-Request-ID` of the `allow`;
|
||||||
|
- `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`: milliseconds from the request's headers being read to the
|
||||||
|
end of the answer;
|
||||||
|
- `duration_upstream_total`: milliseconds from the hand-over to the app to the
|
||||||
|
end of the answer.
|
||||||
|
|
||||||
|
`servewaf` answers `204`, also for a request it does not hold, which it ignores.
|
||||||
|
It answers `400` to a body that is not such an object, has a negative byte
|
||||||
|
count, or is over 1 MiB. `rproxy` does not act on the answer.
|
||||||
|
|
||||||
|
For a request it holds, `servewaf` then:
|
||||||
|
|
||||||
|
- 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, and writes a process message naming
|
||||||
|
the request, since the request's log line has gone out;
|
||||||
|
- adds the request with its status to the client's history and the anomaly
|
||||||
|
counters.
|
||||||
|
|
||||||
|
`servewaf` holds each allowed request until its report comes, up to 10,000
|
||||||
|
requests. Past that, the request asked about longest ago is dropped. A request
|
||||||
|
whose report never comes is in neither the client's history nor the metrics, and
|
||||||
|
its bytes are not counted. A denied request is finished at its decision.
|
||||||
|
|
||||||
|
### `servewaf`'s own endpoints
|
||||||
|
|
||||||
|
A request without `X-Smallwebwaf-Client` is for `servewaf` itself, at its own
|
||||||
|
address on the private network. Nothing routes the internet there. These 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.
|
||||||
|
|
||||||
|
`servewaf` gives each request 60 seconds to arrive and its answer 60 seconds to
|
||||||
|
leave, and 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/request-log.md` gives `servewaf`'s log line, and `docs/metrics.md` how its
|
||||||
|
metrics count.
|
||||||
|
|
||||||
## 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,9 +1323,10 @@ 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: `smallwebwaf rproxy`, the reverse proxy in front of each app that
|
||||||
|
asks `servewaf` about each request (see "`servewaf`").
|
||||||
- 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
|
||||||
reconsidered once real ban volumes are known. Until then each instance keeps
|
reconsidered once real ban volumes are known. Until then each instance keeps
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says which checks they go through
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says which checks they go through
|
||||||
and how their tokens work.
|
and how their tokens work. `smallwebwaf servewaf` answers them at its own
|
||||||
|
address, without those checks (see "`servewaf`" in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
||||||
|
|||||||
+9
-6
@@ -42,7 +42,7 @@ on one line:
|
|||||||
"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 6000",
|
||||||
"detail": {
|
"detail": {
|
||||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
||||||
"cause": "limit",
|
"cause": "limit",
|
||||||
@@ -51,9 +51,9 @@ on one line:
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"kind": "requests",
|
"kind": "requests",
|
||||||
"limit": 1000,
|
"limit": 6000,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
"count": 1001,
|
"count": 6001,
|
||||||
"request": {
|
"request": {
|
||||||
"time": "2026-10-06T12:00:00.123456789Z",
|
"time": "2026-10-06T12:00:00.123456789Z",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -62,7 +62,7 @@ on one line:
|
|||||||
"status": 403,
|
"status": 403,
|
||||||
"user_agent": "scraper/1.0"
|
"user_agent": "scraper/1.0"
|
||||||
},
|
},
|
||||||
"requests": 5210,
|
"requests": 8210,
|
||||||
"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 6000` 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.
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -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,13 @@ 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
|
172,800 requests, under the request limits. Six such runners on one address,
|
||||||
day makes the ban longer. Newer runners ask less often, but a busy one, or
|
each busy all day, pass the day limit of 1,000,000 within two days; five stay
|
||||||
several on one address, can still pass it. Put the runners' addresses in
|
under it. Their address is then banned, their running jobs fail, and each
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in
|
|||||||
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
|
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
|
||||||
label as `exported_instance` unless the scrape sets `honor_labels: true`.
|
label as `exported_instance` unless the scrape sets `honor_labels: true`.
|
||||||
|
|
||||||
|
`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. A report itself is not counted, and neither is a request whose
|
||||||
|
report never comes.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||||
|
|||||||
@@ -117,3 +117,36 @@ 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 line
|
||||||
|
|
||||||
|
`smallwebwaf servewaf` writes one line for each question, once it has answered
|
||||||
|
it, and one for each request to its own endpoints but a report. A report writes
|
||||||
|
no line. A question's line 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 id `servewaf` answers with on `allow`.
|
||||||
|
- `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 the question's headers being read to the line.
|
||||||
|
|
||||||
|
The line leaves out `upstream_status`, `websocket` and the
|
||||||
|
`duration_upstream_...` fields, since `servewaf` passes nothing to the app.
|
||||||
|
`rproxy` holds the visitor's status, the body bytes and the times of the app's
|
||||||
|
answer. The request id joins the two.
|
||||||
|
|
||||||
|
The bytes of an allowed request are counted when its report comes, after its
|
||||||
|
line. So `counts` holds the client's bytes without this request's. A report that
|
||||||
|
breaks a byte limit writes a process message instead of `limit_hit` on the line:
|
||||||
|
`a report's bytes broke a byte limit`, with `request_id`, `client_ip`, `host`,
|
||||||
|
`limit_hit` and `ban_expires`.
|
||||||
|
|||||||
+25
-7
@@ -4,6 +4,23 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
|||||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||||
that take `off`, the `_FILE` form, and what each group does and why.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
|
## `servewaf`
|
||||||
|
|
||||||
|
`smallwebwaf servewaf` uses every setting here 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)).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
||||||
@@ -99,16 +116,16 @@ 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 `6000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
||||||
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the
|
||||||
requests a client may make in each window. A count or `off`.
|
most requests a client may make in each window. A count or `off`.
|
||||||
- `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 +148,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
|
||||||
@@ -327,4 +344,5 @@ 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 allowed requests awaiting their reports.
|
||||||
|
|||||||
@@ -150,13 +150,16 @@ type Params struct {
|
|||||||
|
|
||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||||
// Country are, for a ban, the client's as the ban's notes give them.
|
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||||
|
// the site of the request that raised the alert, while one servewaf serves
|
||||||
|
// many sites, and "" otherwise.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
Client netip.Addr `json:"client"`
|
Client netip.Addr `json:"client"`
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
ASN string `json:"asn"`
|
ASN string `json:"asn"`
|
||||||
@@ -899,12 +902,16 @@ func title(alert *Alert) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// text returns the text of alert in Slack and ntfy: its reason, then a
|
// text returns the text of alert in Slack and ntfy: its reason, then a
|
||||||
// line for each of its client, netblock and country, the file, source,
|
// line for each of its host, client, netblock and country, the file,
|
||||||
// error and mode its detail gives, and its suppressed repeats, that it
|
// source, error and mode its detail gives, and its suppressed repeats, that
|
||||||
// has.
|
// it has.
|
||||||
func text(alert *Alert) string {
|
func text(alert *Alert) string {
|
||||||
lines := []string{alert.Reason}
|
lines := []string{alert.Reason}
|
||||||
|
|
||||||
|
if alert.Host != "" {
|
||||||
|
lines = append(lines, "host: "+alert.Host)
|
||||||
|
}
|
||||||
|
|
||||||
if alert.Client.IsValid() {
|
if alert.Client.IsValid() {
|
||||||
lines = append(lines, "client: "+alert.Client.String())
|
lines = append(lines, "client: "+alert.Client.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1284,9 +1284,11 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
Reason: "refused by the Core Rule Set",
|
Reason: "refused by the Core Rule Set",
|
||||||
|
Host: "app.example",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: waf_block", "default shield",
|
"fsn1app1/gitea: waf_block", "default shield",
|
||||||
"refused by the Core Rule Set\nclient: 192.0.2.1\nnetblock: 192.0.2.1/32",
|
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||||
|
"netblock: 192.0.2.1/32",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alerts.Alert{
|
alerts.Alert{
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ type Counter struct {
|
|||||||
|
|
||||||
// Request is a request that has ended, as the counters count it.
|
// Request is a request that has ended, as the counters count it.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
// Host is the site the alert names, "" for none.
|
||||||
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
||||||
@@ -396,6 +398,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
|||||||
|
|
||||||
return alerts.Alert{
|
return alerts.Alert{
|
||||||
Event: alerts.EventAnomaly,
|
Event: alerts.EventAnomaly,
|
||||||
|
Host: r.Host,
|
||||||
Client: r.Client,
|
Client: r.Client,
|
||||||
Netblock: k.netblock,
|
Netblock: k.netblock,
|
||||||
ASN: r.ASN,
|
ASN: r.ASN,
|
||||||
|
|||||||
@@ -502,17 +502,17 @@ 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", "6000"),
|
||||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
|
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "100000"),
|
||||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
|
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "1000000"),
|
||||||
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", ""),
|
||||||
|
|||||||
@@ -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: 6000,
|
||||||
RateLimitPerHour: 10000,
|
RateLimitPerHour: 100000,
|
||||||
RateLimitPerDay: 50000,
|
RateLimitPerDay: 1000000,
|
||||||
BanResponse: 403,
|
BanResponse: 403,
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: 24 * time.Hour,
|
LimitBanRepeatWindow: 24 * time.Hour,
|
||||||
@@ -284,8 +284,8 @@ func TestDefaults(t *testing.T) {
|
|||||||
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
LookupSource: defaultLookupSource, LookupTimeout: time.Second,
|
||||||
})
|
})
|
||||||
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" {
|
||||||
@@ -2371,17 +2371,17 @@ 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: "6000",
|
||||||
rateLimitPerHour: "10000",
|
rateLimitPerHour: "100000",
|
||||||
rateLimitPerDay: "50000",
|
rateLimitPerDay: "1000000",
|
||||||
rateLimitExemptPaths: "",
|
rateLimitExemptPaths: "",
|
||||||
bytesLimitPerMinute: "10G",
|
bytesLimitPerMinute: "4G",
|
||||||
bytesLimitPerHour: "20G",
|
bytesLimitPerHour: "20G",
|
||||||
bytesLimitPerDay: "50G",
|
bytesLimitPerDay: "200G",
|
||||||
bytesCount: "both",
|
bytesCount: "both",
|
||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
|
|||||||
+38
-18
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
|
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||||
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||||
@@ -237,7 +239,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
{"403", http.StatusForbidden},
|
{"403", http.StatusForbidden},
|
||||||
{"429", http.StatusTooManyRequests},
|
{"429", http.StatusTooManyRequests},
|
||||||
{"close", 0},
|
{banResponseClose, 0},
|
||||||
} {
|
} {
|
||||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
|||||||
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventWAFBlock,
|
Event: alerts.EventWAFBlock,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptNets: scraper,
|
rateLimitExemptNets: scraper,
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
banResponse: "close",
|
banResponse: banResponseClose,
|
||||||
limitBanDuration: "1h",
|
limitBanDuration: "1h",
|
||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|||||||
+49
-32
@@ -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 one request log
|
||||||
// unchanged, within the size and time limits, and writes one request log
|
// line for it. New passes each request it lets through to the app and the
|
||||||
// line for each request.
|
// app's answer back, unchanged, within the size and time limits. NewServeWAF
|
||||||
|
// instead answers rproxy's question about each request, and takes rproxy's
|
||||||
|
// report once the visitor's answer has ended.
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -109,6 +112,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 +191,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 +272,9 @@ 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, each allowed request by its id
|
||||||
|
// until its report comes.
|
||||||
|
awaitingReport *lru.Cache[string, *request]
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTransport returns what carries requests to the app. It never goes
|
// newTransport returns what carries requests to the app. It never goes
|
||||||
@@ -299,11 +300,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 +336,23 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rq.forward(r.Context())
|
rq.forward(r.Context())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
|
||||||
|
// logs through h, with what httpServer sets.
|
||||||
|
func (h *handler) server(httpServer *http.Server) *Server {
|
||||||
|
httpServer.Addr = h.config.ListenAddr
|
||||||
|
httpServer.ErrorLog = h.errorLog
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
Server: httpServer,
|
||||||
|
Ledger: h.ledger,
|
||||||
|
Limiter: h.limiter,
|
||||||
|
GeoJS: h.geojs,
|
||||||
|
Anomalies: h.anomalies,
|
||||||
|
LookupFile: h.lookupFile,
|
||||||
|
Lists: h.lists,
|
||||||
|
DNSBL: h.dnsbl,
|
||||||
|
AbuseIPDB: h.abuseIPDB,
|
||||||
|
Metrics: h.metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,6 +284,16 @@ func newProxy(
|
|||||||
) (*proxy.Server, *output, *alerts.Queue) {
|
) (*proxy.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newServerWith(t, proxy.New, appURL, geojsURL, now, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServerWith is newProxy for the server build makes.
|
||||||
|
func newServerWith(
|
||||||
|
t *testing.T, build func(proxy.Params) *proxy.Server,
|
||||||
|
appURL, geojsURL string, now func() time.Time, env map[string]string,
|
||||||
|
) (*proxy.Server, *output, *alerts.Queue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
||||||
wafMode: off,
|
wafMode: off,
|
||||||
@@ -334,7 +344,7 @@ func newProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
server := build(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
|||||||
rq.h.metrics.ReputationHit(hit.Source)
|
rq.h.metrics.ReputationHit(hit.Source)
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventReputationHit,
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ type request struct {
|
|||||||
client netip.Addr
|
client netip.Addr
|
||||||
peer netip.Addr
|
peer netip.Addr
|
||||||
peerTrusted bool
|
peerTrusted bool
|
||||||
|
// 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.
|
||||||
@@ -108,18 +113,27 @@ 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,
|
h: h,
|
||||||
@@ -569,8 +583,13 @@ 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)
|
err := requestlog.Write(rq.h.requestLog, line)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -634,6 +653,7 @@ func (rq *request) countAnomalies() {
|
|||||||
answer, _ := rq.answerAtTheEnd()
|
answer, _ := rq.answerAtTheEnd()
|
||||||
|
|
||||||
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
ClientGroup: rq.h.clientGroup(rq.client),
|
ClientGroup: rq.h.clientGroup(rq.client),
|
||||||
ASN: answer.ASN,
|
ASN: answer.ASN,
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"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 it passed to
|
||||||
|
// the app.
|
||||||
|
const ReportPath = "/_smallwebwaf/report"
|
||||||
|
|
||||||
|
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||||
|
const QuestionBodyMaxBytes = 64 << 10
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxAwaitingReport is the most allowed requests held until their
|
||||||
|
// reports come. Past it, the request asked about longest ago is dropped.
|
||||||
|
maxAwaitingReport = 10000
|
||||||
|
// reportBodyMaxBytes is the most of a report's body that is read. It is
|
||||||
|
// far above any report's, since the longest thing in one is the request
|
||||||
|
// id, which comes from a header.
|
||||||
|
reportBodyMaxBytes = 1 << 20
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 id servewaf's allow gave. 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, *request](maxAwaitingReport)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // New fails only for a size below one
|
||||||
|
}
|
||||||
|
|
||||||
|
h.awaitingReport = awaitingReport
|
||||||
|
|
||||||
|
return h.server(&http.Server{
|
||||||
|
Handler: http.HandlerFunc(h.serveWAF),
|
||||||
|
ReadTimeout: serveWAFTimeout,
|
||||||
|
WriteTimeout: serveWAFTimeout,
|
||||||
|
IdleTimeout: serveWAFIdleTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWAF handles one request to servewaf: a request carrying
|
||||||
|
// ClientHeader is rproxy's question, and any other is for servewaf itself.
|
||||||
|
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||||
|
h.answerQuestion(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.answerOwnEndpoint(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerQuestion runs the checks on the visitor's request a question
|
||||||
|
// carries, and answers rproxy what to do with it. A denied request is
|
||||||
|
// finished here. An allowed one is finished by its report. A question
|
||||||
|
// without a visitor's address gets no disposition, so that rproxy treats
|
||||||
|
// it as it treats no answer.
|
||||||
|
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
|
||||||
|
if err != nil {
|
||||||
|
h.processLog.Warn("a question gives no visitor's address", "error", err.Error())
|
||||||
|
http.Error(w, "X-Smallwebwaf-Client is not an address", http.StatusBadRequest)
|
||||||
|
|
||||||
|
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), client.Unmap(), peer.Unmap(), true)
|
||||||
|
defer rq.finish()
|
||||||
|
|
||||||
|
rq.site = r.Host
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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) *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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow answers a question with DispositionAllow, the request's id and,
|
||||||
|
// while SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country,
|
||||||
|
// and holds the request until its report comes.
|
||||||
|
func (rq *request) allow() {
|
||||||
|
rq.allowed = true
|
||||||
|
|
||||||
|
// servewaf sends no app this part of the body, and the table of requests
|
||||||
|
// awaiting their reports must not hold it.
|
||||||
|
if rq.body != nil {
|
||||||
|
rq.body.readByCoreRuleSet = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.awaitReport()
|
||||||
|
|
||||||
|
header := rq.out.Header()
|
||||||
|
header.Set(DispositionHeader, DispositionAllow)
|
||||||
|
header.Set(requestIDHeader, rq.line.RequestID)
|
||||||
|
|
||||||
|
if rq.h.config.AddLookupHeaders {
|
||||||
|
setLookupHeaders(header, rq.line.ASN, rq.line.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's server sends this only once the handler has returned, so a report
|
||||||
|
// that answers it cannot come while the handler still uses rq.
|
||||||
|
rq.out.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitReport holds the request by its id, which its report is to name. A
|
||||||
|
// visitor behind a trusted proxy can choose its id, which must not let it
|
||||||
|
// have its report taken for another request's, or lost, and its bytes not
|
||||||
|
// counted. So the request gets a new id when another held request has the
|
||||||
|
// question's, or when that is not valid UTF-8, which a JSON report cannot
|
||||||
|
// carry unchanged.
|
||||||
|
func (rq *request) awaitReport() {
|
||||||
|
id := rq.line.RequestID
|
||||||
|
if !utf8.ValidString(id) {
|
||||||
|
id = rand.Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
held, _ := rq.h.awaitingReport.ContainsOrAdd(id, rq)
|
||||||
|
if held {
|
||||||
|
id = rand.Text()
|
||||||
|
rq.h.awaitingReport.Add(id, rq)
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.line.RequestID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. A report writes no log line.
|
||||||
|
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, and ignores the report otherwise.
|
||||||
|
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var report Report
|
||||||
|
|
||||||
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)).
|
||||||
|
Decode(&report)
|
||||||
|
if err != nil || report.RequestBytes < 0 || report.ResponseBytes < 0 {
|
||||||
|
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rq, held := h.awaitingReport.Peek(report.RequestID)
|
||||||
|
// Remove reports true to one report only, of several that name the id.
|
||||||
|
if held && h.awaitingReport.Remove(report.RequestID) {
|
||||||
|
rq.finishWith(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishWith finishes an allowed request with what its report says: 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.
|
||||||
|
func (rq *request) finishWith(report Report) {
|
||||||
|
// A limit the question broke has been logged.
|
||||||
|
rq.line.LimitHit = ""
|
||||||
|
// addToHistory goes by this for whether the request reached the app.
|
||||||
|
rq.upstreamStart = time.Now()
|
||||||
|
rq.out = &responseWriter{status: report.Status, bytes: report.ResponseBytes}
|
||||||
|
rq.body = &requestBody{}
|
||||||
|
rq.body.bytes.Store(report.RequestBytes)
|
||||||
|
|
||||||
|
rq.countBytes()
|
||||||
|
rq.countAnomalies()
|
||||||
|
rq.addToHistory()
|
||||||
|
|
||||||
|
rq.line.Status = report.Status
|
||||||
|
rq.line.RequestBytes = report.RequestBytes
|
||||||
|
rq.line.ResponseBytes = report.ResponseBytes
|
||||||
|
rq.h.metrics.RequestReported(&rq.line,
|
||||||
|
time.Duration(report.DurationTotal*float64(time.Millisecond)),
|
||||||
|
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
|
||||||
|
|
||||||
|
// The request's log line went out with the answer to its question.
|
||||||
|
if rq.line.LimitHit != "" {
|
||||||
|
rq.h.processLog.Warn("a report's bytes broke a byte limit",
|
||||||
|
"request_id", rq.line.RequestID,
|
||||||
|
"client_ip", rq.line.ClientIP,
|
||||||
|
"host", rq.line.Host,
|
||||||
|
"limit_hit", rq.line.LimitHit,
|
||||||
|
"ban_expires", rq.line.BanExpires)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,777 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// siteA and siteB are two sites behind one servewaf.
|
||||||
|
siteA = "a.example"
|
||||||
|
siteB = "b.example"
|
||||||
|
// rproxyPeer is what the tests' questions give as rproxy's TCP peer.
|
||||||
|
rproxyPeer = "172.18.0.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveWAF is a servewaf running for one test, on a clock set to midnight.
|
||||||
|
type serveWAF struct {
|
||||||
|
t *testing.T
|
||||||
|
addr string
|
||||||
|
out *output
|
||||||
|
server *proxy.Server
|
||||||
|
clock *clock
|
||||||
|
queue *alerts.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServeWAF starts servewaf with the settings in env, clients' AS numbers
|
||||||
|
// and countries looked up at geojsURL.
|
||||||
|
func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
|
settings := map[string]string{
|
||||||
|
alertWebhookURL: "https://alerts.example/servewaf",
|
||||||
|
instanceName: alertInstance,
|
||||||
|
}
|
||||||
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
|
server, out, queue := newServerWith(t, proxy.NewServeWAF,
|
||||||
|
"http://127.0.0.1:8081", geojsURL, clk.Now, settings)
|
||||||
|
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &serveWAF{
|
||||||
|
t: t, addr: listener.Addr().String(), out: out, server: server,
|
||||||
|
clock: clk, queue: queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// question makes rproxy's question about a request for path on host, from the
|
||||||
|
// visitor at from.
|
||||||
|
func (s *serveWAF) question(host, from, path string) *http.Request {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
req := newRequest(s.t, http.MethodGet, s.addr, path, http.NoBody)
|
||||||
|
req.Host = host
|
||||||
|
req.Header.Set(proxy.ClientHeader, from)
|
||||||
|
req.Header.Set(proxy.PeerHeader, rproxyPeer)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends the question about a request for path on siteA from the visitor at
|
||||||
|
// from, and returns the answer.
|
||||||
|
func (s *serveWAF) ask(from, path string) answer {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
return do(s.t, s.question(siteA, from, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed asks about a request for / on siteA, checks that the answer is allow,
|
||||||
|
// and returns the id the answer gives for the report.
|
||||||
|
func (s *serveWAF) allowed(from string) string {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
got := s.ask(from, "/")
|
||||||
|
wantDisposition(s.t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
return got.header.Get(requestIDHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// report sends rproxy's report and returns the answer.
|
||||||
|
func (s *serveWAF) report(report proxy.Report) answer {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(report)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Fatalf("encode the report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return do(s.t, newRequest(s.t, http.MethodPost, s.addr, proxy.ReportPath,
|
||||||
|
bytes.NewReader(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// passedOn asks about a request from the visitor at from on siteA, and reports
|
||||||
|
// it as answered 200 with bodyBytes and answerBytes.
|
||||||
|
func (s *serveWAF) passedOn(from string) {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{
|
||||||
|
RequestID: s.allowed(from),
|
||||||
|
Status: http.StatusOK,
|
||||||
|
RequestBytes: bodyBytes,
|
||||||
|
ResponseBytes: answerBytes,
|
||||||
|
})
|
||||||
|
wantStatus(s.t, got, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// history returns what the table of clients holds of client's history.
|
||||||
|
func (s *serveWAF) history() ratelimit.History {
|
||||||
|
held, _ := s.server.Limiter.Client(netip.MustParsePrefix(client + "/32"))
|
||||||
|
|
||||||
|
return held.History
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics returns what the metrics endpoint answers, for a server started with
|
||||||
|
// the token as SWWAF_METRICS_TOKEN.
|
||||||
|
func (s *serveWAF) metrics() string {
|
||||||
|
s.t.Helper()
|
||||||
|
|
||||||
|
req := newRequest(s.t, http.MethodGet, s.addr, proxy.MetricsPath, http.NoBody)
|
||||||
|
req.Header.Set("Authorization", bearer)
|
||||||
|
|
||||||
|
got := do(s.t, req)
|
||||||
|
wantStatus(s.t, got, http.StatusOK)
|
||||||
|
|
||||||
|
return string(got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantDisposition checks an answer's status and disposition.
|
||||||
|
func wantDisposition(t *testing.T, got answer, status int, disposition string) {
|
||||||
|
t.Helper()
|
||||||
|
wantStatus(t, got, status)
|
||||||
|
|
||||||
|
if answered := got.header.Get(proxy.DispositionHeader); answered != disposition {
|
||||||
|
t.Errorf("answered with disposition %q, want %q", answered, disposition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantBody checks an answer's body.
|
||||||
|
func wantBody(t *testing.T, got answer, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if string(got.body) != want {
|
||||||
|
t.Errorf("answered with the body %q, want %q", got.body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", nil)
|
||||||
|
|
||||||
|
req := s.question(siteA, client, "/page?q=1")
|
||||||
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
req.Header.Set(proxy.ContentLengthHeader, "1234")
|
||||||
|
|
||||||
|
got := do(t, req)
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
wantBody(t, got, "")
|
||||||
|
|
||||||
|
if id := got.header.Get(requestIDHeader); id != "the-id" {
|
||||||
|
t.Errorf("answered with the id %q, want the-id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := s.out.requestLine(t)
|
||||||
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
if line.ClientIP != client || line.PeerIP != rproxyPeer || line.Host != siteA ||
|
||||||
|
line.Path != "/page" || line.Query != "q=1" || line.RequestID != "the-id" ||
|
||||||
|
line.Scheme != "https" || line.ContentLength != 1234 {
|
||||||
|
t.Errorf("log line is %+v", line.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv6VisitorIsCountedInItsGroup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
|
s.allowed("2001:db8::1")
|
||||||
|
|
||||||
|
got := s.ask("2001:db8::2", "/")
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionWithoutAnAddressGetsNoDisposition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", nil)
|
||||||
|
|
||||||
|
got := s.ask("not an address", "/")
|
||||||
|
wantDisposition(t, got, http.StatusBadRequest, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, status := range []int{http.StatusForbidden, http.StatusTooManyRequests} {
|
||||||
|
t.Run(strconv.Itoa(status), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
denyNets: client, banResponse: strconv.Itoa(status),
|
||||||
|
})
|
||||||
|
|
||||||
|
got := s.ask(client, "/")
|
||||||
|
wantDisposition(t, got, status, proxy.DispositionDeny)
|
||||||
|
wantBody(t, got, http.StatusText(status)+"\n")
|
||||||
|
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
denyNets: client, banResponse: banResponseClose, metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
got := s.ask(client, "/")
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionClose)
|
||||||
|
wantBody(t, got, "")
|
||||||
|
// The visitor is sent nothing.
|
||||||
|
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
|
||||||
|
wantMetric(t, s.metrics(), `smallwebwaf_requests_total{action="denied",instance="`+
|
||||||
|
alertInstance+`",status_class="none"}`, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, testRules),
|
||||||
|
banResponse: "429",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := s.ask(client, "/blocked")
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||||
|
requestlog.ActionRuleBlocked)
|
||||||
|
|
||||||
|
got = s.ask(client, "/.env")
|
||||||
|
wantDisposition(t, got, http.StatusTooManyRequests, proxy.DispositionDeny)
|
||||||
|
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
|
||||||
|
requestlog.ActionBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
metricsToken: token, denyNets: otherClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, path := range []string{proxy.HealthPath, proxy.MetricsPath, proxy.ReportPath} {
|
||||||
|
got := s.ask(client, path)
|
||||||
|
wantDisposition(t, got, http.StatusNotFound, proxy.DispositionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
|
||||||
|
|
||||||
|
got := s.ask(otherClient, proxy.MetricsPath)
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client})
|
||||||
|
|
||||||
|
got := s.ask(client, "/")
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
line := s.out.requestLine(t)
|
||||||
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
wantASN, wantCountry string
|
||||||
|
}{
|
||||||
|
{"true", "AS64496", "DE"},
|
||||||
|
{"false", "", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s := startServeWAF(t, geojsURL, map[string]string{
|
||||||
|
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := s.ask(fromDE, "/")
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||||
|
if asn != tc.wantASN || country != tc.wantCountry {
|
||||||
|
t.Errorf("answered with the AS number %q and the country %q, want %q and %q",
|
||||||
|
asn, country, tc.wantASN, tc.wantCountry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
|
||||||
|
|
||||||
|
// The request's own question counts no bytes: it is the report that does.
|
||||||
|
id := s.allowed(client)
|
||||||
|
s.allowed(client)
|
||||||
|
|
||||||
|
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Fatalf("banned before any report: %v", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100 bytes are over the limit of 99.
|
||||||
|
got := s.report(proxy.Report{
|
||||||
|
RequestID: id, Status: http.StatusBadGateway,
|
||||||
|
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||||
|
})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
held := s.server.Ledger.Snapshot()
|
||||||
|
if len(held) != 1 || held[0].Cause != bans.CauseLimit ||
|
||||||
|
held[0].Notes.Kind != ratelimit.KindBytes ||
|
||||||
|
held[0].Notes.Request.Status != http.StatusBadGateway ||
|
||||||
|
held[0].Notes.Request.Host != siteA {
|
||||||
|
t.Errorf("bans held are %+v", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = s.ask(client, "/")
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
|
||||||
|
if !strings.Contains(s.out.text(), `"msg":"a report's bytes broke a byte limit"`) {
|
||||||
|
t.Error("no message says that a report's bytes broke a byte limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
// 100 bytes pass half of a limit of 99 and are within a limit of 199.
|
||||||
|
s := startServeWAF(t, geojsURL, map[string]string{
|
||||||
|
bytesLimitPerMinute: "199", countryBytesPercent: countryDEHalf,
|
||||||
|
lookupTimeout: "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
s.passedOn(fromKP)
|
||||||
|
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
s.passedOn(fromDE)
|
||||||
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
"SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE": "1000",
|
||||||
|
})
|
||||||
|
|
||||||
|
id := s.allowed(client)
|
||||||
|
|
||||||
|
if requests := s.history().Requests; requests != 0 {
|
||||||
|
t.Errorf("history holds %d requests before the report, want none", requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counters := s.server.Anomalies.Snapshot(); len(counters) != 0 {
|
||||||
|
t.Errorf("anomaly counters %v before the report, want none", counters)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{
|
||||||
|
RequestID: id, Status: http.StatusBadGateway,
|
||||||
|
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||||
|
})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
history := s.history()
|
||||||
|
if history.Requests != 1 || history.Forwarded != 1 ||
|
||||||
|
history.RequestBytes != bodyBytes || history.ResponseBytes != answerBytes ||
|
||||||
|
history.Responses.Status5xx != 1 {
|
||||||
|
t.Errorf("history is %+v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counters := s.server.Anomalies.Snapshot(); len(counters) != 1 {
|
||||||
|
t.Errorf("anomaly counters %v, want the client's", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
|
s.allowed(client)
|
||||||
|
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
|
||||||
|
history := s.history()
|
||||||
|
if history.Requests != 1 || history.Refused != 1 {
|
||||||
|
t.Errorf("history is %+v, want the refused request alone", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{metricsToken: token})
|
||||||
|
|
||||||
|
id := s.allowed(client)
|
||||||
|
|
||||||
|
const series = `{action="forward",instance="` + alertInstance + `",status_class="5xx"}`
|
||||||
|
|
||||||
|
metrics := s.metrics()
|
||||||
|
wantNoSeries(t, metrics, "smallwebwaf_requests_total"+series)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`,
|
||||||
|
1) // the scrape itself
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{
|
||||||
|
RequestID: id, Status: http.StatusBadGateway,
|
||||||
|
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
|
||||||
|
DurationTotal: 5, DurationUpstreamTotal: 4,
|
||||||
|
})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
metrics = s.metrics()
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_requests_total"+series, 1)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+series, bodyBytes)
|
||||||
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+series, answerBytes)
|
||||||
|
wantMetric(t, metrics, `smallwebwaf_upstream_duration_seconds_sum{instance="`+
|
||||||
|
alertInstance+`"}`, 0.004)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{RequestID: "no-such-request", ResponseBytes: 1000})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
if clients := s.server.Limiter.Snapshot(); len(clients) != 0 {
|
||||||
|
t.Errorf("the table of clients holds %v", clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
|
t.Errorf("bans held are %v", held)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second report of the same request does not count it twice.
|
||||||
|
id := s.allowed(client)
|
||||||
|
for range 2 {
|
||||||
|
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counted := s.history().ResponseBytes; counted != answerBytes {
|
||||||
|
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportThatCannotBeReadIsRefusedAndTheRequestStaysHeld(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", nil)
|
||||||
|
|
||||||
|
id := s.allowed(client)
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
"not JSON",
|
||||||
|
`{"request_id":"` + id + `","request_bytes":-1}`,
|
||||||
|
`{"request_id":"` + id + `","response_bytes":-1}`,
|
||||||
|
} {
|
||||||
|
got := do(t, newRequest(t, http.MethodPost, s.addr, proxy.ReportPath,
|
||||||
|
strings.NewReader(body)))
|
||||||
|
wantStatus(t, got, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
if counted := s.history().ResponseBytes; counted != answerBytes {
|
||||||
|
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisitorCannotHaveItsReportTakenForAnothersByChoosingItsID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
chosen = "chosen-by-a-visitor"
|
||||||
|
exempt = "/static/x"
|
||||||
|
)
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
bytesLimitPerMinute: byteLimit, rateLimitExemptPaths: "/static/",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The request whose bytes are not counted takes the id first.
|
||||||
|
first := s.question(siteA, client, exempt)
|
||||||
|
first.Header.Set(requestIDHeader, chosen)
|
||||||
|
gotFirst := do(t, first)
|
||||||
|
wantDisposition(t, gotFirst, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
second := s.question(siteA, client, "/download")
|
||||||
|
second.Header.Set(requestIDHeader, chosen)
|
||||||
|
gotSecond := do(t, second)
|
||||||
|
wantDisposition(t, gotSecond, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
id := gotSecond.header.Get(requestIDHeader)
|
||||||
|
if gotFirst.header.Get(requestIDHeader) != chosen || id == chosen || id == "" {
|
||||||
|
t.Errorf("answered with the ids %q and %q, want %q and a new one",
|
||||||
|
gotFirst.header.Get(requestIDHeader), id, chosen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line := s.out.requestLines(t, 2)[1]; line.RequestID != id {
|
||||||
|
t.Errorf("log line has request_id %q, want %q", line.RequestID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.report(proxy.Report{RequestID: id, ResponseBytes: 1000})
|
||||||
|
wantStatus(t, got, http.StatusNoContent)
|
||||||
|
|
||||||
|
if held := s.server.Ledger.Snapshot(); len(held) != 1 {
|
||||||
|
t.Errorf("bans held are %v, want the one the download's bytes make", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDAJSONReportCannotCarryIsReplaced(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
|
||||||
|
|
||||||
|
req := s.question(siteA, client, "/")
|
||||||
|
req.Header.Set(requestIDHeader, "not-utf-8-\xff")
|
||||||
|
|
||||||
|
got := do(t, req)
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
report := s.report(proxy.Report{
|
||||||
|
RequestID: got.header.Get(requestIDHeader), ResponseBytes: 1000,
|
||||||
|
})
|
||||||
|
wantStatus(t, report, http.StatusNoContent)
|
||||||
|
|
||||||
|
if held := s.server.Ledger.Snapshot(); len(held) != 1 {
|
||||||
|
t.Errorf("bans held are %v, want the one the report's bytes make", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)})
|
||||||
|
|
||||||
|
got := do(t, s.question(siteA, client, "/.env"))
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
|
||||||
|
got = do(t, s.question(siteB, client, "/"))
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
if lines[0].Host != siteA || lines[1].Host != siteB {
|
||||||
|
t.Errorf("log lines have the hosts %q and %q", lines[0].Host, lines[1].Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
held := s.server.Ledger.Snapshot()
|
||||||
|
if len(held) != 1 || held[0].Notes.Request.Host != siteA {
|
||||||
|
t.Errorf("bans held are %+v, want one whose notes name %s", held, siteA)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) == 0 || waiting[0].Host != siteA {
|
||||||
|
t.Errorf("alerts waiting are %+v, want the ban's, naming %s", waiting, siteA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestsOnEverySiteCountTowardOneSetOfLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
|
s.allowed(client)
|
||||||
|
|
||||||
|
got := do(t, s.question(siteB, client, "/"))
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Were a check run on its requests, localhost's would be refused.
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
metricsToken: token, adminToken: adminSecret, denyNets: localhost,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, authorization string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{proxy.HealthPath, "", http.StatusOK},
|
||||||
|
{proxy.MetricsPath, bearer, http.StatusOK},
|
||||||
|
{proxy.MetricsPath, "", http.StatusUnauthorized},
|
||||||
|
{proxy.BansPath, adminBearer, http.StatusOK},
|
||||||
|
{proxy.BansPath, "", http.StatusUnauthorized},
|
||||||
|
{proxy.ClientsPath + client, adminBearer, http.StatusOK},
|
||||||
|
{proxy.ReportPath, "", http.StatusNotFound},
|
||||||
|
{"/_smallwebwaf/other", "", http.StatusNotFound},
|
||||||
|
{"/", "", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
req := newRequest(t, http.MethodGet, s.addr, tc.path, http.NoBody)
|
||||||
|
req.Header.Set("Authorization", tc.authorization)
|
||||||
|
wantDisposition(t, do(t, req), tc.want, "")
|
||||||
|
|
||||||
|
// The same request carrying the header is a question about it.
|
||||||
|
req = newRequest(t, http.MethodGet, s.addr, tc.path, http.NoBody)
|
||||||
|
req.Header.Set("Authorization", tc.authorization)
|
||||||
|
req.Header.Set(proxy.ClientHeader, client)
|
||||||
|
|
||||||
|
if strings.HasPrefix(tc.path, "/_smallwebwaf/") {
|
||||||
|
wantDisposition(t, do(t, req), http.StatusNotFound, proxy.DispositionDeny)
|
||||||
|
} else {
|
||||||
|
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportWritesNoLogLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", nil)
|
||||||
|
|
||||||
|
s.passedOn(client)
|
||||||
|
// The health check's line comes after the question's.
|
||||||
|
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
if len(lines) != 2 || lines[1].Path != proxy.HealthPath {
|
||||||
|
t.Errorf("request log lines are %+v, want the question's and the health check's",
|
||||||
|
lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
requestMaxBytes: "1",
|
||||||
|
clientHeaderMaxBytes: "5K",
|
||||||
|
clientRequestTimeout: off,
|
||||||
|
clientResponseTimeout: off,
|
||||||
|
trustedProxies: localhost,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader("a body"))
|
||||||
|
req.Host = siteA
|
||||||
|
req.Header.Set(proxy.ClientHeader, client)
|
||||||
|
req.Header.Set(proxy.ContentLengthHeader, "1073741824")
|
||||||
|
req.Header.Set(forwardedFor, otherClient)
|
||||||
|
req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
|
||||||
|
|
||||||
|
got := do(t, req)
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
|
||||||
|
line := s.out.requestLine(t)
|
||||||
|
if line.ClientIP != client || line.ContentLength != 1<<30 {
|
||||||
|
t.Errorf("log line has client_ip %q and content_length %d",
|
||||||
|
line.ClientIP, line.ContentLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, `
|
||||||
|
own-header header:X-Smallwebwaf-Client block .
|
||||||
|
announced header:Content-Length block ^70000$
|
||||||
|
carried header:Content-Length block ^6$
|
||||||
|
`),
|
||||||
|
})
|
||||||
|
|
||||||
|
// What the question's own Content-Length says, 6, is not what the visitor
|
||||||
|
// announced.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
announced string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"70000", http.StatusForbidden},
|
||||||
|
{"", http.StatusOK},
|
||||||
|
{"6000", http.StatusOK},
|
||||||
|
} {
|
||||||
|
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader("a body"))
|
||||||
|
req.Header.Set(proxy.ClientHeader, client)
|
||||||
|
|
||||||
|
if tc.announced != "" {
|
||||||
|
req.Header.Set(proxy.ContentLengthHeader, tc.announced)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantStatus(t, do(t, req), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
wafMode: block, wafBodyLimit: "1M",
|
||||||
|
})
|
||||||
|
|
||||||
|
ask := func(contentType, body string) answer {
|
||||||
|
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader(body))
|
||||||
|
req.Header.Set(proxy.ClientHeader, client)
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
req.Header.Set(proxy.ContentLengthHeader, "100000")
|
||||||
|
|
||||||
|
return do(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ask(formData, "id=1'%20OR%20'1'='1")
|
||||||
|
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
|
||||||
|
// The first 64 KiB of a longer JSON body cannot be parsed, which is no
|
||||||
|
// attack, so the Core Rule Set does not read it.
|
||||||
|
cut := `{"text":"` + strings.Repeat("x", proxy.QuestionBodyMaxBytes)
|
||||||
|
got = ask("application/json", cut[:proxy.QuestionBodyMaxBytes])
|
||||||
|
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnomalyAlertNamesTheSite(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startServeWAF(t, "", map[string]string{
|
||||||
|
"SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
s.passedOn(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventAnomaly ||
|
||||||
|
waiting[0].Host != siteA {
|
||||||
|
t.Errorf("alerts waiting are %+v, want an anomaly naming %s", waiting, siteA)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package smallwebwaf_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
|
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("question: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = io.Copy(io.Discard, res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != status ||
|
||||||
|
res.Header.Get(proxy.DispositionHeader) != disposition {
|
||||||
|
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
|
||||||
|
res.Header.Get(proxy.DispositionHeader), status, disposition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runServeWAF runs `smallwebwaf servewaf` with the settings in env, has use
|
||||||
|
// send it requests at the address it listens on, stops it as SIGTERM does, and
|
||||||
|
// checks that it stopped in order.
|
||||||
|
func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
|
out := &output{}
|
||||||
|
exited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
exited <- runAs(ctx, true, env, out)
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
|
use(addr)
|
||||||
|
stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case status := <-exited:
|
||||||
|
if status != 0 {
|
||||||
|
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
|
}
|
||||||
|
case <-time.After(waitLimit):
|
||||||
|
t.Fatal("still running after being told to stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
out.line(t, "msg", "stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
listenAddr: localhost + ":0",
|
||||||
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runServeWAF(t, env, func(addr string) {
|
||||||
|
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
|
||||||
|
// The default rule file bans a probe for this.
|
||||||
|
ask(t, addr, "/.env", http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
})
|
||||||
|
|
||||||
|
runServeWAF(t, env, func(addr string) {
|
||||||
|
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutACommandTheClientHeaderIsNoQuestion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runUntilStopped(t, map[string]string{
|
||||||
|
listenAddr: localhost + ":0",
|
||||||
|
upstreamURL: startApp(t),
|
||||||
|
stateDir: t.TempDir(),
|
||||||
|
rulesDir: t.TempDir(),
|
||||||
|
}, func(url string) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
|
||||||
|
http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set(proxy.ClientHeader, placed)
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
|
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if err != nil || string(body) != greeting ||
|
||||||
|
res.Header.Get(proxy.DispositionHeader) != "" {
|
||||||
|
t.Errorf("answered %q (%v) with disposition %q, want the app's greeting",
|
||||||
|
body, err, res.Header.Get(proxy.DispositionHeader))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// 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.
|
||||||
package smallwebwaf
|
package smallwebwaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -45,11 +46,15 @@ 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 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)
|
||||||
@@ -63,6 +68,7 @@ func Main(version string) int {
|
|||||||
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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +123,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 +162,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 +187,7 @@ func newServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
server := newProxy(proxy.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: stdout,
|
RequestLog: stdout,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
|
|||||||
@@ -113,6 +113,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 +131,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 +1248,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": "6000",
|
||||||
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
|
"SWWAF_RATE_LIMIT_PER_HOUR": "100000",
|
||||||
rateLimitPerDay: "50000",
|
rateLimitPerDay: "1000000",
|
||||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWWAF_DENIED_COUNTRIES": "",
|
"SWWAF_DENIED_COUNTRIES": "",
|
||||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user