check / check (push) Successful in 13m11s
`smallwebwaf servewaf` runs today's checks on the visitor's request each question carries, answers rproxy, and passes nothing to an app. rproxy reports every request servewaf answered. A report counts an allowed request's bytes and writes servewaf's second log line. The table holds a copy of what a report needs, never the request, so a report cannot reach a request being handled. Judgement call: a question's body is read before any check; over 64 KiB gets 400. Judgement call: a report's bytes and durations have ceilings, so they cannot overflow. Judgement call: a `close` disposition for SWWAF_BAN_RESPONSE=close. Model: opus-5-5
1632 lines
48 KiB
Go
1632 lines
48 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
const (
|
|
// siteA and siteB are two sites behind one servewaf.
|
|
siteA = "a.example"
|
|
siteB = "b.example"
|
|
// rproxyPeer is what the tests' questions give as rproxy's TCP peer.
|
|
rproxyPeer = "172.18.0.2"
|
|
// heldLimit is how many answered requests "servewaf" in SPEC.md says it
|
|
// holds.
|
|
heldLimit = 10000
|
|
// reportedBytesLimit is the most bytes for each way SPEC.md lets a report
|
|
// give.
|
|
reportedBytesLimit = 1 << 50
|
|
// 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)
|
|
}
|
|
}
|