Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6ba53747 |
No files matched your search
@@ -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.
|
||||||
|
|
||||||
`servewaf` starts as `smallwebwaf` with no command does. It reads the settings,
|
It starts as `smallwebwaf` with no command does. It reads the settings, the rule
|
||||||
the rule files, the lookup database and the state files, and listens at
|
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,42 +1048,12 @@ 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, which `rproxy` makes fresh for every request
|
- `X-Request-ID`: the request's id.
|
||||||
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, with
|
rule files and the Core Rule Set see the headers the visitor sent.
|
||||||
`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
|
||||||
|
|
||||||
@@ -1091,9 +1061,12 @@ metrics.
|
|||||||
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. With `SWWAF_ADD_LOOKUP_HEADERS` the answer also carries
|
request to the app. The answer also carries:
|
||||||
`X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it
|
- `X-Request-ID`: the id `rproxy` passes to the app, logs and names in the
|
||||||
passes on. A header whose value is unknown is left out.
|
report;
|
||||||
|
- `X-Client-ASN` and `X-Client-Country`, with `SWWAF_ADD_LOOKUP_HEADERS`,
|
||||||
|
which `rproxy` adds to the request it passes on. A header whose value is
|
||||||
|
unknown is left out.
|
||||||
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the
|
- `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:
|
||||||
@@ -1104,81 +1077,58 @@ 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`, for a question `servewaf` refuses. `rproxy`
|
- No such header, status `400`, when `X-Smallwebwaf-Client` is not an address.
|
||||||
treats this answer as it treats no answer at all.
|
`rproxy` treats this answer as it treats no answer at all.
|
||||||
|
|
||||||
|
The `X-Request-ID` of an `allow` is the question's id, unless `servewaf` has to
|
||||||
|
make a new one. A visitor behind a trusted proxy can choose its id, so it makes
|
||||||
|
a new one when the question's id is:
|
||||||
|
|
||||||
|
- absent;
|
||||||
|
- the id of another request awaiting its report;
|
||||||
|
- not valid UTF-8, which a JSON report cannot carry.
|
||||||
|
|
||||||
In `observe` mode a request that `enforce` mode would refuse is answered
|
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 inspects the body a question carries only while
|
The Core Rule Set reads the body a question carries only while
|
||||||
`SWWAF_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it
|
`SWWAF_WAF_BODY_LIMIT` is set. It reads at most 64 KiB less one byte. So it does
|
||||||
does not inspect a JSON or XML body of 64 KiB or more, which may have been cut
|
not read a JSON or XML body of 64 KiB or more, which may have been cut short.
|
||||||
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 `servewaf` answered with `allow`, `deny` or `close`. It sends
|
for each request it passed to the app on an `allow`. The report carries no
|
||||||
none after a `400` or no answer. The report carries no `X-Smallwebwaf-Client`.
|
`X-Smallwebwaf-Client`. Its body is a JSON object with these fields, named as
|
||||||
Its body is one JSON object with all of these fields, named as the request log's
|
the request log's fields of the same meaning:
|
||||||
fields of the same meaning:
|
|
||||||
|
|
||||||
- `request_id`: the question's `X-Request-ID`;
|
- `request_id`: the `X-Request-ID` of the `allow`;
|
||||||
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
|
- `status`: what the visitor got, `0` if nothing;
|
||||||
- `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, each a whole number from `0` to `1125899906842624`
|
WebSocket carried each way;
|
||||||
(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, from `0` to `1000000000000` (about 31 years);
|
end of the answer;
|
||||||
- `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, in the same range, and `0` for a request not passed to the
|
end of the answer.
|
||||||
app.
|
|
||||||
|
|
||||||
`servewaf` answers `400` and writes a process warning when the body:
|
`servewaf` answers `204`, also for a request it does not hold, which it ignores.
|
||||||
|
It answers `400` to a body that is not such an object, has a negative byte
|
||||||
|
count, or is over 1 MiB. `rproxy` does not act on the answer.
|
||||||
|
|
||||||
- is not one JSON object, such as `null`, or an object followed by other data;
|
For a request it holds, `servewaf` then:
|
||||||
- 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;
|
- bans a client that passes a byte limit, and writes a process message naming
|
||||||
- adds the request with its status to the client's history, the anomaly counters
|
the request, since the request's log line has gone out;
|
||||||
and the metrics.
|
- adds the request with its status to the client's history and the anomaly
|
||||||
|
counters.
|
||||||
|
|
||||||
A denied request's counting is finished at its decision, so its report counts
|
`servewaf` holds each allowed request until its report comes, up to 10,000
|
||||||
nothing.
|
requests. Past that, the request asked about longest ago is dropped. A request
|
||||||
|
whose report never comes is in neither the client's history nor the metrics, and
|
||||||
`servewaf` holds each answered request until its report comes, up to 10,000
|
its bytes are not counted. A denied request is finished at its decision.
|
||||||
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
|
||||||
|
|
||||||
@@ -1205,7 +1155,8 @@ 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/metrics.md` says how its metrics count.
|
`docs/request-log.md` gives `servewaf`'s log line, and `docs/metrics.md` how its
|
||||||
|
metrics count.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -6,12 +6,8 @@ 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. These are not counted:
|
report gives. A report itself is not counted, and neither is a request whose
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
+13
-42
@@ -118,34 +118,19 @@ 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 lines
|
## `servewaf`'s line
|
||||||
|
|
||||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
`smallwebwaf servewaf` writes one line for each question, once it has answered
|
||||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
it, and one for each request to its own endpoints but a report. A report writes
|
||||||
|
no line. A question's line has the fields above, with these meanings where they
|
||||||
- `decision`: the line it writes once it has answered the question;
|
differ:
|
||||||
- `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 question's `X-Request-ID`.
|
- `request_id`: the id `servewaf` answers with on `allow`.
|
||||||
- `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.
|
||||||
@@ -157,25 +142,11 @@ It has the fields above, with these meanings where they 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. So `counts`
|
The bytes of an allowed request are counted when its report comes, after its
|
||||||
holds the client's bytes without this request's.
|
line. So `counts` holds the client's bytes without this request's. A report that
|
||||||
|
breaks a byte limit writes a process message instead of `limit_hit` on the line:
|
||||||
### The `report` line
|
`a report's bytes broke a byte limit`, with `request_id`, `client_ip`, `host`,
|
||||||
|
`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
@@ -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 answered requests awaiting their reports.
|
- `servewaf` holds at most 10,000 allowed requests awaiting their reports.
|
||||||
@@ -15,11 +15,9 @@ 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{
|
||||||
decision: decision{
|
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||||
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() {
|
||||||
|
|||||||
@@ -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.line.Method,
|
Method: rq.in.Method,
|
||||||
Host: rq.line.Host,
|
Host: rq.in.Host,
|
||||||
Path: rq.requestURI,
|
Path: rq.in.URL.RequestURI(),
|
||||||
Status: status,
|
Status: status,
|
||||||
UserAgent: rq.line.UserAgent,
|
UserAgent: rq.in.UserAgent(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-11
@@ -1,8 +1,8 @@
|
|||||||
// Package proxy runs the checks on each request and writes its request log
|
// Package proxy runs the checks on each request and writes one request log
|
||||||
// line. New passes each request it lets through to the app and the app's
|
// line for it. New passes each request it lets through to the app and the
|
||||||
// answer back, unchanged, within the size and time limits. NewServeWAF instead
|
// app's answer back, unchanged, within the size and time limits. NewServeWAF
|
||||||
// answers rproxy's question about each request, and takes rproxy's report once
|
// instead answers rproxy's question about each request, and takes rproxy's
|
||||||
// the visitor's answer has ended, which writes the request a second log line.
|
// report once the visitor's answer has ended.
|
||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -56,8 +56,7 @@ 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, and from servewaf a second
|
// RequestLog receives one JSON line per request.
|
||||||
// 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
|
||||||
@@ -273,10 +272,9 @@ type handler struct {
|
|||||||
rules *rules.Files
|
rules *rules.Files
|
||||||
coreRuleSet *waf.CoreRuleSet
|
coreRuleSet *waf.CoreRuleSet
|
||||||
alerts *alerts.Queue
|
alerts *alerts.Queue
|
||||||
// awaitingReport holds, in servewaf, the decision on each answered
|
// awaitingReport holds, in servewaf, each allowed request by its id
|
||||||
// question by its id until its report comes, and nil for a question still
|
// until its report comes.
|
||||||
// being handled.
|
awaitingReport *lru.Cache[string, *request]
|
||||||
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
|
||||||
|
|||||||
+27
-49
@@ -39,15 +39,20 @@ type refusal struct {
|
|||||||
limit string
|
limit string
|
||||||
}
|
}
|
||||||
|
|
||||||
// decision is what the checks note of a request: its log line, and what
|
// request is one request on its way through smallwebwaf, from the moment
|
||||||
// counting the request needs once it has ended. servewaf keeps a copy of it
|
// its headers have been read to its log line.
|
||||||
// until the request's report comes. It holds no credentials, since the log line
|
type request struct {
|
||||||
// never does, and neither the request's objects nor their bodies.
|
h *handler
|
||||||
type decision struct {
|
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
|
||||||
line requestlog.Line
|
line requestlog.Line
|
||||||
|
|
||||||
client netip.Addr
|
client netip.Addr
|
||||||
// requestURI is the path with the query, as a ban's notes keep it.
|
peer netip.Addr
|
||||||
requestURI string
|
peerTrusted bool
|
||||||
// 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
|
||||||
@@ -65,36 +70,17 @@ type decision 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, and wafBlocked for
|
// trap path, ruleBlocked for one a block rule refused, wafBlocked for
|
||||||
// one the Core Rule Set refused, each an offence its client's history
|
// one the Core Rule Set refused, and tokenRefused for one refused for a
|
||||||
// counts.
|
// missing or wrong token, each an offence its client's history counts.
|
||||||
attack, ruleBlocked, wafBlocked bool
|
attack, ruleBlocked, wafBlocked, tokenRefused 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.
|
||||||
@@ -150,9 +136,14 @@ func (h *handler) startRequest(
|
|||||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||||
|
|
||||||
rq := &request{
|
rq := &request{
|
||||||
decision: decision{
|
h: h,
|
||||||
|
in: r,
|
||||||
|
rc: http.NewResponseController(w),
|
||||||
|
out: &responseWriter{ResponseWriter: w},
|
||||||
client: client,
|
client: client,
|
||||||
requestURI: r.URL.RequestURI(),
|
peer: peer,
|
||||||
|
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,
|
||||||
@@ -175,14 +166,6 @@ 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.
|
||||||
@@ -608,12 +591,7 @@ func (rq *request) finish() {
|
|||||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.writeLine()
|
err := requestlog.Write(rq.h.requestLog, line)
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-202
@@ -1,11 +1,8 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -46,30 +43,21 @@ const (
|
|||||||
DispositionClose = "close"
|
DispositionClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportPath is where rproxy sends the report of each request servewaf
|
// ReportPath is where rproxy sends the report of each request it passed to
|
||||||
// answered.
|
// the app.
|
||||||
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 answered requests held until their
|
// maxAwaitingReport is the most allowed requests held until their
|
||||||
// reports come. Past it, the request answered longest ago is dropped.
|
// reports come. Past it, the request asked about 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.
|
// far above any report's, since the longest thing in one is the request
|
||||||
|
// 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.
|
||||||
@@ -77,15 +65,10 @@ 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 question's id. Status is what the visitor got, 0
|
// meaning. RequestID is the id servewaf's allow gave. Status is what the
|
||||||
// if nothing. The durations are in milliseconds.
|
// visitor got, 0 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 {
|
||||||
@@ -119,7 +102,7 @@ func NewServeWAF(params Params) *Server {
|
|||||||
|
|
||||||
h := newHandler(params)
|
h := newHandler(params)
|
||||||
|
|
||||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
awaitingReport, err := lru.New[string, *request](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
|
||||||
}
|
}
|
||||||
@@ -147,27 +130,26 @@ 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's counting
|
// carries, and answers rproxy what to do with it. A denied request is
|
||||||
// is finished here, and an allowed one's by its report.
|
// finished here. An allowed one is finished by its report. A question
|
||||||
|
// without a visitor's address gets no disposition, so that rproxy treats
|
||||||
|
// it as it treats no answer.
|
||||||
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||||
client, body, ok := h.readQuestion(w, r)
|
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
h.processLog.Warn("a question gives no visitor's address", "error", err.Error())
|
||||||
|
http.Error(w, "X-Smallwebwaf-Client is not an address", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
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, body), client.Unmap(), peer.Unmap(),
|
rq := h.startRequest(w, asTheVisitorSentIt(r), client.Unmap(), peer.Unmap(), true)
|
||||||
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)
|
||||||
@@ -199,70 +181,11 @@ 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, with
|
// log line are to see it: without the headers rproxy adds for servewaf, and
|
||||||
// body, which has been read of r, and with the length the visitor announced,
|
// with the length the visitor announced, as -1 when it announced none. The
|
||||||
// as -1 when it announced none. The question's own length covers only the part
|
// question's own length covers only the part of the body it carries.
|
||||||
// of the body it carries.
|
func asTheVisitorSentIt(r *http.Request) *http.Request {
|
||||||
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)
|
||||||
|
|
||||||
@@ -279,34 +202,55 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *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 and, while
|
// allow answers a question with DispositionAllow, the request's id and,
|
||||||
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
// while SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country,
|
||||||
|
// and holds the request until its report comes.
|
||||||
func (rq *request) allow() {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// holdForReport holds a copy of what the report's steps need, by the request's
|
// awaitReport holds the request by its id, which its report is to name. A
|
||||||
// id. A report only ever reaches that copy, never the request being handled.
|
// visitor behind a trusted proxy can choose its id, which must not let it
|
||||||
func (rq *request) holdForReport() {
|
// have its report taken for another request's, or lost, and its bytes not
|
||||||
held := rq.decision
|
// counted. So the request gets a new id when another held request has the
|
||||||
rq.h.awaitingReport.Add(rq.line.RequestID, &held)
|
// question's, or when that is not valid UTF-8, which a JSON report cannot
|
||||||
|
// carry unchanged.
|
||||||
|
func (rq *request) awaitReport() {
|
||||||
|
id := rq.line.RequestID
|
||||||
|
if !utf8.ValidString(id) {
|
||||||
|
id = rand.Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
held, _ := rq.h.awaitingReport.ContainsOrAdd(id, rq)
|
||||||
|
if held {
|
||||||
|
id = rand.Text()
|
||||||
|
rq.h.awaitingReport.Add(id, rq)
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.line.RequestID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
// deny answers a question with what the visitor is to get for r.
|
// deny answers a question with what the visitor is to get for r.
|
||||||
@@ -327,7 +271,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.
|
// rproxy and the admin's tools, not visitors. A report writes no log line.
|
||||||
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)
|
||||||
@@ -350,115 +294,57 @@ 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's decision, and ignores the report
|
// names, if it holds that request, and ignores the report otherwise.
|
||||||
// otherwise.
|
|
||||||
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||||
report, err := readReport(w, r)
|
var report Report
|
||||||
if err != nil {
|
|
||||||
h.processLog.Warn("a report is refused", "error", err.Error())
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)).
|
||||||
|
Decode(&report)
|
||||||
|
if err != nil || report.RequestBytes < 0 || report.ResponseBytes < 0 {
|
||||||
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
held, found := h.awaitingReport.Peek(report.RequestID)
|
rq, held := 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 found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
if held && h.awaitingReport.Remove(report.RequestID) {
|
||||||
h.finishWith(held, report)
|
rq.finishWith(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readReport reads a report from r's body, which must be one JSON object with
|
// finishWith finishes an allowed request with what its report says: the
|
||||||
// all of Report's fields, in the ranges SPEC.md gives.
|
// steps ServeHTTP takes once a request passed to the app has ended, with
|
||||||
func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
|
// the status and bytes of the report in place of those it would have seen.
|
||||||
var report Report
|
func (rq *request) finishWith(report Report) {
|
||||||
|
// A limit the question broke has been logged.
|
||||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes))
|
rq.line.LimitHit = ""
|
||||||
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)))
|
||||||
}
|
|
||||||
|
|
||||||
rq.writeLine()
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+103
-837
File diff suppressed because it is too large.
Load diff
@@ -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, or two from servewaf, marked "type":"request", and
|
// JSON object per request, marked "type":"request", and the process's own
|
||||||
// the process's own messages as JSON lines marked "type":"process".
|
// messages as JSON lines marked "type":"process".
|
||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -60,16 +60,6 @@ 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"
|
||||||
|
|
||||||
@@ -80,9 +70,6 @@ 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.
|
||||||
|
|||||||
@@ -2,95 +2,15 @@ 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) {
|
||||||
@@ -103,7 +23,6 @@ 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()
|
||||||
|
|||||||
Reference in new issue
Block a user