1 Commits
Author SHA1 Message Date
sneak 4a2d57dca2 servewaf answers rproxy's question about each request and takes its report (closes #170)
check / check (push) Successful in 4m23s
`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 21:10:03 +00:00
12 changed files with 1340 additions and 290 deletions

No files matched your search

+94 -45
View File
@@ -1019,8 +1019,8 @@ 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 the statuses below are exact. Each `SWWAF_` setting and check keeps the meaning
this document gives it, except as this section says. this document gives it, except as this section says.
It starts as `smallwebwaf` with no command does. It reads the settings, the rule `servewaf` starts as `smallwebwaf` with no command does. It reads the settings,
files, the lookup database and the state files, and listens at 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. `SWWAF_LISTEN_ADDR`. It stops in the same orderly way, writing the state files.
`docs/settings.md` lists the settings it ignores. `docs/settings.md` lists the settings it ignores.
@@ -1048,12 +1048,42 @@ no other header:
line's `peer_ip`. line's `peer_ip`.
- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is - `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is
absent when the visitor announced none. absent when the visitor announced none.
- `X-Request-ID`: the request's id. - `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. - `X-Forwarded-Proto`: the scheme.
Before any check, `servewaf` removes the three `X-Smallwebwaf-` headers. It also 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 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. 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 ### The answer
@@ -1061,12 +1091,9 @@ rule files and the Core Rule Set see the headers the visitor sent.
Every answer but the last below carries `X-Smallwebwaf-Disposition`. Every answer but the last below carries `X-Smallwebwaf-Disposition`.
- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the - `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the
request to the app. The answer also carries: request to the app. With `SWWAF_ADD_LOOKUP_HEADERS` the answer also carries
- `X-Request-ID`: the id `rproxy` passes to the app, logs and names in the `X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it
report; passes on. A header whose value is unknown is left out.
- `X-Client-ASN` and `X-Client-Country`, with `SWWAF_ADD_LOOKUP_HEADERS`,
which `rproxy` adds to the request it passes on. A header whose value is
unknown is left out.
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the - `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 visitor is to get. `rproxy` gives them to the visitor, less this header. The
status is one of: status is one of:
@@ -1077,58 +1104,81 @@ Every answer but the last below carries `X-Smallwebwaf-Disposition`.
- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the - `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
visitor's connection without an answer. visitor's connection without an answer.
- No such header, status `400`, when `X-Smallwebwaf-Client` is not an address. - No such header, status `400`, for a question `servewaf` refuses. `rproxy`
`rproxy` treats this answer as it treats no answer at all. treats this answer as it treats no answer at all.
The `X-Request-ID` of an `allow` is the question's id, unless `servewaf` has to
make a new one. A visitor behind a trusted proxy can choose its id, so it makes
a new one when the question's id is:
- absent;
- the id of another request awaiting its report;
- not valid UTF-8, which a JSON report cannot carry.
In `observe` mode a request that `enforce` mode would refuse is answered 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 `allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
still answered `404`. still answered `404`.
The Core Rule Set reads the body a question carries only while The Core Rule Set inspects the body a question carries only while
`SWWAF_WAF_BODY_LIMIT` is set. It reads at most 64 KiB less one byte. So it does `SWWAF_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it
not read a JSON or XML body of 64 KiB or more, which may have been cut short. does not inspect a JSON or XML body of 64 KiB or more, which may have been cut
short.
### The report ### The report
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report` Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
for each request it passed to the app on an `allow`. The report carries no for each request `servewaf` answered with `allow`, `deny` or `close`. It sends
`X-Smallwebwaf-Client`. Its body is a JSON object with these fields, named as none after a `400` or no answer. The report carries no `X-Smallwebwaf-Client`.
the request log's fields of the same meaning: 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 `X-Request-ID` of the `allow`; - `request_id`: the question's `X-Request-ID`;
- `status`: what the visitor got, `0` if nothing; - `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 - `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way; 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 - `duration_total`: milliseconds from the request's headers being read to the
end of the answer; end of the answer, from `0` to `1000000000000` (about 31 years);
- `duration_upstream_total`: milliseconds from the hand-over to the app to the - `duration_upstream_total`: milliseconds from the hand-over to the app to the
end of the answer. end of the answer, in the same range, and `0` for a request not passed to the
app.
`servewaf` answers `204`, also for a request it does not hold, which it ignores. `servewaf` answers `400` and writes a process warning when the body:
It answers `400` to a body that is not such an object, has a negative byte
count, or is over 1 MiB. `rproxy` does not act on the answer.
For a request it holds, `servewaf` then: - 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 - counts the bytes as `SWWAF_BYTES_COUNT` says, toward the client's byte limits
at the percentages it gave that request; at the percentages it gave that request;
- bans a client that passes a byte limit, and writes a process message naming - bans a client that passes a byte limit;
the request, since the request's log line has gone out; - adds the request with its status to the client's history, the anomaly counters
- adds the request with its status to the client's history and the anomaly and the metrics.
counters.
`servewaf` holds each allowed request until its report comes, up to 10,000 A denied request's counting is finished at its decision, so its report counts
requests. Past that, the request asked about longest ago is dropped. A request nothing.
whose report never comes is in neither the client's history nor the metrics, and
its bytes are not counted. A denied request is finished at its decision. `servewaf` 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 ### `servewaf`'s own endpoints
@@ -1155,8 +1205,7 @@ 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 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. by a request and ban notes name the site by its host.
`docs/request-log.md` gives `servewaf`'s log line, and `docs/metrics.md` how its `docs/metrics.md` says how its metrics count.
metrics count.
## Deployment ## Deployment
+6 -2
View File
@@ -6,8 +6,12 @@ label as `exported_instance` unless the scrape sets `honor_labels: true`.
`smallwebwaf servewaf` counts a denied request at its decision. It counts an `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 allowed request when its report comes, with the status, bytes and times the
report gives. A report itself is not counted, and neither is a request whose report gives. These are not counted:
report never comes.
- a report itself;
- an allowed request whose report never comes;
- a question refused with `400`;
- a question whose body stopped arriving.
## Traffic ## Traffic
+42 -13
View File
@@ -118,19 +118,34 @@ All in milliseconds:
- `duration_upstream_first_byte`: until the first byte of the answer; - `duration_upstream_first_byte`: until the first byte of the answer;
- `duration_upstream_total`: until the end of the answer. - `duration_upstream_total`: until the end of the answer.
## `servewaf`'s line ## `servewaf`'s lines
`smallwebwaf servewaf` writes one line for each question, once it has answered `smallwebwaf servewaf` writes two lines for each question it answers, joined by
it, and one for each request to its own endpoints but a report. A report writes `request_id`. The field `servewaf_line` says which of the two a line is:
no line. A question's line has the fields above, with these meanings where they
differ: - `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`. - `client_ip`: `X-Smallwebwaf-Client`.
- `peer_ip`: `X-Smallwebwaf-Peer`. - `peer_ip`: `X-Smallwebwaf-Peer`.
- `host`: the site the visitor asked for. - `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`. - `protocol`: the question's own, `HTTP/1.1`.
- `content_length`: `X-Smallwebwaf-Content-Length`. - `content_length`: `X-Smallwebwaf-Content-Length`.
- `request_id`: the id `servewaf` answers with on `allow`. - `request_id`: the question's `X-Request-ID`.
- `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`, - `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`,
or `0` for `close`, as the visitor is then sent nothing. 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. - `request_bytes`: how much of the question's body the Core Rule Set read.
@@ -142,11 +157,25 @@ differ:
The line leaves out `upstream_status`, `websocket` and the The line leaves out `upstream_status`, `websocket` and the
`duration_upstream_...` fields, since `servewaf` passes nothing to the app. `duration_upstream_...` fields, since `servewaf` passes nothing to the app.
`rproxy` holds the visitor's status, the body bytes and the times of the app's
answer. The request id joins the two.
The bytes of an allowed request are counted when its report comes, after its The bytes of an allowed request are counted when its report comes. So `counts`
line. So `counts` holds the client's bytes without this request's. A report that holds the client's bytes without this request's.
breaks a byte limit writes a process message instead of `limit_hit` on the line:
`a report's bytes broke a byte limit`, with `request_id`, `client_ip`, `host`, ### The `report` line
`limit_hit` and `ban_expires`.
It has every field of the `decision` line, and `time` stays when the request
arrived. 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.
+1 -1
View File
@@ -345,4 +345,4 @@ A few limits are fixed rather than settings:
- at most 1,000 DNS blocklist queries are under way at once; - at most 1,000 DNS blocklist queries are under way at once;
- at most 20,000 anomaly counters are kept; - at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts; - each alert destination's queue holds at most 1,000 alerts;
- `servewaf` holds at most 10,000 allowed requests awaiting their reports. - `servewaf` holds at most 10,000 answered requests awaiting their reports.
+3 -1
View File
@@ -15,9 +15,11 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
// anomaly counters, nor a clock, and the request no response: reading // anomaly counters, nor a clock, and the request no response: reading
// any of them to count the request panics. // any of them to count the request panics.
rq := &request{ rq := &request{
h: &handler{config: &config.Config{LookupSource: "geojs"}}, decision: decision{
client: netip.MustParseAddr("203.0.113.9"), client: netip.MustParseAddr("203.0.113.9"),
lookedUp: true, lookedUp: true,
},
h: &handler{config: &config.Config{LookupSource: "geojs"}},
} }
defer func() { defer func() {
+4 -4
View File
@@ -340,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,
} }
} }
+11 -9
View File
@@ -1,8 +1,8 @@
// Package proxy runs the checks on each request and writes one request log // Package proxy runs the checks on each request and writes its request log
// line for it. New passes each request it lets through to the app and the // line. New passes each request it lets through to the app and the app's
// app's answer back, unchanged, within the size and time limits. NewServeWAF // answer back, unchanged, within the size and time limits. NewServeWAF instead
// instead answers rproxy's question about each request, and takes rproxy's // answers rproxy's question about each request, and takes rproxy's report once
// report once the visitor's answer has ended. // the visitor's answer has ended, which writes the request a second log line.
package proxy package proxy
import ( import (
@@ -56,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
@@ -272,9 +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, each allowed request by its id // awaitingReport holds, in servewaf, the decision on each answered
// until its report comes. // question by its id until its report comes, and nil for a question still
awaitingReport *lru.Cache[string, *request] // 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
+49 -27
View File
@@ -39,20 +39,15 @@ type refusal struct {
limit string limit string
} }
// request is one request on its way through smallwebwaf, from the moment // decision is what the checks note of a request: its log line, and what
// its headers have been read to its log line. // counting the request needs once it has ended. servewaf keeps a copy of it
type request struct { // until the request's report comes. It holds no credentials, since the log line
h *handler // never does, and neither the request's objects nor their bodies.
in *http.Request type decision struct {
// rc sets the deadlines of the connection to the client.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
line requestlog.Line line requestlog.Line
client netip.Addr client netip.Addr
peer netip.Addr // requestURI is the path with the query, as a ban's notes keep it.
peerTrusted bool requestURI string
// site is, in servewaf, the host the visitor asked for, which the // site is, in servewaf, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. It is "" otherwise. // alerts name, since one servewaf serves many sites. It is "" otherwise.
site string site string
@@ -70,17 +65,36 @@ type request struct {
counted bool counted bool
limitPercent, bytesPercent percentage limitPercent, bytesPercent percentage
// attack is true for a request that matched a ban rule or asked for a // attack is true for a request that matched a ban rule or asked for a
// trap path, ruleBlocked for one a block rule refused, wafBlocked for // trap path, ruleBlocked for one a block rule refused, and wafBlocked for
// one the Core Rule Set refused, and tokenRefused for one refused for a // one the Core Rule Set refused, each an offence its client's history
// missing or wrong token, each an offence its client's history counts. // counts.
attack, ruleBlocked, wafBlocked, tokenRefused bool attack, ruleBlocked, wafBlocked bool
// reputation is the reputation sources that list the client, for the
// notes of a ban the request makes.
reputation []bans.ReputationHit
}
// request is one request on its way through smallwebwaf, from the moment
// its headers have been read to its log line.
type request struct {
decision
h *handler
in *http.Request
// rc sets the deadlines of the connection to the client.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
peer netip.Addr
peerTrusted bool
// tokenRefused is true for a request refused for a missing or wrong
// token, an offence its client's history counts.
tokenRefused bool
// blocklisted is true once a blocklist is found to list the client, // blocklisted is true once a blocklist is found to list the client,
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once // dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
// AbuseIPDB's score of it is a hit. // AbuseIPDB's score of it is a hit.
blocklisted, dnsblListed, abuseIPDBHit bool blocklisted, dnsblListed, abuseIPDBHit bool
// reputation is the reputation sources that list the client, for the
// notes of a ban the request makes.
reputation []bans.ReputationHit
start time.Time start time.Time
// checked is when the checks were done, and upstreamStart when the // checked is when the checks were done, and upstreamStart when the
// request was handed to the app. // request was handed to the app.
@@ -136,14 +150,9 @@ func (h *handler) startRequest(
forwardedFor := r.Header.Values("X-Forwarded-For") forwardedFor := r.Header.Values("X-Forwarded-For")
rq := &request{ rq := &request{
h: h, decision: decision{
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
client: client, client: client,
peer: peer, requestURI: r.URL.RequestURI(),
peerTrusted: peerTrusted,
start: start,
line: requestlog.Line{ line: requestlog.Line{
Time: requestlog.FormatTime(start), Time: requestlog.FormatTime(start),
Instance: h.config.InstanceName, Instance: h.config.InstanceName,
@@ -166,6 +175,14 @@ func (h *handler) startRequest(
HasCookie: len(r.Header.Values("Cookie")) > 0, HasCookie: len(r.Header.Values("Cookie")) > 0,
Action: requestlog.ActionForward, Action: requestlog.ActionForward,
}, },
},
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
peer: peer,
peerTrusted: peerTrusted,
start: start,
} }
// A length of -1 is a body whose length was not announced. // A length of -1 is a body whose length was not announced.
@@ -591,7 +608,12 @@ func (rq *request) finish() {
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration) rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
} }
err := requestlog.Write(rq.h.requestLog, line) rq.writeLine()
}
// writeLine writes the request's log line.
func (rq *request) writeLine() {
err := requestlog.Write(rq.h.requestLog, &rq.line)
if err != nil { if err != nil {
rq.h.processLog.Error("writing the request log failed", "error", err.Error()) rq.h.processLog.Error("writing the request log failed", "error", err.Error())
} }
+202 -88
View File
@@ -1,8 +1,11 @@
package proxy package proxy
import ( import (
"crypto/rand" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io"
"net/http" "net/http"
"net/netip" "net/netip"
"strconv" "strconv"
@@ -43,21 +46,30 @@ const (
DispositionClose = "close" DispositionClose = "close"
) )
// ReportPath is where rproxy sends the report of each request it passed to // ReportPath is where rproxy sends the report of each request servewaf
// the app. // answered.
const ReportPath = "/_smallwebwaf/report" const ReportPath = "/_smallwebwaf/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
const ( const (
// maxAwaitingReport is the most allowed requests held until their // maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request asked about longest ago is dropped. // reports come. Past it, the request answered longest ago is dropped.
maxAwaitingReport = 10000 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 // reportBodyMaxBytes is the most of a report's body that is read. It is
// far above any report's, since the longest thing in one is the request // far above any report's.
// id, which comes from a header.
reportBodyMaxBytes = 1 << 20 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, // serveWAFTimeout bounds how long a request to servewaf takes to arrive,
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open // and its answer to leave, and serveWAFIdleTimeout how long a kept-open
// connection waits for its next request. // connection waits for its next request.
@@ -65,10 +77,15 @@ const (
serveWAFIdleTimeout = 120 * 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 // 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 // answer has ended, its fields named as the request log's of the same
// meaning. RequestID is the id servewaf's allow gave. Status is what the // meaning. RequestID is the question's id. Status is what the visitor got, 0
// visitor got, 0 if nothing. The durations are in milliseconds. // if nothing. The durations are in milliseconds.
// //
//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 Report struct { type Report struct {
@@ -102,7 +119,7 @@ func NewServeWAF(params Params) *Server {
h := newHandler(params) h := newHandler(params)
awaitingReport, err := lru.New[string, *request](maxAwaitingReport) awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
if err != nil { if err != nil {
panic(err) // New fails only for a size below one panic(err) // New fails only for a size below one
} }
@@ -130,26 +147,27 @@ func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
} }
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
// carries, and answers rproxy what to do with it. A denied request is // carries, and answers rproxy what to do with it. A denied request's counting
// finished here. An allowed one is finished by its report. A question // is finished here, and an allowed one's by its report.
// without a visitor's address gets no disposition, so that rproxy treats
// it as it treats no answer.
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) { func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
client, err := netip.ParseAddr(r.Header.Get(ClientHeader)) client, body, ok := h.readQuestion(w, r)
if err != nil { if !ok {
h.processLog.Warn("a question gives no visitor's address", "error", err.Error())
http.Error(w, "X-Smallwebwaf-Client is not an address", http.StatusBadRequest)
return return
} }
// One that cannot be read leaves the log line's peer_ip invalid. // One that cannot be read leaves the log line's peer_ip invalid.
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader)) peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
rq := h.startRequest(w, asTheVisitorSentIt(r), client.Unmap(), peer.Unmap(), true) 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() defer rq.finish()
rq.site = r.Host rq.site = r.Host
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
@@ -181,11 +199,70 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) 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 // asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for servewaf, and // log line are to see it: without the headers rproxy adds for servewaf, with
// with the length the visitor announced, as -1 when it announced none. The // body, which has been read of r, and with the length the visitor announced,
// question's own length covers only the part of the body it carries. // as -1 when it announced none. The question's own length covers only the part
func asTheVisitorSentIt(r *http.Request) *http.Request { // of the body it carries.
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
sent := r.Clone(r.Context()) sent := r.Clone(r.Context())
announced := sent.Header.Get(ContentLengthHeader) announced := sent.Header.Get(ContentLengthHeader)
@@ -202,55 +279,34 @@ func asTheVisitorSentIt(r *http.Request) *http.Request {
sent.Header.Set("Content-Length", announced) sent.Header.Set("Content-Length", announced)
} }
sent.Body = http.NoBody
if len(body) > 0 {
sent.Body = io.NopCloser(bytes.NewReader(body))
}
return sent return sent
} }
// allow answers a question with DispositionAllow, the request's id and, // allow answers a question with DispositionAllow and, while
// while SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country, // SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
// and holds the request until its report comes.
func (rq *request) allow() { func (rq *request) allow() {
rq.allowed = true rq.allowed = true
// servewaf sends no app this part of the body, and the table of requests
// awaiting their reports must not hold it.
if rq.body != nil {
rq.body.readByCoreRuleSet = nil
}
rq.awaitReport()
header := rq.out.Header() header := rq.out.Header()
header.Set(DispositionHeader, DispositionAllow) header.Set(DispositionHeader, DispositionAllow)
header.Set(requestIDHeader, rq.line.RequestID)
if rq.h.config.AddLookupHeaders { if rq.h.config.AddLookupHeaders {
setLookupHeaders(header, rq.line.ASN, rq.line.Country) setLookupHeaders(header, rq.line.ASN, rq.line.Country)
} }
// Go's server sends this only once the handler has returned, so a report
// that answers it cannot come while the handler still uses rq.
rq.out.WriteHeader(http.StatusOK) rq.out.WriteHeader(http.StatusOK)
} }
// awaitReport holds the request by its id, which its report is to name. A // holdForReport holds a copy of what the report's steps need, by the request's
// visitor behind a trusted proxy can choose its id, which must not let it // id. A report only ever reaches that copy, never the request being handled.
// have its report taken for another request's, or lost, and its bytes not func (rq *request) holdForReport() {
// counted. So the request gets a new id when another held request has the held := rq.decision
// question's, or when that is not valid UTF-8, which a JSON report cannot rq.h.awaitingReport.Add(rq.line.RequestID, &held)
// carry unchanged.
func (rq *request) awaitReport() {
id := rq.line.RequestID
if !utf8.ValidString(id) {
id = rand.Text()
}
held, _ := rq.h.awaitingReport.ContainsOrAdd(id, rq)
if held {
id = rand.Text()
rq.h.awaitingReport.Add(id, rq)
}
rq.line.RequestID = id
} }
// deny answers a question with what the visitor is to get for r. // deny answers a question with what the visitor is to get for r.
@@ -271,7 +327,7 @@ func (rq *request) deny(r refusal) {
// answerOwnEndpoint answers a request to servewaf's own address. No check // 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 // runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors. A report writes no log line. // rproxy and the admin's tools, not visitors.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) { func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == ReportPath { if r.Method == http.MethodPost && r.URL.Path == ReportPath {
h.takeReport(w, r) h.takeReport(w, r)
@@ -294,57 +350,115 @@ func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
} }
// takeReport answers POST ReportPath: it finishes the request the report // takeReport answers POST ReportPath: it finishes the request the report
// names, if it holds that request, and ignores the report otherwise. // names, if it holds that request's decision, and ignores the report
// otherwise.
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) { func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
var report Report report, err := readReport(w, r)
if err != nil {
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)). h.processLog.Warn("a report is refused", "error", err.Error())
Decode(&report)
if err != nil || report.RequestBytes < 0 || report.ResponseBytes < 0 {
http.Error(w, "the body is not a report", http.StatusBadRequest) http.Error(w, "the body is not a report", http.StatusBadRequest)
return return
} }
rq, held := h.awaitingReport.Peek(report.RequestID) held, found := h.awaitingReport.Peek(report.RequestID)
// Remove reports true to one report only, of several that name the id. // Remove reports true to one report only, of several that name the id.
if held && h.awaitingReport.Remove(report.RequestID) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
rq.finishWith(report) h.finishWith(held, report)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// finishWith finishes an allowed request with what its report says: the // readReport reads a report from r's body, which must be one JSON object with
// steps ServeHTTP takes once a request passed to the app has ended, with // all of Report's fields, in the ranges SPEC.md gives.
// the status and bytes of the report in place of those it would have seen. func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
func (rq *request) finishWith(report Report) { var report Report
// A limit the question broke has been logged.
rq.line.LimitHit = "" 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. // addToHistory goes by this for whether the request reached the app.
rq.upstreamStart = time.Now() rq.upstreamStart = time.Now()
rq.out = &responseWriter{status: report.Status, bytes: report.ResponseBytes}
rq.body = &requestBody{}
rq.body.bytes.Store(report.RequestBytes)
rq.countBytes() rq.countBytes()
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes
rq.h.metrics.RequestReported(&rq.line, rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotal*float64(time.Millisecond)), time.Duration(report.DurationTotal*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond))) time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
// The request's log line went out with the answer to its question.
if rq.line.LimitHit != "" {
rq.h.processLog.Warn("a report's bytes broke a byte limit",
"request_id", rq.line.RequestID,
"client_ip", rq.line.ClientIP,
"host", rq.line.Host,
"limit_hit", rq.line.LimitHit,
"ban_expires", rq.line.BanExpires)
} }
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.
+81
View File
@@ -2,15 +2,95 @@ package smallwebwaf_test
import ( import (
"context" "context"
"crypto/rand"
"io" "io"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"syscall"
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/proxy" "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 // 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. // placed, checks the answer's status and disposition, and closes it.
func ask(t *testing.T, addr, path string, status int, disposition string) { func ask(t *testing.T, addr, path string, status int, disposition string) {
@@ -23,6 +103,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
} }
req.Header.Set(proxy.ClientHeader, placed) req.Header.Set(proxy.ClientHeader, placed)
req.Header.Set("X-Request-ID", rand.Text())
transport := &http.Transport{} transport := &http.Transport{}
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()