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 // reportBodyLimit is the longest body SPEC.md lets a report have. reportBodyLimit = 1 << 20 // 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 TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) { t.Parallel() s := startServeWAF(t, "", map[string]string{ rulesDir: writeRules(t, testRules), errorBurstThreshold: "1", }) for range 2 { got := s.ask(client, "/blocked") wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) } 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, 9+3*len(fields)) bodies = append(bodies, "", "not JSON", "null", "[]", `"text"`, "{}", whole+" {}", whole+whole, `{"request_id":"`+id+`"}`) 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 TestReportOver1MiBIsRefusedAndOneOfExactly1MiBIsTaken(t *testing.T) { t.Parallel() s := startServeWAF(t, "", nil) id := s.allowed(client) // The padding is a field of no meaning here, so only the size can refuse it. padded := func(size int) string { return encodedReport(t, id, map[string]any{"padding": strings.Repeat("x", size)}) } paddingBytes := reportBodyLimit - len(padded(0)) wantStatus(t, s.reportBody(padded(paddingBytes+1)), http.StatusBadRequest) wantProcessMessages(t, s, "a report is refused", 1) if requests := s.history().Requests; requests != 0 { t.Errorf("history holds %d requests, want the refused report not counted", requests) } wantStatus(t, s.reportBody(padded(paddingBytes)), http.StatusNoContent) if requests := s.history().Requests; requests != 1 { t.Errorf("history holds %d requests, want the report of 1 MiB counted", requests) } } 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 TestShortClientResponseTimeoutIsIgnored(t *testing.T) { t.Parallel() // The setting would leave no time to send any answer. s := startServeWAF(t, "", map[string]string{ clientResponseTimeout: time.Nanosecond.String(), denyNets: client, }) wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny) wantStatus(t, get(t, s.addr, proxy.HealthPath), http.StatusOK) } func TestShortClientRequestTimeoutIsIgnored(t *testing.T) { t.Parallel() // The setting would have run out while the Core Rule Set read the body. Go's // server would then take the connection for gone, and the line say aborted. s := startServeWAF(t, "", map[string]string{ clientRequestTimeout: time.Nanosecond.String(), wafMode: block, wafBodyLimit: "1M", }) req := s.questionWith(http.MethodPost, siteA, client, "/", strings.NewReader("a=1")) req.Header.Set("Content-Type", formData) wantDisposition(t, do(t, req), http.StatusOK, proxy.DispositionAllow) if line := s.out.requestLine(t); line.Aborted { t.Errorf("log line says aborted: %+v", line.Line) } } func TestServerHasTheTimeLimitsSPECGives(t *testing.T) { t.Parallel() s := startServeWAF(t, "", nil) if s.server.ReadTimeout != time.Minute || s.server.WriteTimeout != time.Minute || s.server.IdleTimeout != 2*time.Minute { t.Errorf("the read, write and idle limits are %v, %v and %v, want 1m, 1m and 2m", s.server.ReadTimeout, s.server.WriteTimeout, s.server.IdleTimeout) } } func TestChecksSeeTheHeadersTheVisitorSent(t *testing.T) { t.Parallel() s := startServeWAF(t, "", map[string]string{ rulesDir: writeRules(t, ` own-client header:X-Smallwebwaf-Client block . own-peer header:X-Smallwebwaf-Peer block . own-length header:X-Smallwebwaf-Content-Length 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 TestCoreRuleSetAlertNamesTheSite(t *testing.T) { t.Parallel() s := startServeWAF(t, "", map[string]string{wafMode: block}) got := s.ask(client, sqlInjection) wantDisposition(t, got, http.StatusForbidden, proxy.DispositionDeny) waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventWAFBlock || waiting[0].Host != siteA { t.Errorf("alerts waiting are %+v, want a waf_block naming %s", waiting, siteA) } } func TestReputationHitAlertNamesTheSite(t *testing.T) { t.Parallel() s := startServeWAF(t, "", map[string]string{blocklistURLs: dropURL}) loadLists(t, s.server, map[string][]string{dropURL: {client}}) wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, proxy.DispositionDeny) waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || waiting[0].Host != siteA { t.Errorf("alerts waiting are %+v, want a reputation_hit naming %s", waiting, siteA) } } 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) } }