Author SHA1 Message Date
sneak 872172e958 servewaf answers rproxy's question about each request and takes its report (closes #170)
check / check (push) Waiting to run
`smallwebwaf servewaf` runs today's checks on the visitor's request each
question carries, answers rproxy, and passes nothing to an app. rproxy reports
every request servewaf answered. A report counts an allowed request's bytes and
writes servewaf's second log line. The table holds a copy of what a report
needs, never the request, so a report cannot reach a request being handled.

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

Model: opus-5-5
2026-11-04 23:07:20 +00:00
clawbot 718a2c94b2 Default request limits per minute, hour and day, sized for API use (closes #176)
check / check (push) Waiting to run
The default request limits now suit programmatic use of an API, such
as an automation fleet on another host, and still catch abusive bots:

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

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

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

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

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

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

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

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

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