Files
smallwebwaf/internal/proxy/servewaf_test.go
T
sneak 4a2d57dca2
check / check (push) Successful in 4m23s
servewaf answers rproxy's question about each request and takes its report (closes #170)
`smallwebwaf servewaf` runs today's checks on the visitor's request each
question carries, answers rproxy, and passes nothing to an app. rproxy reports
every request servewaf answered. A report counts an allowed request's bytes and
writes servewaf's second log line. The table holds a copy of what a report
needs, never the request, so a report cannot reach a request being handled.

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

Model: opus-5-5
2026-11-04 21:10:03 +00:00

1512 lines
44 KiB
Go

package proxy_test
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"maps"
"math"
"net"
"net/http"
"net/netip"
"reflect"
"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"
// heldLimit is how many answered requests "servewaf" in SPEC.md says it
// holds.
heldLimit = 10000
// reportedBytesLimit is the most bytes for each way SPEC.md lets a report
// give.
reportedBytesLimit = 1 << 50
// anomalyClientPerMinute is the anomaly threshold these tests set.
anomalyClientPerMinute = "SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE"
)
// The fields of a report, as "servewaf" in SPEC.md names them.
const (
reportID = "request_id"
reportStatus = "status"
reportRequestBytes = "request_bytes"
reportResponseBytes = "response_bytes"
reportDuration = "duration_total"
reportUpstreamDuration = "duration_upstream_total"
)
// 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()
return startServeWAFWith(t, geojsURL, env, func(*proxy.Server) {})
}
// startServeWAFWith is startServeWAF with change applied to the server before
// it starts.
func startServeWAFWith(
t *testing.T, geojsURL string, env map[string]string, change func(*proxy.Server),
) *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)
change(server)
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,
}
}
// questionWith makes rproxy's question about a request for path on host, from
// the visitor at from, carrying body and a new id, as rproxy makes for every
// request.
func (s *serveWAF) questionWith(
method, host, from, path string, body io.Reader,
) *http.Request {
s.t.Helper()
req := newRequest(s.t, method, s.addr, path, body)
req.Host = host
req.Header.Set(proxy.ClientHeader, from)
req.Header.Set(proxy.PeerHeader, rproxyPeer)
req.Header.Set(requestIDHeader, rand.Text())
return req
}
// question makes rproxy's question about a request for path without a body.
func (s *serveWAF) question(host, from, path string) *http.Request {
s.t.Helper()
return s.questionWith(http.MethodGet, host, from, path, http.NoBody)
}
// 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 request's id.
func (s *serveWAF) allowed(from string) string {
s.t.Helper()
req := s.question(siteA, from, "/")
wantDisposition(s.t, do(s.t, req), http.StatusOK, proxy.DispositionAllow)
return req.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 s.reportBody(string(body))
}
// reportBody sends body as rproxy's report and returns the answer.
func (s *serveWAF) reportBody(body string) answer {
s.t.Helper()
return do(s.t, newRequest(s.t, http.MethodPost, s.addr, proxy.ReportPath,
strings.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)
}
}
// wantProcessMessages checks that msg is among the process's messages count
// times, each from the server's log at the level warnings are.
func wantProcessMessages(t *testing.T, s *serveWAF, msg string, count int) {
t.Helper()
found := 0
for _, fields := range s.out.lines(t) {
if fields["msg"] == msg && fields["level"] == "WARN" {
found++
}
}
if found != count {
t.Errorf("%d process warnings %q, want %d; output:\n%s", found, msg, count,
s.out.text())
}
}
// wantNoRequestLines checks that no request log line has been written.
func wantNoRequestLines(t *testing.T, s *serveWAF) {
t.Helper()
for _, fields := range s.out.lines(t) {
if fields["type"] == requestType {
t.Errorf("a request log line was written: %v", fields)
}
}
}
func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/page?q=1&token=visible")
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 != "" {
t.Errorf("answered with the id %q, want none: rproxy makes 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&token=visible" ||
line.RequestID != "the-id" || line.Scheme != "https" ||
line.ContentLength != 1234 ||
line.ServeWAFLine != requestlog.ServeWAFLineDecision {
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 TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{denyNets: client})
req := s.question(siteA, "::ffff:"+client, "/")
req.Header.Set(proxy.PeerHeader, "::ffff:"+rproxyPeer)
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny)
line := s.out.requestLine(t)
if line.ClientIP != client || line.PeerIP != rproxyPeer {
t.Errorf("log line has client_ip %q and peer_ip %q", line.ClientIP, line.PeerIP)
}
}
func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
change func(*http.Request)
}{
{"client not an address", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "not an address")
}},
{"client empty", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "")
}},
{"client address with a zone", func(req *http.Request) {
req.Header.Set(proxy.ClientHeader, "2001:db8::1%eth0")
}},
{"id absent", func(req *http.Request) {
req.Header.Del(requestIDHeader)
}},
{"id empty", func(req *http.Request) {
req.Header.Set(requestIDHeader, "")
}},
{"id not UTF-8", func(req *http.Request) {
req.Header.Set(requestIDHeader, "not-utf-8-\xff")
}},
{"id too long", func(req *http.Request) {
req.Header.Set(requestIDHeader, strings.Repeat("x", 129))
}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{metricsToken: token})
req := s.question(siteA, client, "/")
tc.change(req)
wantDisposition(t, do(t, req), http.StatusBadRequest, "")
wantProcessMessages(t, s, "a question is refused", 1)
wantNoRequestLines(t, s)
metrics := s.metrics()
if strings.Contains(metrics, `status_class="4xx"`) {
t.Errorf("a refused question is counted in the metrics:\n%s", metrics)
}
})
}
}
func TestLongestValidIDIsTaken(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/")
req.Header.Set(requestIDHeader, strings.Repeat("x", 128))
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
}
func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
over := strings.NewReader(strings.Repeat("x", proxy.QuestionBodyMaxBytes+1))
got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over))
wantDisposition(t, got, http.StatusBadRequest, "")
wantProcessMessages(t, s, "a question is refused", 1)
wantNoRequestLines(t, s)
whole := strings.NewReader(strings.Repeat("x", proxy.QuestionBodyMaxBytes))
got = do(t, s.questionWith(http.MethodPost, siteA, client, "/", whole))
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
}
func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
id := s.allowed(client)
again := s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, id)
wantDisposition(t, do(t, again), http.StatusBadRequest, "")
wantProcessMessages(t, s, "a question is refused", 1)
// A denied request's id is held as well.
denied := s.question(siteA, otherClient, proxy.HealthPath)
wantDisposition(t, do(t, denied), http.StatusNotFound, proxy.DispositionDeny)
again = s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader))
wantDisposition(t, do(t, again), http.StatusBadRequest, "")
// The report ends the holding, and the refused questions changed nothing.
got := s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if history := s.history(); history.ResponseBytes != answerBytes {
t.Errorf("history is %+v, want the first question's report", history)
}
again = s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, id)
wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow)
}
func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
t.Parallel()
geojsURL, asked, release := startHeldGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit,
})
first := s.question(siteA, client, "/")
id := first.Header.Get(requestIDHeader)
shared := newClient(t)
answered := make(chan answer, 1)
go func() {
res, err := shared.Do(first)
if err != nil {
t.Errorf("question: %v", err)
answered <- answer{}
return
}
answered <- readAnswer(res)
}()
// The lookup holds the question up.
waitUntil(func() bool { return len(asked()) > 0 })
// A report for a question not yet answered is ignored, and that question's
// id is taken.
got := s.report(proxy.Report{RequestID: id, ResponseBytes: 1000})
wantStatus(t, got, http.StatusNoContent)
// This would wait on the same lookup were the id not taken.
ctx, cancel := context.WithTimeout(t.Context(), waitLimit)
defer cancel()
again := s.question(siteA, client, "/").WithContext(ctx)
again.Header.Set(requestIDHeader, id)
wantDisposition(t, do(t, again), http.StatusBadRequest, "")
release()
select {
case got := <-answered:
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
case <-time.After(waitLimit):
t.Fatal("the question got no answer")
}
line := s.out.requestLine(t)
if line.ResponseBytes != 0 || line.ServeWAFLine != requestlog.ServeWAFLineDecision {
t.Errorf("the report reached the question being handled: %+v", line.Line)
}
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
t.Errorf("the ignored report made bans: %v", held)
}
// The true report comes after, and is the one that counts.
got = s.report(proxy.Report{RequestID: id, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if history := s.history(); history.ResponseBytes != answerBytes {
t.Errorf("history is %+v, want the true report's bytes", history)
}
}
func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) {
t.Parallel()
for _, peer := range []string{"", "not an address"} {
t.Run(peer, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/")
req.Header.Del(proxy.PeerHeader)
if peer != "" {
req.Header.Set(proxy.PeerHeader, peer)
}
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
if line := s.out.requestLine(t); line.PeerIP != "invalid IP" {
t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP)
}
})
}
}
func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) {
t.Parallel()
for _, announced := range []string{"many", "-1", "1.5"} {
t.Run(announced, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
rulesDir: writeRules(t, "any-length header:Content-Length block .\n"),
})
req := s.question(siteA, client, "/")
req.Header.Set(proxy.ContentLengthHeader, announced)
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
if line := s.out.requestLine(t); line.ContentLength != 0 {
t.Errorf("log line has content_length %d, want none", line.ContentLength)
}
})
}
}
func TestHeaderSentTwiceUsesItsFirstValue(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
req := s.question(siteA, client, "/")
req.Header.Add(proxy.ClientHeader, otherClient)
req.Header.Add(proxy.PeerHeader, "172.18.0.3")
req.Header.Set(requestIDHeader, "first")
req.Header.Add(requestIDHeader, "second")
req.Header.Set(proxy.ContentLengthHeader, "1234")
req.Header.Add(proxy.ContentLengthHeader, "5678")
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
line := s.out.requestLine(t)
if line.ClientIP != client || line.PeerIP != rproxyPeer ||
line.RequestID != "first" || line.ContentLength != 1234 {
t.Errorf("log line is %+v", line.Line)
}
}
func TestSchemeAbsentIsLoggedAsHTTP(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
s.allowed(client)
if line := s.out.requestLine(t); line.Scheme != "http" {
t.Errorf("log line has scheme %q, want http", line.Scheme)
}
}
// sendQuestionWithPartOfItsBody sends a question over conn whose Content-Length
// announces more than it sends.
func sendQuestionWithPartOfItsBody(t *testing.T, conn net.Conn) {
t.Helper()
_, err := fmt.Fprintf(conn,
"POST / HTTP/1.1\r\nHost: %s\r\n%s: %s\r\n%s: an-id\r\n"+
"Content-Length: 100\r\n\r\npart",
siteA, proxy.ClientHeader, client, requestIDHeader)
if err != nil {
t.Fatalf("send the question: %v", err)
}
}
func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
t.Parallel()
// Stalling would take the server's 60 seconds otherwise.
const readTimeout = time.Second
for _, tc := range []struct {
name string
stop func(*testing.T, net.Conn)
}{
{"it stalls", func(*testing.T, net.Conn) {}},
{"rproxy stops sending", func(t *testing.T, conn net.Conn) {
t.Helper()
err := conn.(*net.TCPConn).CloseWrite() //nolint:forcetypeassert // dial's
if err != nil {
t.Fatalf("close the sending side: %v", err)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startServeWAFWith(t, "", map[string]string{metricsToken: token},
func(server *proxy.Server) { server.ReadTimeout = readTimeout })
conn := dial(t, s.addr)
sendQuestionWithPartOfItsBody(t, conn)
tc.stop(t, conn)
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
if err != nil {
t.Fatalf("set the deadline: %v", err)
}
sent, err := io.ReadAll(conn)
if err != nil || len(sent) != 0 {
t.Errorf("rproxy read %q (%v), want the connection closed unanswered",
sent, err)
}
wantProcessMessages(t, s, "a question's body stopped arriving", 1)
wantNoRequestLines(t, s)
// The id was not taken, and the metrics count nothing for it.
again := s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, "an-id")
wantDisposition(t, do(t, again), http.StatusOK, proxy.DispositionAllow)
wantNoSeries(t, s.metrics(),
`smallwebwaf_size_and_time_limit_hits_total{instance="`+alertInstance+
`",limit="SWWAF_CLIENT_REQUEST_TIMEOUT"}`)
})
}
}
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 TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
mode: observe, rulesDir: writeRules(t, testRules), errorBurstThreshold: "1",
})
for range 2 {
got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
}
lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitErrorBurst {
t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitErrorBurst)
}
}
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 ||
held[0].Notes.Request.Path != "/" ||
held[0].Notes.Request.Method != http.MethodGet {
t.Errorf("bans held are %+v", held)
}
third := s.out.requestLines(t, 3)[2]
if third.ServeWAFLine != requestlog.ServeWAFLineReport ||
third.LimitHit != minuteBytes || third.Offence != requestlog.OffenceLimit ||
third.BanExpires == "" {
t.Errorf("the report's log line is %+v", third.Line)
}
got = s.ask(client, "/")
wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny)
}
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{anomalyClientPerMinute: "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", metricsToken: token, anomalyClientPerMinute: "1000",
})
s.allowed(client)
denied := s.question(siteA, client, "/")
wantDisposition(t, do(t, denied), http.StatusForbidden, proxy.DispositionDeny)
counters := s.server.Anomalies.Snapshot()
if len(counters) != 1 || counters[0].Minute.Current != 1 {
t.Errorf("anomaly counters %+v, want the denied request's", counters)
}
history := s.history()
if history.Requests != 1 || history.Refused != 1 {
t.Errorf("history is %+v, want the refused request alone", history)
}
const series = `smallwebwaf_requests_total{action="rate_limited",instance="` +
alertInstance + `",status_class="4xx"}`
wantMetric(t, s.metrics(), series, 1)
got := s.report(proxy.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotal: 2,
})
wantStatus(t, got, http.StatusNoContent)
if after := s.history(); after != history {
t.Errorf("the report changed the history to %+v, from %+v", after, history)
}
counters = s.server.Anomalies.Snapshot()
if len(counters) != 1 || counters[0].Minute.Current != 1 ||
counters[0].MinuteBytes.Current != 0 {
t.Errorf("the report changed the anomaly counters: %+v", counters)
}
wantMetric(t, s.metrics(), series, 1)
}
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)
}
wantNoRequestLines(t, s)
wantProcessMessages(t, s, "a report is refused", 0)
// 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)
}
// The question's line and one for the first report, none for the second.
if lines := s.out.requestLines(t, 2); len(lines) != 2 {
t.Errorf("request log lines are %+v, want two", lines)
}
}
// reportFields is the JSON of a report of the request id with a true value for
// every field, for a test to change and leave out.
func reportFields(id string) map[string]any {
return map[string]any{
reportID: id, reportStatus: http.StatusOK,
reportRequestBytes: bodyBytes, reportResponseBytes: answerBytes,
reportDuration: 5, reportUpstreamDuration: 4,
}
}
// encodedReport returns a report of id, with the value of each field in changed
// in place of its true one, and without the fields named in missing.
func encodedReport(
t *testing.T, id string, changed map[string]any, missing ...string,
) string {
t.Helper()
fields := reportFields(id)
maps.Copy(fields, changed)
for _, name := range missing {
delete(fields, name)
}
encoded, err := json.Marshal(fields)
if err != nil {
t.Fatalf("encode the report: %v", err)
}
return string(encoded)
}
func TestReportThatIsNotOneObjectWithAllItsFieldsIsRefused(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
id := s.allowed(client)
whole := encodedReport(t, id, nil)
fields := reportFields(id)
bodies := make([]string, 0, 10+3*len(fields))
bodies = append(bodies,
"", "not JSON", "null", "[]", `"text"`, "{}",
whole+" {}",
whole+whole,
`{"request_id":"`+id+`"}`,
`{"request_id":"`+id+`","padding":"`+strings.Repeat("x", 1<<20)+`"}`)
for name := range fields {
bodies = append(bodies,
encodedReport(t, id, nil, name),
encodedReport(t, id, map[string]any{name: nil}),
encodedReport(t, id, map[string]any{name: []int{1}}))
}
for _, body := range bodies {
wantStatus(t, s.reportBody(body), http.StatusBadRequest)
}
wantProcessMessages(t, s, "a report is refused", len(bodies))
// None of those finished the request, so its true report still counts.
wantStatus(t, s.reportBody(whole), http.StatusNoContent)
if counted := s.history().ResponseBytes; counted != answerBytes {
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
}
}
func TestReportWithAValueOutOfRangeIsRefused(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
id := s.allowed(client)
cases := []struct {
field string
value any
}{
{reportStatus, -5},
{reportStatus, 99},
{reportStatus, 600},
{reportStatus, 99999},
{reportStatus, 200.5},
{reportStatus, "200"},
{reportRequestBytes, -1},
{reportRequestBytes, reportedBytesLimit + 1},
{reportRequestBytes, int64(math.MaxInt64)},
{reportRequestBytes, 1.5},
{reportResponseBytes, -1},
{reportResponseBytes, reportedBytesLimit + 1},
{reportResponseBytes, int64(math.MaxInt64)},
{reportDuration, -1},
{reportDuration, 1e13},
{reportDuration, "5"},
{reportUpstreamDuration, -0.5},
{reportUpstreamDuration, 1e13},
}
for _, tc := range cases {
body := encodedReport(t, id, map[string]any{tc.field: tc.value})
wantStatus(t, s.reportBody(body), http.StatusBadRequest)
}
wantProcessMessages(t, s, "a report is refused", len(cases))
if held := s.history(); held.Requests != 0 {
t.Errorf("a refused report counted the request: %+v", held)
}
}
func TestReportValuesAtTheEndsOfTheirRangesAreTaken(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
})
for _, tc := range []struct {
field string
value any
}{
{reportStatus, 0},
{reportStatus, 100},
{reportStatus, 599},
{reportRequestBytes, 0},
{reportRequestBytes, reportedBytesLimit},
{reportResponseBytes, reportedBytesLimit},
{reportDuration, 0},
{reportDuration, 1e12},
{reportUpstreamDuration, 1e12},
} {
id := s.allowed(client)
body := encodedReport(t, id, map[string]any{tc.field: tc.value})
wantStatus(t, s.reportBody(body), http.StatusNoContent)
}
// A field of no meaning here is read as if it were not there.
id := s.allowed(client)
body := encodedReport(t, id, map[string]any{"some_later_field": 1})
wantStatus(t, s.reportBody(body), http.StatusNoContent)
wantProcessMessages(t, s, "a report is refused", 0)
}
func TestBytesAtTheirMostEachWayBanRatherThanOverflow(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
got := s.report(proxy.Report{
RequestID: s.allowed(client), Status: http.StatusOK,
RequestBytes: reportedBytesLimit, ResponseBytes: reportedBytesLimit,
})
wantStatus(t, got, http.StatusNoContent)
if held := s.server.Ledger.Snapshot(); len(held) != 1 {
t.Errorf("bans held are %v, want the client's", held)
}
}
// wantReportsFields checks that line has what the report in
// TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields says.
func wantReportsFields(t *testing.T, line logLine) {
t.Helper()
if line.ServeWAFLine != requestlog.ServeWAFLineReport ||
line.Status != http.StatusCreated ||
line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes ||
line.DurationTotal != 12.5 || line.DurationUpstreamTotal == nil ||
*line.DurationUpstreamTotal != 10.25 {
t.Errorf("the report's line lacks the report's fields: %+v", line.Line)
}
}
// wantSameDecision checks that the report's line is the decision's, but for the
// fields a report says and the byte totals its counting changes.
func wantSameDecision(t *testing.T, reported, decided logLine) {
t.Helper()
same := reported.Line
same.ServeWAFLine = decided.ServeWAFLine
same.Status = decided.Status
same.RequestBytes = decided.RequestBytes
same.ResponseBytes = decided.ResponseBytes
same.DurationTotal = decided.DurationTotal
same.DurationUpstreamTotal = decided.DurationUpstreamTotal
same.Counts.MinuteBytes = decided.Counts.MinuteBytes
same.Counts.HourBytes = decided.Counts.HourBytes
same.Counts.DayBytes = decided.Counts.DayBytes
if !reflect.DeepEqual(same, decided.Line) {
t.Errorf("the report's line lacks the decision's fields:\n%+v\n%+v",
reported.Line, decided.Line)
}
}
// wantDecisionsFields checks what servewaf answered and noted on the decision's
// line, of a request with these fields.
func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
t.Helper()
if line.ServeWAFLine != requestlog.ServeWAFLineDecision ||
line.Status != http.StatusOK || line.ResponseBytes != 0 ||
line.DurationUpstreamTotal != nil || line.DurationChecks == nil ||
line.RequestID != id || line.Query != query ||
!line.HasAuthorization || !line.HasCookie ||
line.RequestHeaders["accept"] != accept || len(line.RuleIDs) != 1 ||
line.Counts.Minute != 1 {
t.Errorf("the decision's line is %+v", line.Line)
}
}
func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
logRequestHeaders: "accept",
rulesDir: writeRules(t, "seen path log ^/page$\n"),
})
const (
secret = "Bearer the-visitors-secret"
cookie = "session=the-visitors-cookie"
verbatim = "q=1&token=visible"
accept = "text/css"
)
req := s.question(siteA, client, "/page?"+verbatim)
req.Header.Set("Accept", accept)
req.Header.Set("Authorization", secret)
req.Header.Set("Cookie", cookie)
req.Header.Set("X-Forwarded-Proto", "https")
id := req.Header.Get(requestIDHeader)
wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow)
got := s.report(proxy.Report{
RequestID: id, Status: http.StatusCreated,
RequestBytes: bodyBytes, ResponseBytes: answerBytes,
DurationTotal: 12.5, DurationUpstreamTotal: 10.25,
})
wantStatus(t, got, http.StatusNoContent)
lines := s.out.requestLines(t, 2)
decided, reported := lines[0], lines[1]
wantDecisionsFields(t, decided, id, verbatim, accept)
wantReportsFields(t, reported)
wantSameDecision(t, reported, decided)
// The report's line has the request's bytes in the totals, which the
// decision could not know.
if decided.Counts.DayBytes != 0 || reported.Counts.DayBytes != 100 {
t.Errorf("the lines' day_bytes are %v and %v, want 0 and 100",
decided.Counts.DayBytes, reported.Counts.DayBytes)
}
// Neither line holds a credential.
text := s.out.text()
if strings.Contains(text, "the-visitors-secret") ||
strings.Contains(text, "the-visitors-cookie") {
t.Errorf("a line holds a credential:\n%s", text)
}
}
func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
path string
status int
action string
}{
{
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
requestlog.ActionDenied,
},
{
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
0, requestlog.ActionDenied,
},
{"own prefix", nil, proxy.HealthPath, http.StatusNotFound, requestlog.ActionAdmin},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", tc.env)
req := s.question(siteA, client, tc.path)
id := req.Header.Get(requestIDHeader)
do(t, req)
got := s.report(proxy.Report{
RequestID: id, Status: tc.status, ResponseBytes: 8, DurationTotal: 3,
})
wantStatus(t, got, http.StatusNoContent)
line := s.out.requestLines(t, 2)[1]
wantLine(t, line, tc.status, tc.action)
if line.ServeWAFLine != requestlog.ServeWAFLineReport ||
line.RequestID != id || line.ResponseBytes != 8 || line.DurationTotal != 3 ||
line.DurationUpstreamTotal != nil {
t.Errorf("the report's line is %+v", line.Line)
}
})
}
}
func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
rateLimitExemptNets: otherClient,
})
oldest := s.allowed(client)
next := s.allowed(client)
// One connection for them all, as rproxy keeps one open.
shared := newClient(t)
for range heldLimit - 1 {
res, err := shared.Do(s.question(siteA, otherClient, "/"))
if err != nil {
t.Fatalf("question: %v", err)
}
wantDisposition(t, readAnswer(res), http.StatusOK, proxy.DispositionAllow)
}
// The table is full again with next and those, without oldest.
got := s.report(proxy.Report{RequestID: oldest, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 0 {
t.Errorf("history holds %d requests, want the dropped one not counted", counted)
}
got = s.report(proxy.Report{RequestID: next, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 1 {
t.Errorf("history holds %d requests, want the one still held", counted)
}
}
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 = s.question(siteA, client, tc.path)
req.Header.Set("Authorization", tc.authorization)
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 TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", nil)
wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK)
line := s.out.requestLine(t)
if _, there := line.fields["servewaf_line"]; there || line.Path != proxy.HealthPath {
t.Errorf("the health check's log line is %+v", line.fields)
}
}
func TestSettingsOfTheLimitsAndTheProxiesAreIgnored(t *testing.T) {
t.Parallel()
s := startServeWAF(t, "", map[string]string{
requestMaxBytes: "1",
clientHeaderMaxBytes: "5K",
clientRequestTimeout: off,
clientResponseTimeout: off,
trustedProxies: localhost,
})
req := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader("a body"))
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 := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader("a body"))
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 := s.questionWith(http.MethodPost, siteA, client, "/",
strings.NewReader(body))
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{anomalyClientPerMinute: "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)
}
}