servewaf answers rproxy's question about each request and takes its report (closes #170)
check / check (push) Successful in 4m27s

`smallwebwaf servewaf` runs today's checks on the visitor's request a
question carries and answers allow, deny or close, instead of passing it
to an app. rproxy's report then counts the bytes toward the byte limits
and adds the request to the client's history. SPEC.md gives the exact
question, answer and report, which rproxy follows. `smallwebwaf` with no
command is unchanged.

Judgement call: a third disposition, close, since closing the question's
connection would read as servewaf down and let the request through.
Judgement call: allow can answer a new request id, since a visitor behind
a trusted proxy can choose its own.

Model: opus-5-5
This commit is contained in:
2026-11-04 19:41:12 +00:00
parent fdabf4974b
commit ed6ba53747
25 changed files with 1673 additions and 85 deletions
+5
View File
@@ -339,6 +339,9 @@ pinned.
[`docs/settings.md`](docs/settings.md)).
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
them across a deploy.
- `smallwebwaf servewaf` runs the same checks for the sites that ask it about
each request, and passes nothing to an app (see "`servewaf`" in
[`SPEC.md`](SPEC.md)).
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five
seconds after `SIGTERM`.
@@ -351,6 +354,8 @@ what each does.
## TODO
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes:
- `smallwebwaf rproxy`, the reverse proxy in front of each app that asks
`smallwebwaf servewaf` about each request;
- sharing bans between the instances of several apps;
- sending the log over RELP.
+157 -4
View File
@@ -47,14 +47,16 @@ to them.
- `internal/smallwebwaf`: the process. It reads the settings, the rule
files, the lookup database and the state files, listens, and serves until
`SIGTERM` or `SIGINT`. Then it stops, writing the state files. Run as
`smallwebwaf healthcheck`, it is the image's health check instead.
`smallwebwaf servewaf`, it answers `rproxy` instead of passing requests to
an app (see "`servewaf`"). Run as `smallwebwaf healthcheck`, it is the
image's health check instead.
- `internal/config`: reads the settings, the one place they are read.
- `internal/proxy`: what happens to each request. It works out the client
and runs the checks in `check`. It passes the request to the app with the
standard library's `httputil.ReverseProxy`, within the timeouts and size
limits, and answers its own endpoints in `answerAdmin`. Once the request
has ended, it counts bytes, refusals and anomalies, and writes the log
line.
line. It also holds `servewaf`'s question, answer and report.
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
refusals, and keeps each client's history.
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
@@ -1006,6 +1008,156 @@ answers.
costs).
- A ban made through the endpoints starts at once.
## `servewaf`
`smallwebwaf servewaf` runs the checks for `smallwebwaf rproxy`, a small reverse
proxy in front of each app. `rproxy` asks `servewaf` about each visitor's
request. `servewaf` answers `rproxy` and passes nothing to an app itself. One
`servewaf` serves many sites.
This section is the agreement between the two. The header names, the fields and
the statuses below are exact. Each `SWWAF_` setting and check keeps the meaning
this document gives it, except as this section says.
It starts as `smallwebwaf` with no command does. It reads the settings, the rule
files, the lookup database and the state files, and listens at
`SWWAF_LISTEN_ADDR`. It stops in the same orderly way, writing the state files.
`docs/settings.md` lists the settings it ignores.
### The question
A request carrying `X-Smallwebwaf-Client` is `rproxy` asking about one visitor's
request. It is the visitor's request as the visitor sent it:
- the method;
- the path and query as sent;
- `Host`;
- every header the visitor sent, including its own `X-Forwarded-For`;
- the first 64 KiB of the body, or all of it when shorter.
A body whose length the visitor did not announce comes chunked. Any other body
comes with a `Content-Length` of the part it carries.
`rproxy` removes these headers if the visitor sent them, then sets them. It adds
no other header:
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out.
`servewaf` takes it as the client, and groups an IPv6 address as "What happens
to a request" says.
- `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log
line's `peer_ip`.
- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is
absent when the visitor announced none.
- `X-Request-ID`: the request's id.
- `X-Forwarded-Proto`: the scheme.
Before any check, `servewaf` removes the three `X-Smallwebwaf-` headers. It also
sets `Content-Length` to the length the visitor announced, or removes it. So the
rule files and the Core Rule Set see the headers the visitor sent.
### The answer
`servewaf` runs steps 3 to 12 of "What happens to a request", in that order.
Every answer but the last below carries `X-Smallwebwaf-Disposition`.
- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the
request to the app. The answer also carries:
- `X-Request-ID`: the id `rproxy` passes to the app, logs and names in the
report;
- `X-Client-ASN` and `X-Client-Country`, with `SWWAF_ADD_LOOKUP_HEADERS`,
which `rproxy` adds to the request it passes on. A header whose value is
unknown is left out.
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the
visitor is to get. `rproxy` gives them to the visitor, less this header. The
status is one of:
- `SWWAF_BAN_RESPONSE`, for a refusal;
- `403`, for a `block` rule or the Core Rule Set;
- `404`, for a path under `/_smallwebwaf/` that passes the checks, so that
the app never sees that prefix.
- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
visitor's connection without an answer.
- No such header, status `400`, when `X-Smallwebwaf-Client` is not an address.
`rproxy` treats this answer as it treats no answer at all.
The `X-Request-ID` of an `allow` is the question's id, unless `servewaf` has to
make a new one. A visitor behind a trusted proxy can choose its id, so it makes
a new one when the question's id is:
- absent;
- the id of another request awaiting its report;
- not valid UTF-8, which a JSON report cannot carry.
In `observe` mode a request that `enforce` mode would refuse is answered
`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
still answered `404`.
The Core Rule Set reads the body a question carries only while
`SWWAF_WAF_BODY_LIMIT` is set. It reads at most 64 KiB less one byte. So it does
not read a JSON or XML body of 64 KiB or more, which may have been cut short.
### The report
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
for each request it passed to the app on an `allow`. The report carries no
`X-Smallwebwaf-Client`. Its body is a JSON object with these fields, named as
the request log's fields of the same meaning:
- `request_id`: the `X-Request-ID` of the `allow`;
- `status`: what the visitor got, `0` if nothing;
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way;
- `duration_total`: milliseconds from the request's headers being read to the
end of the answer;
- `duration_upstream_total`: milliseconds from the hand-over to the app to the
end of the answer.
`servewaf` answers `204`, also for a request it does not hold, which it ignores.
It answers `400` to a body that is not such an object, has a negative byte
count, or is over 1 MiB. `rproxy` does not act on the answer.
For a request it holds, `servewaf` then:
- counts the bytes as `SWWAF_BYTES_COUNT` says, toward the client's byte limits
at the percentages it gave that request;
- bans a client that passes a byte limit, and writes a process message naming
the request, since the request's log line has gone out;
- adds the request with its status to the client's history and the anomaly
counters.
`servewaf` holds each allowed request until its report comes, up to 10,000
requests. Past that, the request asked about longest ago is dropped. A request
whose report never comes is in neither the client's history nor the metrics, and
its bytes are not counted. A denied request is finished at its decision.
### `servewaf`'s own endpoints
A request without `X-Smallwebwaf-Client` is for `servewaf` itself, at its own
address on the private network. Nothing routes the internet there. These are:
- the health check, the metrics, and the ban and client endpoints, as
`docs/admin-endpoints.md` lists them, with the same tokens;
- the report.
Anything else gets `404`. No check runs on these requests, since their peers are
`rproxy` and the admin's tools. The report needs no token, so anything on the
private network can send one or ask a question.
`servewaf` gives each request 60 seconds to arrive and its answer 60 seconds to
leave, and closes a connection idle for 120 seconds. It takes a request line and
headers up to 1 MiB, Go's default, so that it never refuses a question for the
size of a visitor's headers.
### Sites
One `servewaf` keeps one table of clients, counters and bans for every site that
asks it. A client banned through one site is refused on all of them, and its
requests on all of them count toward one set of limits. Log lines, alerts raised
by a request and ban notes name the site by its host.
`docs/request-log.md` gives `servewaf`'s log line, and `docs/metrics.md` how its
metrics count.
## Deployment
`smallwebwaf` runs inside the app's own container: the app's Dockerfile builds
@@ -1171,9 +1323,10 @@ and `script/gitea-requests` checks those notes.
## Planned, not built
These two points are specified as later work, and nothing in the code does them
yet:
These points are specified as later work, and nothing in the code does them yet:
- Planned: `smallwebwaf rproxy`, the reverse proxy in front of each app that
asks `servewaf` about each request (see "`servewaf`").
- Planned: sharing bans between the instances of several apps, by running one
CrowdSec engine per host that every instance reports its bans to. It is
reconsidered once real ban volumes are known. Until then each instance keeps
+2 -1
View File
@@ -2,7 +2,8 @@
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says which checks they go through
and how their tokens work.
and how their tokens work. `smallwebwaf servewaf` answers them at its own
address, without those checks (see "`servewaf`" in [`SPEC.md`](../SPEC.md)).
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
+4 -1
View File
@@ -78,6 +78,9 @@ on one line:
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above.
- `host`: the site of the request that raised the alert. Only `servewaf` sends
this field, since it serves many sites, and not for `source_failure`,
`file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
@@ -110,7 +113,7 @@ Slack and ntfy get a message with a title and a text. The title is the instance
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one
line for each of these that the alert has:
- the client, the netblock and the country;
- the host, the client, the netblock and the country;
- the file, the source, the error and the mode from `detail`;
- the suppressed repeats, if any.
+5
View File
@@ -4,6 +4,11 @@ What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance`
label as `exported_instance` unless the scrape sets `honor_labels: true`.
`smallwebwaf servewaf` counts a denied request at its decision. It counts an
allowed request when its report comes, with the status, bytes and times the
report gives. A report itself is not counted, and neither is a request whose
report never comes.
## Traffic
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
+33
View File
@@ -117,3 +117,36 @@ All in milliseconds:
connection;
- `duration_upstream_first_byte`: until the first byte of the answer;
- `duration_upstream_total`: until the end of the answer.
## `servewaf`'s line
`smallwebwaf servewaf` writes one line for each question, once it has answered
it, and one for each request to its own endpoints but a report. A report writes
no line. A question's line has the fields above, with these meanings where they
differ:
- `client_ip`: `X-Smallwebwaf-Client`.
- `peer_ip`: `X-Smallwebwaf-Peer`.
- `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`.
- `content_length`: `X-Smallwebwaf-Content-Length`.
- `request_id`: the id `servewaf` answers with on `allow`.
- `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`,
or `0` for `close`, as the visitor is then sent nothing.
- `request_bytes`: how much of the question's body the Core Rule Set read.
- `response_bytes`: the body of `servewaf`'s answer.
- `action`: `forward` for `allow`, and `admin` for the `404` for a path under
`/_smallwebwaf/`.
- `aborted`: true when `rproxy` went away before the answer.
- `duration_total`: from the question's headers being read to the line.
The line leaves out `upstream_status`, `websocket` and the
`duration_upstream_...` fields, since `servewaf` passes nothing to the app.
`rproxy` holds the visitor's status, the body bytes and the times of the app's
answer. The request id joins the two.
The bytes of an allowed request are counted when its report comes, after its
line. So `counts` holds the client's bytes without this request's. A report that
breaks a byte limit writes a process message instead of `limit_hit` on the line:
`a report's bytes broke a byte limit`, with `request_id`, `client_ip`, `host`,
`limit_hit` and `ban_expires`.
+19 -1
View File
@@ -4,6 +4,23 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why.
## `servewaf`
`smallwebwaf servewaf` uses every setting here but these, which it ignores:
- `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`;
- the five timeouts under "Size and time limits";
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_REQUEST_MAX_BYTES` and
`SWWAF_RESPONSE_MAX_BYTES`.
An invalid value of one of these still stops the start, and the line of settings
at start still shows them.
`SWWAF_ADD_LOOKUP_HEADERS` puts the headers on the answer to `rproxy`, not on a
request to an app. `SWWAF_WAF_BODY_LIMIT` has an effect up to 64 KiB less one
byte, whatever its value. A question carries no more of a body than 64 KiB (see
"`servewaf`" in [`SPEC.md`](../SPEC.md)).
## Forms of values
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
@@ -327,4 +344,5 @@ A few limits are fixed rather than settings:
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
- at most 1,000 DNS blocklist queries are under way at once;
- 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.
+11 -4
View File
@@ -150,13 +150,16 @@ type Params struct {
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
// Country are, for a ban, the client's as the ban's notes give them.
// Country are, for a ban, the client's as the ban's notes give them. Host is
// the site of the request that raised the alert, while one servewaf serves
// many sites, and "" otherwise.
//
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
type Alert struct {
Instance string `json:"instance"`
Time time.Time `json:"time"`
Event string `json:"event"`
Host string `json:"host,omitempty"`
Client netip.Addr `json:"client"`
Netblock netip.Prefix `json:"netblock"`
ASN string `json:"asn"`
@@ -899,12 +902,16 @@ func title(alert *Alert) string {
}
// text returns the text of alert in Slack and ntfy: its reason, then a
// line for each of its client, netblock and country, the file, source,
// error and mode its detail gives, and its suppressed repeats, that it
// has.
// line for each of its host, client, netblock and country, the file,
// source, error and mode its detail gives, and its suppressed repeats, that
// it has.
func text(alert *Alert) string {
lines := []string{alert.Reason}
if alert.Host != "" {
lines = append(lines, "host: "+alert.Host)
}
if alert.Client.IsValid() {
lines = append(lines, "client: "+alert.Client.String())
}
+3 -1
View File
@@ -1284,9 +1284,11 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
Reason: "refused by the Core Rule Set",
Host: "app.example",
},
"fsn1app1/gitea: waf_block", "default shield",
"refused by the Core Rule Set\nclient: 192.0.2.1\nnetblock: 192.0.2.1/32",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32",
},
{
alerts.Alert{
+3
View File
@@ -103,6 +103,8 @@ type Counter struct {
// Request is a request that has ended, as the counters count it.
type Request struct {
// Host is the site the alert names, "" for none.
Host string
// Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX its address is in.
@@ -396,6 +398,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
return alerts.Alert{
Event: alerts.EventAnomaly,
Host: r.Host,
Client: r.Client,
Netblock: k.netblock,
ASN: r.ASN,
+40 -20
View File
@@ -403,28 +403,21 @@ func (m *Metrics) RequestEnded(
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
) {
m.inFlight.Dec()
m.count(line, limit, duration, upstreamDuration)
}
class := statusClass(line.Status)
m.requests.WithLabelValues(class, line.Action).Inc()
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
m.requestDuration.Observe(duration.Seconds())
// RequestAllowed counts a question of rproxy's, answered allow, as no longer
// under way. RequestReported counts the request it was about.
func (m *Metrics) RequestAllowed() {
m.inFlight.Dec()
}
if upstreamDuration > 0 {
m.upstreamDuration.Observe(upstreamDuration.Seconds())
}
if limit != "" {
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
}
if line.Country != "" {
m.countries.add(line.Country, line)
}
if line.ASN != "" {
m.asns.add(line.ASN, line)
}
// RequestReported counts a request rproxy passed to the app, as RequestEnded
// does, from what its report says, which line holds.
func (m *Metrics) RequestReported(
line *requestlog.Line, duration, upstreamDuration time.Duration,
) {
m.count(line, "", duration, upstreamDuration)
}
// LimitHit counts a request that broke a rate limit, a byte limit or the
@@ -493,6 +486,33 @@ func (m *Metrics) StateFileEditSetAside(name string) {
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
}
// count counts a request that has ended, as RequestEnded describes.
func (m *Metrics) count(
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
) {
class := statusClass(line.Status)
m.requests.WithLabelValues(class, line.Action).Inc()
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
m.requestDuration.Observe(duration.Seconds())
if upstreamDuration > 0 {
m.upstreamDuration.Observe(upstreamDuration.Seconds())
}
if limit != "" {
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
}
if line.Country != "" {
m.countries.add(line.Country, line)
}
if line.ASN != "" {
m.asns.add(line.ASN, line)
}
}
// addReputationQueries adds the counter of the queries to source, a DNSBL
// zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
+9
View File
@@ -41,6 +41,15 @@ var (
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
)
// answerHealth answers a request for the health endpoint.
func (rq *request) answerHealth() {
rq.line.Action = requestlog.ActionAdmin
// Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(rq.out, "ok\n")
}
// answerAdmin answers a request for smallwebwaf itself, under
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
// token, sent as Authorization: Bearer <token>: the metrics
+1
View File
@@ -323,6 +323,7 @@ func (rq *request) alertBan(ban bans.Ban) {
rq.h.alerts.Raise(alerts.Alert{
Event: event,
Host: rq.site,
Client: rq.client,
Netblock: ban.Netblock,
ASN: ban.Notes.ASN,
+3 -1
View File
@@ -25,6 +25,8 @@ const (
userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent"
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
banResponseClose = "close"
)
func TestBrokenLimitBansTheClient(t *testing.T) {
@@ -237,7 +239,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
{"", http.StatusForbidden},
{"403", http.StatusForbidden},
{"429", http.StatusTooManyRequests},
{"close", 0},
{banResponseClose, 0},
} {
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
t.Parallel()
+1
View File
@@ -113,6 +113,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventWAFBlock,
Host: rq.site,
Client: rq.client,
Netblock: rq.h.clientGroup(rq.client),
ASN: rq.line.ASN,
+1 -1
View File
@@ -207,7 +207,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
rateLimitPerMinute: "1",
rateLimitExemptNets: scraper,
denyNets: denied,
banResponse: "close",
banResponse: banResponseClose,
limitBanDuration: "1h",
maxBanDuration: "2h",
})
+49 -32
View File
@@ -1,6 +1,8 @@
// Package proxy passes each request to the app and the app's answer back,
// unchanged, within the size and time limits, and writes one request log
// line for each request.
// Package proxy runs the checks on each request and writes one request log
// line for it. New passes each request it lets through to the app and the
// app's answer back, unchanged, within the size and time limits. NewServeWAF
// instead answers rproxy's question about each request, and takes rproxy's
// report once the visitor's answer has ended.
package proxy
import (
@@ -11,6 +13,8 @@ import (
"strings"
"time"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/bans"
@@ -19,7 +23,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
@@ -109,6 +112,23 @@ type Server struct {
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
// applies the timeouts and size limits from then on.
func New(params Params) *Server {
h := newHandler(params)
return h.server(&http.Server{
Handler: h,
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
// Off is an IdleTimeout of 0, which Go's server replaces with
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
IdleTimeout: params.Config.ClientIdleTimeout,
// Go's server reads 4 KiB past MaxHeaderBytes before it
// refuses, so the limit a client meets is the setting.
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
})
}
// newHandler returns the handler with what every request shares, as the
// settings in params make it.
func newHandler(params Params) *handler {
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
lists, dnsbl, abuseIPDB := newReputation(params, m)
@@ -171,29 +191,7 @@ func New(params Params) *Server {
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
m.AddRules(params.Rules)
return &Server{
Server: &http.Server{
Addr: params.Config.ListenAddr,
Handler: h,
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
// Off is an IdleTimeout of 0, which Go's server replaces with
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
IdleTimeout: params.Config.ClientIdleTimeout,
// Go's server reads 4 KiB past MaxHeaderBytes before it
// refuses, so the limit a client meets is the setting.
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
ErrorLog: errorLog,
},
Ledger: h.ledger,
Limiter: h.limiter,
GeoJS: h.geojs,
Anomalies: h.anomalies,
LookupFile: h.lookupFile,
Lists: h.lists,
DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
Metrics: m,
}
return h
}
// newReputation returns the lists fetched from URLs, the DNSBL zones'
@@ -274,6 +272,9 @@ type handler struct {
rules *rules.Files
coreRuleSet *waf.CoreRuleSet
alerts *alerts.Queue
// awaitingReport holds, in servewaf, each allowed request by its id
// until its report comes.
awaitingReport *lru.Cache[string, *request]
}
// newTransport returns what carries requests to the app. It never goes
@@ -299,11 +300,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The health endpoint is answered at once, before any check, so that
// a health checker is never refused. It does not ask the app.
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
rq.line.Action = requestlog.ActionAdmin
// Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(rq.out, "ok\n")
rq.answerHealth()
return
}
@@ -339,3 +336,23 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq.forward(r.Context())
}
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
// logs through h, with what httpServer sets.
func (h *handler) server(httpServer *http.Server) *Server {
httpServer.Addr = h.config.ListenAddr
httpServer.ErrorLog = h.errorLog
return &Server{
Server: httpServer,
Ledger: h.ledger,
Limiter: h.limiter,
GeoJS: h.geojs,
Anomalies: h.anomalies,
LookupFile: h.lookupFile,
Lists: h.lists,
DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB,
Metrics: h.metrics,
}
}
+11 -1
View File
@@ -284,6 +284,16 @@ func newProxy(
) (*proxy.Server, *output, *alerts.Queue) {
t.Helper()
return newServerWith(t, proxy.New, appURL, geojsURL, now, env)
}
// newServerWith is newProxy for the server build makes.
func newServerWith(
t *testing.T, build func(proxy.Params) *proxy.Server,
appURL, geojsURL string, now func() time.Time, env map[string]string,
) (*proxy.Server, *output, *alerts.Queue) {
t.Helper()
settings := map[string]string{
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
wafMode: off,
@@ -334,7 +344,7 @@ func newProxy(
}
}
server := proxy.New(proxy.Params{
server := build(proxy.Params{
Config: cfg,
RequestLog: out,
ProcessLog: processLog,
+1
View File
@@ -110,6 +110,7 @@ func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
rq.h.metrics.ReputationHit(hit.Source)
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventReputationHit,
Host: rq.site,
Client: rq.client,
Netblock: rq.h.clientGroup(rq.client),
ASN: rq.line.ASN,
+29 -9
View File
@@ -53,6 +53,11 @@ type request struct {
client netip.Addr
peer netip.Addr
peerTrusted bool
// site is, in servewaf, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. It is "" otherwise.
site string
// allowed is true for a question answered with DispositionAllow.
allowed bool
// lookedUp is true once the client's AS number and country have been
// looked up, whether or not an answer was there, and lookupAnswer is
// what the lookup gave then, the zero Answer while GeoJS had given none.
@@ -108,18 +113,27 @@ type request struct {
answerStarted time.Time
}
// newRequest starts handling r: it notes the time, counts the request as
// under way, works out the client, and starts the log line with what is
// known of the request.
// newRequest starts handling r: it works out the client from the TCP peer
// and X-Forwarded-For, and goes on as startRequest does.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
return h.startRequest(w, r, client, peer, isInside(peer, trusted))
}
// startRequest starts handling r from client, which came through peer: it
// notes the time, counts the request as under way, and starts the log line
// with what is known of the request. peerTrusted says whether to believe
// the request's id and scheme, which r's headers give.
func (h *handler) startRequest(
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, peerTrusted bool,
) *request {
h.metrics.RequestStarted()
start := time.Now()
peer := peerAddress(r)
trusted := h.config.TrustedProxies
peerTrusted := isInside(peer, trusted)
forwardedFor := r.Header.Values("X-Forwarded-For")
client := clientAddress(peer, forwardedFor, trusted)
rq := &request{
h: h,
@@ -569,8 +583,13 @@ func (rq *request) finish() {
}
// Counted before the log line is written, so that the metrics count
// every request whose line is out.
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
// every request whose line is out. An allowed request is the exception:
// its report gives the status, bytes and times the metrics count.
if rq.allowed {
rq.h.metrics.RequestAllowed()
} else {
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
}
err := requestlog.Write(rq.h.requestLog, line)
if err != nil {
@@ -634,6 +653,7 @@ func (rq *request) countAnomalies() {
answer, _ := rq.answerAtTheEnd()
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
Host: rq.site,
Client: rq.client,
ClientGroup: rq.h.clientGroup(rq.client),
ASN: answer.ASN,
+350
View File
@@ -0,0 +1,350 @@
package proxy
import (
"crypto/rand"
"encoding/json"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"unicode/utf8"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The headers of rproxy's question and of servewaf's answer, as
// "servewaf" in SPEC.md gives them.
const (
// ClientHeader marks a request as rproxy's question, and gives the
// visitor's address.
ClientHeader = "X-Smallwebwaf-Client"
// PeerHeader gives the address of rproxy's own TCP peer.
PeerHeader = "X-Smallwebwaf-Peer"
// ContentLengthHeader gives the body length the visitor announced.
ContentLengthHeader = "X-Smallwebwaf-Content-Length"
// DispositionHeader gives servewaf's answer.
DispositionHeader = "X-Smallwebwaf-Disposition"
)
// The values of DispositionHeader.
const (
// DispositionAllow tells rproxy to pass the request to the app.
DispositionAllow = "allow"
// DispositionDeny tells rproxy to give the visitor the answer's status,
// headers and body.
DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
// servewaf closing the question's connection instead would read as
// servewaf being down, and rproxy would then let the request through.
DispositionClose = "close"
)
// ReportPath is where rproxy sends the report of each request it passed to
// the app.
const ReportPath = "/_smallwebwaf/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10
const (
// maxAwaitingReport is the most allowed requests held until their
// reports come. Past it, the request asked about longest ago is dropped.
maxAwaitingReport = 10000
// reportBodyMaxBytes is the most of a report's body that is read. It is
// far above any report's, since the longest thing in one is the request
// id, which comes from a header.
reportBodyMaxBytes = 1 << 20
// serveWAFTimeout bounds how long a request to servewaf takes to arrive,
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open
// connection waits for its next request.
serveWAFTimeout = 60 * time.Second
serveWAFIdleTimeout = 120 * time.Second
)
// Report is the JSON object rproxy posts to ReportPath once the visitor's
// answer has ended, its fields named as the request log's of the same
// meaning. RequestID is the id servewaf's allow gave. Status is what the
// visitor got, 0 if nothing. The durations are in milliseconds.
//
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Report struct {
RequestID string `json:"request_id"`
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
DurationTotal float64 `json:"duration_total"`
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
}
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers
// rproxy's questions with the checks New runs, takes rproxy's reports and
// answers its own endpoints. It passes nothing to an app. rproxy applies
// the size and time limits to the visitor, so their settings do not apply
// here. Go's default limit on a request's line and headers, 1 MiB, stays:
// it is far above rproxy's, and a question refused for its size would have
// rproxy let the request through.
func NewServeWAF(params Params) *Server {
cfg := *params.Config
cfg.RequestMaxBytes = 0
cfg.ClientRequestTimeout = serveWAFTimeout
// The server's WriteTimeout does this. It sets a new deadline for each
// request, where a deadline a handler sets stays on a kept-open
// connection, and would run out under a later request's answer.
cfg.ClientResponseTimeout = 0
// A question's body of QuestionBodyMaxBytes may be cut short, and the
// Core Rule Set does not read a JSON or XML body over its limit.
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
params.Config = &cfg
h := newHandler(params)
awaitingReport, err := lru.New[string, *request](maxAwaitingReport)
if err != nil {
panic(err) // New fails only for a size below one
}
h.awaitingReport = awaitingReport
return h.server(&http.Server{
Handler: http.HandlerFunc(h.serveWAF),
ReadTimeout: serveWAFTimeout,
WriteTimeout: serveWAFTimeout,
IdleTimeout: serveWAFIdleTimeout,
})
}
// serveWAF handles one request to servewaf: a request carrying
// ClientHeader is rproxy's question, and any other is for servewaf itself.
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 {
h.answerQuestion(w, r)
return
}
h.answerOwnEndpoint(w, r)
}
// answerQuestion runs the checks on the visitor's request a question
// carries, and answers rproxy what to do with it. A denied request is
// finished here. An allowed one is finished by its report. A question
// without a visitor's address gets no disposition, so that rproxy treats
// it as it treats no answer.
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
if err != nil {
h.processLog.Warn("a question gives no visitor's address", "error", err.Error())
http.Error(w, "X-Smallwebwaf-Client is not an address", http.StatusBadRequest)
return
}
// One that cannot be read leaves the log line's peer_ip invalid.
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
rq := h.startRequest(w, asTheVisitorSentIt(r), client.Unmap(), peer.Unmap(), true)
defer rq.finish()
rq.site = r.Host
refused := rq.check(r.Context())
rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here.
defer rq.countRefusal()
rq.allow()
return
}
// The last deferred runs first, as in ServeHTTP.
defer rq.addToHistory()
defer rq.countAnomalies()
defer rq.countRefusal()
if refused != nil {
rq.deny(*refused)
return
}
// The app never sees this prefix. servewaf's own endpoints are at its
// own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionAdmin
rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
}
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for servewaf, and
// with the length the visitor announced, as -1 when it announced none. The
// question's own length covers only the part of the body it carries.
func asTheVisitorSentIt(r *http.Request) *http.Request {
sent := r.Clone(r.Context())
announced := sent.Header.Get(ContentLengthHeader)
sent.Header.Del(ClientHeader)
sent.Header.Del(PeerHeader)
sent.Header.Del(ContentLengthHeader)
sent.Header.Del("Content-Length")
sent.ContentLength = -1
length, err := strconv.ParseInt(announced, 10, 64)
if err == nil && length >= 0 {
sent.ContentLength = length
sent.Header.Set("Content-Length", announced)
}
return sent
}
// allow answers a question with DispositionAllow, the request's id and,
// while SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country,
// and holds the request until its report comes.
func (rq *request) allow() {
rq.allowed = true
// servewaf sends no app this part of the body, and the table of requests
// awaiting their reports must not hold it.
if rq.body != nil {
rq.body.readByCoreRuleSet = nil
}
rq.awaitReport()
header := rq.out.Header()
header.Set(DispositionHeader, DispositionAllow)
header.Set(requestIDHeader, rq.line.RequestID)
if rq.h.config.AddLookupHeaders {
setLookupHeaders(header, rq.line.ASN, rq.line.Country)
}
// Go's server sends this only once the handler has returned, so a report
// that answers it cannot come while the handler still uses rq.
rq.out.WriteHeader(http.StatusOK)
}
// awaitReport holds the request by its id, which its report is to name. A
// visitor behind a trusted proxy can choose its id, which must not let it
// have its report taken for another request's, or lost, and its bytes not
// counted. So the request gets a new id when another held request has the
// question's, or when that is not valid UTF-8, which a JSON report cannot
// carry unchanged.
func (rq *request) awaitReport() {
id := rq.line.RequestID
if !utf8.ValidString(id) {
id = rand.Text()
}
held, _ := rq.h.awaitingReport.ContainsOrAdd(id, rq)
if held {
id = rand.Text()
rq.h.awaitingReport.Add(id, rq)
}
rq.line.RequestID = id
}
// deny answers a question with what the visitor is to get for r.
func (rq *request) deny(r refusal) {
if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
// status stays 0: the visitor is sent nothing.
rq.refused.CompareAndSwap(nil, &r)
rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
return
}
rq.out.Header().Set(DispositionHeader, DispositionDeny)
rq.answer(r)
}
// answerOwnEndpoint answers a request to servewaf's own address. No check
// runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors. A report writes no log line.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == ReportPath {
h.takeReport(w, r)
return
}
peer := peerAddress(r)
rq := h.startRequest(w, r, peer, peer, false)
defer rq.finish()
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
rq.answerHealth()
return
}
rq.answerAdmin()
}
// takeReport answers POST ReportPath: it finishes the request the report
// names, if it holds that request, and ignores the report otherwise.
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
var report Report
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes)).
Decode(&report)
if err != nil || report.RequestBytes < 0 || report.ResponseBytes < 0 {
http.Error(w, "the body is not a report", http.StatusBadRequest)
return
}
rq, held := h.awaitingReport.Peek(report.RequestID)
// Remove reports true to one report only, of several that name the id.
if held && h.awaitingReport.Remove(report.RequestID) {
rq.finishWith(report)
}
w.WriteHeader(http.StatusNoContent)
}
// finishWith finishes an allowed request with what its report says: the
// steps ServeHTTP takes once a request passed to the app has ended, with
// the status and bytes of the report in place of those it would have seen.
func (rq *request) finishWith(report Report) {
// A limit the question broke has been logged.
rq.line.LimitHit = ""
// addToHistory goes by this for whether the request reached the app.
rq.upstreamStart = time.Now()
rq.out = &responseWriter{status: report.Status, bytes: report.ResponseBytes}
rq.body = &requestBody{}
rq.body.bytes.Store(report.RequestBytes)
rq.countBytes()
rq.countAnomalies()
rq.addToHistory()
rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes
rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotal*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
// The request's log line went out with the answer to its question.
if rq.line.LimitHit != "" {
rq.h.processLog.Warn("a report's bytes broke a byte limit",
"request_id", rq.line.RequestID,
"client_ip", rq.line.ClientIP,
"host", rq.line.Host,
"limit_hit", rq.line.LimitHit,
"ban_expires", rq.line.BanExpires)
}
}
+777
View File
@@ -0,0 +1,777 @@
package proxy_test
import (
"bytes"
"encoding/json"
"maps"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
const (
// siteA and siteB are two sites behind one servewaf.
siteA = "a.example"
siteB = "b.example"
// rproxyPeer is what the tests' questions give as rproxy's TCP peer.
rproxyPeer = "172.18.0.2"
)
// serveWAF is a servewaf running for one test, on a clock set to midnight.
type serveWAF struct {
t *testing.T
addr string
out *output
server *proxy.Server
clock *clock
queue *alerts.Queue
}
// startServeWAF starts servewaf with the settings in env, clients' AS numbers
// and countries looked up at geojsURL.
func startServeWAF(t *testing.T, geojsURL string, env map[string]string) *serveWAF {
t.Helper()
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
settings := map[string]string{
alertWebhookURL: "https://alerts.example/servewaf",
instanceName: alertInstance,
}
maps.Copy(settings, env)
server, out, queue := newServerWith(t, proxy.NewServeWAF,
"http://127.0.0.1:8081", geojsURL, clk.Now, settings)
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
_ = server.Close()
})
return &serveWAF{
t: t, addr: listener.Addr().String(), out: out, server: server,
clock: clk, queue: queue,
}
}
// question makes rproxy's question about a request for path on host, from the
// visitor at from.
func (s *serveWAF) question(host, from, path string) *http.Request {
s.t.Helper()
req := newRequest(s.t, http.MethodGet, s.addr, path, http.NoBody)
req.Host = host
req.Header.Set(proxy.ClientHeader, from)
req.Header.Set(proxy.PeerHeader, rproxyPeer)
return req
}
// ask sends the question about a request for path on siteA from the visitor at
// from, and returns the answer.
func (s *serveWAF) ask(from, path string) answer {
s.t.Helper()
return do(s.t, s.question(siteA, from, path))
}
// allowed asks about a request for / on siteA, checks that the answer is allow,
// and returns the id the answer gives for the report.
func (s *serveWAF) allowed(from string) string {
s.t.Helper()
got := s.ask(from, "/")
wantDisposition(s.t, got, http.StatusOK, proxy.DispositionAllow)
return got.header.Get(requestIDHeader)
}
// report sends rproxy's report and returns the answer.
func (s *serveWAF) report(report proxy.Report) answer {
s.t.Helper()
body, err := json.Marshal(report)
if err != nil {
s.t.Fatalf("encode the report: %v", err)
}
return do(s.t, newRequest(s.t, http.MethodPost, s.addr, proxy.ReportPath,
bytes.NewReader(body)))
}
// passedOn asks about a request from the visitor at from on siteA, and reports
// it as answered 200 with bodyBytes and answerBytes.
func (s *serveWAF) passedOn(from string) {
s.t.Helper()
got := s.report(proxy.Report{
RequestID: s.allowed(from),
Status: http.StatusOK,
RequestBytes: bodyBytes,
ResponseBytes: answerBytes,
})
wantStatus(s.t, got, http.StatusNoContent)
}
// history returns what the table of clients holds of client's history.
func (s *serveWAF) history() ratelimit.History {
held, _ := s.server.Limiter.Client(netip.MustParsePrefix(client + "/32"))
return held.History
}
// metrics returns what the metrics endpoint answers, for a server started with
// the token as SWWAF_METRICS_TOKEN.
func (s *serveWAF) metrics() string {
s.t.Helper()
req := newRequest(s.t, http.MethodGet, s.addr, proxy.MetricsPath, http.NoBody)
req.Header.Set("Authorization", bearer)
got := do(s.t, req)
wantStatus(s.t, got, http.StatusOK)
return string(got.body)
}
// wantDisposition checks an answer's status and disposition.
func wantDisposition(t *testing.T, got answer, status int, disposition string) {
t.Helper()
wantStatus(t, got, status)
if answered := got.header.Get(proxy.DispositionHeader); answered != disposition {
t.Errorf("answered with disposition %q, want %q", answered, disposition)
}
}
// wantBody checks an answer's body.
func wantBody(t *testing.T, got answer, want string) {
t.Helper()
if string(got.body) != want {
t.Errorf("answered with the body %q, want %q", got.body, want)
}
}
func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/page?q=1")
req.Header.Set(requestIDHeader, "the-id")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set(proxy.ContentLengthHeader, "1234")
got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
wantBody(t, got, "")
if id := got.header.Get(requestIDHeader); id != "the-id" {
t.Errorf("answered with the id %q, want the-id", id)
}
line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.ClientIP != client || line.PeerIP != rproxyPeer || line.Host != siteA ||
line.Path != "/page" || line.Query != "q=1" || line.RequestID != "the-id" ||
line.Scheme != "https" || line.ContentLength != 1234 {
t.Errorf("log line is %+v", line.Line)
}
}
func TestIPv6VisitorIsCountedInItsGroup(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
s.allowed("2001:db8::1")
got := s.ask("2001:db8::2", "/")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
}
func TestQuestionWithoutAnAddressGetsNoDisposition(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
got := s.ask("not an address", "/")
wantDisposition(t, got, http.StatusBadRequest, "")
}
func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusForbidden, http.StatusTooManyRequests} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
denyNets: client, banResponse: strconv.Itoa(status),
})
got := s.ask(client, "/")
wantDisposition(t, got, status, proxy.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
})
}
}
func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
denyNets: client, banResponse: banResponseClose, metricsToken: token,
})
got := s.ask(client, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionClose)
wantBody(t, got, "")
// The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
wantMetric(t, s.metrics(), `smallwebwaf_requests_total{action="denied",instance="`+
alertInstance+`",status_class="none"}`, 1)
}
func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
banResponse: "429",
})
got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionRuleBlocked)
got = s.ask(client, "/.env")
wantDisposition(t, got, http.StatusTooManyRequests, proxy.DispositionDeny)
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
requestlog.ActionBanned)
}
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
metricsToken: token, denyNets: otherClient,
})
for _, path := range []string{proxy.HealthPath, proxy.MetricsPath, proxy.ReportPath} {
got := s.ask(client, path)
wantDisposition(t, got, http.StatusNotFound, proxy.DispositionDeny)
}
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
got := s.ask(otherClient, proxy.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
}
func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{mode: observe, denyNets: client})
got := s.ask(client, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
}
func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
setting string
wantASN, wantCountry string
}{
{"true", "AS64496", "DE"},
{"false", "", ""},
} {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
})
got := s.ask(fromDE, "/")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != tc.wantASN || country != tc.wantCountry {
t.Errorf("answered with the AS number %q and the country %q, want %q and %q",
asn, country, tc.wantASN, tc.wantCountry)
}
})
}
}
func TestReportCountsTowardAByteLimitWhichThenBans(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
// The request's own question counts no bytes: it is the report that does.
id := s.allowed(client)
s.allowed(client)
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
t.Fatalf("banned before any report: %v", held)
}
// 100 bytes are over the limit of 99.
got := s.report(proxy.Report{
RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
})
wantStatus(t, got, http.StatusNoContent)
held := s.server.Ledger.Snapshot()
if len(held) != 1 || held[0].Cause != bans.CauseLimit ||
held[0].Notes.Kind != ratelimit.KindBytes ||
held[0].Notes.Request.Status != http.StatusBadGateway ||
held[0].Notes.Request.Host != siteA {
t.Errorf("bans held are %+v", held)
}
got = s.ask(client, "/")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
if !strings.Contains(s.out.text(), `"msg":"a report's bytes broke a byte limit"`) {
t.Error("no message says that a report's bytes broke a byte limit")
}
}
func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
// 100 bytes pass half of a limit of 99 and are within a limit of 199.
s := startServeWAF(t, geojsURL, map[string]string{
bytesLimitPerMinute: "199", countryBytesPercent: countryDEHalf,
lookupTimeout: "1h",
})
s.passedOn(fromKP)
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, proxy.DispositionAllow)
s.passedOn(fromDE)
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, proxy.DispositionDeny)
}
func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
"SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE": "1000",
})
id := s.allowed(client)
if requests := s.history().Requests; requests != 0 {
t.Errorf("history holds %d requests before the report, want none", requests)
}
if counters := s.server.Anomalies.Snapshot(); len(counters) != 0 {
t.Errorf("anomaly counters %v before the report, want none", counters)
}
got := s.report(proxy.Report{
RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
})
wantStatus(t, got, http.StatusNoContent)
history := s.history()
if history.Requests != 1 || history.Forwarded != 1 ||
history.RequestBytes != bodyBytes || history.ResponseBytes != answerBytes ||
history.Responses.Status5xx != 1 {
t.Errorf("history is %+v", history)
}
if counters := s.server.Anomalies.Snapshot(); len(counters) != 1 {
t.Errorf("anomaly counters %v, want the client's", counters)
}
}
func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
s.allowed(client)
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny)
history := s.history()
if history.Requests != 1 || history.Refused != 1 {
t.Errorf("history is %+v, want the refused request alone", history)
}
}
func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{metricsToken: token})
id := s.allowed(client)
const series = `{action="forward",instance="` + alertInstance + `",status_class="5xx"}`
metrics := s.metrics()
wantNoSeries(t, metrics, "smallwebwaf_requests_total"+series)
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="`+alertInstance+`"}`,
1) // the scrape itself
got := s.report(proxy.Report{
RequestID: id, Status: http.StatusBadGateway,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 5, DurationUpstreamTotal: 4,
})
wantStatus(t, got, http.StatusNoContent)
metrics = s.metrics()
wantMetric(t, metrics, "smallwebwaf_requests_total"+series, 1)
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+series, bodyBytes)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+series, answerBytes)
wantMetric(t, metrics, `smallwebwaf_upstream_duration_seconds_sum{instance="`+
alertInstance+`"}`, 0.004)
}
func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
got := s.report(proxy.Report{RequestID: "no-such-request", ResponseBytes: 1000})
wantStatus(t, got, http.StatusNoContent)
if clients := s.server.Limiter.Snapshot(); len(clients) != 0 {
t.Errorf("the table of clients holds %v", clients)
}
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
t.Errorf("bans held are %v", held)
}
// A second report of the same request does not count it twice.
id := s.allowed(client)
for range 2 {
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
}
if counted := s.history().ResponseBytes; counted != answerBytes {
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
}
}
func TestReportThatCannotBeReadIsRefusedAndTheRequestStaysHeld(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
id := s.allowed(client)
for _, body := range []string{
"not JSON",
`{"request_id":"` + id + `","request_bytes":-1}`,
`{"request_id":"` + id + `","response_bytes":-1}`,
} {
got := do(t, newRequest(t, http.MethodPost, s.addr, proxy.ReportPath,
strings.NewReader(body)))
wantStatus(t, got, http.StatusBadRequest)
}
got := s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if counted := s.history().ResponseBytes; counted != answerBytes {
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
}
}
func TestVisitorCannotHaveItsReportTakenForAnothersByChoosingItsID(t *testing.T) {
t.Parallel()
const (
chosen = "chosen-by-a-visitor"
exempt = "/static/x"
)
s := startServeWAF(t, "", map[string]string{
bytesLimitPerMinute: byteLimit, rateLimitExemptPaths: "/static/",
})
// The request whose bytes are not counted takes the id first.
first := s.question(siteA, client, exempt)
first.Header.Set(requestIDHeader, chosen)
gotFirst := do(t, first)
wantDisposition(t, gotFirst, http.StatusOK, proxy.DispositionAllow)
second := s.question(siteA, client, "/download")
second.Header.Set(requestIDHeader, chosen)
gotSecond := do(t, second)
wantDisposition(t, gotSecond, http.StatusOK, proxy.DispositionAllow)
id := gotSecond.header.Get(requestIDHeader)
if gotFirst.header.Get(requestIDHeader) != chosen || id == chosen || id == "" {
t.Errorf("answered with the ids %q and %q, want %q and a new one",
gotFirst.header.Get(requestIDHeader), id, chosen)
}
if line := s.out.requestLines(t, 2)[1]; line.RequestID != id {
t.Errorf("log line has request_id %q, want %q", line.RequestID, id)
}
got := s.report(proxy.Report{RequestID: id, ResponseBytes: 1000})
wantStatus(t, got, http.StatusNoContent)
if held := s.server.Ledger.Snapshot(); len(held) != 1 {
t.Errorf("bans held are %v, want the one the download's bytes make", held)
}
}
func TestIDAJSONReportCannotCarryIsReplaced(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
req := s.question(siteA, client, "/")
req.Header.Set(requestIDHeader, "not-utf-8-\xff")
got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
report := s.report(proxy.Report{
RequestID: got.header.Get(requestIDHeader), ResponseBytes: 1000,
})
wantStatus(t, report, http.StatusNoContent)
if held := s.server.Ledger.Snapshot(); len(held) != 1 {
t.Errorf("bans held are %v, want the one the report's bytes make", held)
}
}
func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)})
got := do(t, s.question(siteA, client, "/.env"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
got = do(t, s.question(siteB, client, "/"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
lines := s.out.requestLines(t, 2)
if lines[0].Host != siteA || lines[1].Host != siteB {
t.Errorf("log lines have the hosts %q and %q", lines[0].Host, lines[1].Host)
}
held := s.server.Ledger.Snapshot()
if len(held) != 1 || held[0].Notes.Request.Host != siteA {
t.Errorf("bans held are %+v, want one whose notes name %s", held, siteA)
}
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) == 0 || waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want the ban's, naming %s", waiting, siteA)
}
}
func TestRequestsOnEverySiteCountTowardOneSetOfLimits(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
s.allowed(client)
got := do(t, s.question(siteB, client, "/"))
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
}
func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
t.Parallel()
// Were a check run on its requests, localhost's would be refused.
s := startServeWAF(t, "", map[string]string{
metricsToken: token, adminToken: adminSecret, denyNets: localhost,
})
for _, tc := range []struct {
path, authorization string
want int
}{
{proxy.HealthPath, "", http.StatusOK},
{proxy.MetricsPath, bearer, http.StatusOK},
{proxy.MetricsPath, "", http.StatusUnauthorized},
{proxy.BansPath, adminBearer, http.StatusOK},
{proxy.BansPath, "", http.StatusUnauthorized},
{proxy.ClientsPath + client, adminBearer, http.StatusOK},
{proxy.ReportPath, "", http.StatusNotFound},
{"/_smallwebwaf/other", "", http.StatusNotFound},
{"/", "", http.StatusNotFound},
} {
req := newRequest(t, http.MethodGet, s.addr, tc.path, http.NoBody)
req.Header.Set("Authorization", tc.authorization)
wantDisposition(t, do(t, req), tc.want, "")
// The same request carrying the header is a question about it.
req = newRequest(t, http.MethodGet, s.addr, tc.path, http.NoBody)
req.Header.Set("Authorization", tc.authorization)
req.Header.Set(proxy.ClientHeader, client)
if strings.HasPrefix(tc.path, "/_smallwebwaf/") {
wantDisposition(t, do(t, req), http.StatusNotFound, proxy.DispositionDeny)
} else {
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
}
}
}
func TestReportWritesNoLogLine(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
s.passedOn(client)
// The health check's line comes after the question's.
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK)
lines := s.out.requestLines(t, 2)
if len(lines) != 2 || lines[1].Path != proxy.HealthPath {
t.Errorf("request log lines are %+v, want the question's and the health check's",
lines)
}
}
func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
requestMaxBytes: "1",
clientHeaderMaxBytes: "5K",
clientRequestTimeout: off,
clientResponseTimeout: off,
trustedProxies: localhost,
})
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader("a body"))
req.Host = siteA
req.Header.Set(proxy.ClientHeader, client)
req.Header.Set(proxy.ContentLengthHeader, "1073741824")
req.Header.Set(forwardedFor, otherClient)
req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
got := do(t, req)
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
line := s.out.requestLine(t)
if line.ClientIP != client || line.ContentLength != 1<<30 {
t.Errorf("log line has client_ip %q and content_length %d",
line.ClientIP, line.ContentLength)
}
}
func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
rulesDir: writeRules(t, `
own-header header:X-Smallwebwaf-Client block .
announced header:Content-Length block ^70000$
carried header:Content-Length block ^6$
`),
})
// What the question's own Content-Length says, 6, is not what the visitor
// announced.
for _, tc := range []struct {
announced string
want int
}{
{"70000", http.StatusForbidden},
{"", http.StatusOK},
{"6000", http.StatusOK},
} {
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader("a body"))
req.Header.Set(proxy.ClientHeader, client)
if tc.announced != "" {
req.Header.Set(proxy.ContentLengthHeader, tc.announced)
}
wantStatus(t, do(t, req), tc.want)
}
}
func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
wafMode: block, wafBodyLimit: "1M",
})
ask := func(contentType, body string) answer {
req := newRequest(t, http.MethodPost, s.addr, "/", strings.NewReader(body))
req.Header.Set(proxy.ClientHeader, client)
req.Header.Set("Content-Type", contentType)
req.Header.Set(proxy.ContentLengthHeader, "100000")
return do(t, req)
}
got := ask(formData, "id=1'%20OR%20'1'='1")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
// The first 64 KiB of a longer JSON body cannot be parsed, which is no
// attack, so the Core Rule Set does not read it.
cut := `{"text":"` + strings.Repeat("x", proxy.QuestionBodyMaxBytes)
got = ask("application/json", cut[:proxy.QuestionBodyMaxBytes])
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
}
func TestAnomalyAlertNamesTheSite(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
"SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE": "1",
})
for range 2 {
s.passedOn(client)
}
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventAnomaly ||
waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want an anomaly naming %s", waiting, siteA)
}
}
+129
View File
@@ -0,0 +1,129 @@
package smallwebwaf_test
import (
"context"
"io"
"net/http"
"path/filepath"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
)
// ask sends servewaf at addr rproxy's question about a request for path from
// placed, checks the answer's status and disposition, and closes it.
func ask(t *testing.T, addr, path string, status int, disposition string) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+path, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set(proxy.ClientHeader, placed)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("question: %v", err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != status ||
res.Header.Get(proxy.DispositionHeader) != disposition {
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
res.Header.Get(proxy.DispositionHeader), status, disposition)
}
}
// runServeWAF runs `smallwebwaf servewaf` with the settings in env, has use
// send it requests at the address it listens on, stops it as SIGTERM does, and
// checks that it stopped in order.
func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
t.Helper()
ctx, stop := context.WithCancel(t.Context())
out := &output{}
exited := make(chan int, 1)
go func() {
exited <- runAs(ctx, true, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
use(addr)
stop()
select {
case status := <-exited:
if status != 0 {
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Fatal("still running after being told to stop")
}
out.line(t, "msg", "stopped")
}
func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
t.Parallel()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
}
runServeWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusOK, proxy.DispositionAllow)
// The default rule file bans a probe for this.
ask(t, addr, "/.env", http.StatusForbidden, proxy.DispositionDeny)
})
runServeWAF(t, env, func(addr string) {
ask(t, addr, "/", http.StatusForbidden, proxy.DispositionDeny)
})
}
func TestWithoutACommandTheClientHeaderIsNoQuestion(t *testing.T) {
t.Parallel()
runUntilStopped(t, map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
}, func(url string) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set(proxy.ClientHeader, placed)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || string(body) != greeting ||
res.Header.Get(proxy.DispositionHeader) != "" {
t.Errorf("answered %q (%v) with disposition %q, want the app's greeting",
body, err, res.Header.Get(proxy.DispositionHeader))
}
})
}
+21 -8
View File
@@ -1,7 +1,8 @@
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
// the rule files, the lookup database and the state files, serves requests
// until it is told to stop, and then stops in an orderly way, writing the
// state files.
// state files. It serves them in front of an app, or, as `smallwebwaf
// servewaf`, by answering rproxy's questions about them.
package smallwebwaf
import (
@@ -45,11 +46,15 @@ type Params struct {
LookupEnv func(string) (string, bool)
// Stdout receives the request log and the process's own messages.
Stdout io.Writer
// ServeWAF makes it `smallwebwaf servewaf`, which answers rproxy's
// questions, rather than the proxy in front of an app.
ServeWAF bool
}
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
// process's exit status. Run as `smallwebwaf healthcheck`, it is the
// container's health check instead.
// process's exit status. Run as `smallwebwaf servewaf`, it answers
// rproxy's questions rather than pass requests to an app. Run as
// `smallwebwaf healthcheck`, it is the container's health check instead.
func Main(version string) int {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
@@ -63,6 +68,7 @@ func Main(version string) int {
Version: version,
LookupEnv: os.LookupEnv,
Stdout: os.Stdout,
ServeWAF: len(os.Args) > 1 && os.Args[1] == "servewaf",
})
}
@@ -117,7 +123,12 @@ func Run(ctx context.Context, params Params) int {
return 1
}
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
newProxy := proxy.New
if params.ServeWAF {
newProxy = proxy.NewServeWAF
}
server, err := newServer(newProxy, cfg, stdout, processLog, now, ruleFiles, alertQueue)
if err != nil {
processLog.Error("cannot use the lookup database", "error", err.Error())
@@ -151,10 +162,12 @@ func Run(ctx context.Context, params Params) int {
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
}
// newServer returns the server smallwebwaf runs, with the metrics of the
// alerts, after reading the lookup database while SWWAF_LOOKUP_SOURCE is
// file. A lookup database that cannot be read is an error.
// newServer returns the server smallwebwaf runs, which newProxy makes, with
// the metrics of the alerts, after reading the lookup database while
// SWWAF_LOOKUP_SOURCE is file. A lookup database that cannot be read is an
// error.
func newServer(
newProxy func(proxy.Params) *proxy.Server,
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
) (*proxy.Server, error) {
@@ -174,7 +187,7 @@ func newServer(
}
}
server := proxy.New(proxy.Params{
server := newProxy(proxy.Params{
Config: cfg,
RequestLog: stdout,
ProcessLog: processLog,
+9 -1
View File
@@ -113,6 +113,13 @@ func (o *output) text() string {
// returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int {
return runAs(ctx, false, env, out)
}
// runAs is run as `smallwebwaf servewaf` while serveWAF is true.
func runAs(
ctx context.Context, serveWAF bool, env map[string]string, out *output,
) int {
return smallwebwaf.Run(ctx, smallwebwaf.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
@@ -123,7 +130,8 @@ func run(ctx context.Context, env map[string]string, out *output) int {
return value, ok
},
Stdout: out,
Stdout: out,
ServeWAF: serveWAF,
})
}