check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. Its constructor is New, like rproxy's. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
1613 lines
48 KiB
Go
1613 lines
48 KiB
Go
package servewaf_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/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
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 *servewaf.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(*servewaf.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(*servewaf.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 := newServeWAF(t, geojsURL, clk.Now, settings)
|
|
change(server)
|
|
serve(t, server)
|
|
|
|
return &serveWAF{
|
|
t: t, addr: server.Addr, 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(servewaf.ClientHeader, from)
|
|
req.Header.Set(servewaf.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, servewaf.DispositionAllow)
|
|
|
|
return req.Header.Get(requestIDHeader)
|
|
}
|
|
|
|
// report sends rproxy's report and returns the answer.
|
|
func (s *serveWAF) report(report servewaf.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, servewaf.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(servewaf.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, servewaf.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(servewaf.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(servewaf.ContentLengthHeader, "1234")
|
|
|
|
got := do(t, req)
|
|
wantDisposition(t, got, http.StatusOK, servewaf.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, servewaf.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(servewaf.PeerHeader, "::ffff:"+rproxyPeer)
|
|
|
|
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.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(servewaf.ClientHeader, "not an address")
|
|
}},
|
|
{"client empty", func(req *http.Request) {
|
|
req.Header.Set(servewaf.ClientHeader, "")
|
|
}},
|
|
{"client address with a zone", func(req *http.Request) {
|
|
req.Header.Set(servewaf.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, servewaf.DispositionAllow)
|
|
}
|
|
|
|
func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := startServeWAF(t, "", nil)
|
|
|
|
over := strings.NewReader(strings.Repeat("x", servewaf.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", servewaf.QuestionBodyMaxBytes))
|
|
got = do(t, s.questionWith(http.MethodPost, siteA, client, "/", whole))
|
|
wantDisposition(t, got, http.StatusOK, servewaf.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, servewaf.HealthPath)
|
|
wantDisposition(t, do(t, denied), http.StatusNotFound, servewaf.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(servewaf.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, servewaf.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(servewaf.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, servewaf.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(servewaf.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(servewaf.PeerHeader)
|
|
|
|
if peer != "" {
|
|
req.Header.Set(servewaf.PeerHeader, peer)
|
|
}
|
|
|
|
wantDisposition(t, do(t, req), http.StatusOK, servewaf.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(servewaf.ContentLengthHeader, announced)
|
|
wantDisposition(t, do(t, req), http.StatusOK, servewaf.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(servewaf.ClientHeader, otherClient)
|
|
req.Header.Add(servewaf.PeerHeader, "172.18.0.3")
|
|
req.Header.Set(requestIDHeader, "first")
|
|
req.Header.Add(requestIDHeader, "second")
|
|
req.Header.Set(servewaf.ContentLengthHeader, "1234")
|
|
req.Header.Add(servewaf.ContentLengthHeader, "5678")
|
|
wantDisposition(t, do(t, req), http.StatusOK, servewaf.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, servewaf.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 *servewaf.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.
|
|
again := s.question(siteA, client, "/")
|
|
again.Header.Set(requestIDHeader, "an-id")
|
|
wantDisposition(t, do(t, again), http.StatusOK, servewaf.DispositionAllow)
|
|
})
|
|
}
|
|
}
|
|
|
|
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, servewaf.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, servewaf.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, servewaf.DispositionDeny)
|
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
|
requestlog.ActionRuleBlocked)
|
|
|
|
got = s.ask(client, "/.env")
|
|
wantDisposition(t, got, http.StatusTooManyRequests, servewaf.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{
|
|
servewaf.HealthPath, servewaf.MetricsPath, servewaf.ReportPath,
|
|
} {
|
|
got := s.ask(client, path)
|
|
wantDisposition(t, got, http.StatusNotFound, servewaf.DispositionDeny)
|
|
}
|
|
|
|
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionAdmin)
|
|
|
|
got := s.ask(otherClient, servewaf.MetricsPath)
|
|
wantDisposition(t, got, http.StatusForbidden, servewaf.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, servewaf.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, servewaf.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, servewaf.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
|
|
}{
|
|
{on, "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, servewaf.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(servewaf.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, servewaf.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, servewaf.DispositionAllow)
|
|
|
|
s.passedOn(fromDE)
|
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, servewaf.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(servewaf.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, servewaf.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(servewaf.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(servewaf.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)
|
|
|
|
// A scrape adds its own time to the request duration when it ends, so this
|
|
// check needs a server that has not been scraped yet.
|
|
unscraped := startServeWAF(t, "", map[string]string{metricsToken: token})
|
|
|
|
got = unscraped.report(servewaf.Report{
|
|
RequestID: unscraped.allowed(client), Status: http.StatusBadGateway, DurationTotal: 5,
|
|
})
|
|
wantStatus(t, got, http.StatusNoContent)
|
|
wantMetric(t, unscraped.metrics(),
|
|
`smallwebwaf_request_duration_seconds_sum{instance="`+alertInstance+`"}`, 0.005)
|
|
}
|
|
|
|
func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := startServeWAF(t, "", map[string]string{bytesLimitPerMinute: byteLimit})
|
|
|
|
got := s.report(servewaf.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(servewaf.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(servewaf.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, servewaf.DispositionAllow)
|
|
|
|
got := s.report(servewaf.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, servewaf.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(servewaf.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, servewaf.DispositionAllow)
|
|
}
|
|
|
|
// The table is full again with next and those, without oldest.
|
|
got := s.report(servewaf.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(servewaf.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, servewaf.DispositionDeny)
|
|
|
|
got = do(t, s.question(siteB, client, "/"))
|
|
wantDisposition(t, got, http.StatusForbidden, servewaf.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, servewaf.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
|
|
}{
|
|
{servewaf.HealthPath, "", http.StatusOK},
|
|
{servewaf.MetricsPath, bearer, http.StatusOK},
|
|
{servewaf.MetricsPath, "", http.StatusUnauthorized},
|
|
{servewaf.BansPath, adminBearer, http.StatusOK},
|
|
{servewaf.BansPath, "", http.StatusUnauthorized},
|
|
{servewaf.ClientsPath + client, adminBearer, http.StatusOK},
|
|
{servewaf.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, servewaf.DispositionDeny)
|
|
} else {
|
|
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOwnEndpointsLinesDoNotSayWhichOfTheTwoTheyAre(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := startServeWAF(t, "", nil)
|
|
|
|
wantStatus(t, get(t, s.addr, servewaf.HealthPath), http.StatusOK)
|
|
|
|
line := s.out.requestLine(t)
|
|
if _, there := line.fields["servewaf_line"]; there ||
|
|
line.Path != servewaf.HealthPath {
|
|
t.Errorf("the health check's log line is %+v", line.fields)
|
|
}
|
|
}
|
|
|
|
func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := startServeWAF(t, "", nil)
|
|
|
|
req := s.questionWith(http.MethodPost, siteA, client, "/",
|
|
strings.NewReader("a body"))
|
|
req.Header.Set(servewaf.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, servewaf.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 TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := startServeWAF(t, "", map[string]string{"SWWAF_LOG_LEVEL": "error"})
|
|
|
|
// A refused question would write a process warning, held back at error.
|
|
req := s.question(siteA, client, "/")
|
|
req.Header.Del(requestIDHeader)
|
|
wantStatus(t, do(t, req), http.StatusBadRequest)
|
|
|
|
s.allowed(client)
|
|
wantLine(t, s.out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
|
|
|
for _, line := range s.out.lines(t) {
|
|
if line["type"] == "process" {
|
|
t.Errorf("process line %v, want none at error", 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(servewaf.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: "64K",
|
|
})
|
|
|
|
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(servewaf.ContentLengthHeader, "100000")
|
|
|
|
return do(t, req)
|
|
}
|
|
|
|
got := ask(formData, "id=1'%20OR%20'1'='1")
|
|
wantDisposition(t, got, http.StatusForbidden, servewaf.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", servewaf.QuestionBodyMaxBytes)
|
|
got = ask("application/json", cut[:servewaf.QuestionBodyMaxBytes])
|
|
wantDisposition(t, got, http.StatusOK, servewaf.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, servewaf.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, servewaf.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)
|
|
}
|
|
}
|