Files
smallwebwaf/internal/coreruleset/inspectiontime_test.go
T
sneak 8c236369f0
check / check (push) Failing after 2m58s
waf: decide every visitor request within rproxy's timeout (closes #394)
The Core Rule Set is the one check whose time a visitor can stretch.
At paranoia level 4 it took over 2 s on a request with an 8 KiB
request line, three 8 KiB Cookie lines and a 64 KiB form body.
SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE now takes at most 8K, which
brings that request under 1 s.

A test per kind of input times the Core Rule Set on the worst request
within the limits, at the slowest settings. SPEC.md lists the limits
that bound it.

Model: opus-5-5
2026-11-15 17:09:00 +00:00

337 lines
9.0 KiB
Go

package coreruleset_test
import (
"maps"
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// The bounds on what a visitor can send, as SPEC.md gives them.
const (
// lineLimit is the longest request line or header line.
lineLimit = 8 << 10
// headerLimit is the most bytes rproxy reads of a request line and
// headers together.
headerLimit = 32 << 10
// mostCookies and mostHeaderFields are waf's limits on cookies and header
// fields.
mostCookies = 50
mostHeaderFields = 100
// mostBodyRead is the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
mostBodyRead = 8 << 10
)
// inspectionTimeLimit is the most time the Core Rule Set may take on one
// request. It is the default of rproxy's SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, 2
// seconds, less the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS, 800
// milliseconds, which a question can also wait for GeoJS, and less 200
// milliseconds for waf's other checks.
const inspectionTimeLimit = time.Second
// text returns n bytes of letters, digits and spaces, which the Core Rule Set
// takes longer over than one letter repeated.
func text(n int) string {
const words = "select 0x2f from tbl_1 where name like abc_9 "
return strings.Repeat(words, n/len(words)+1)[:n]
}
// visitorRequest is a request a visitor can send, at the most of one or more
// of the bounds above.
type visitorRequest struct {
name string
target string
// header holds the headers besides Host.
header http.Header
// contentType is the body's type, "" for no body.
contentType string
body string
}
// withLine returns a target whose request line, "POST <target> HTTP/1.1", is
// lineLimit bytes long.
func withLine() string {
const around = len("POST /?q= HTTP/1.1")
return "/?q=" + strings.ReplaceAll(text(lineLimit-around), " ", "+")
}
// cookieLine returns a Cookie value whose line, "Cookie: <value>", is
// lineLimit bytes long, made of count cookies.
func cookieLine(count int) string {
const linePrefix = len("Cookie: ")
cookies := make([]string, count)
for i := range cookies {
cookies[i] = "c" + strconv.Itoa(i) + "="
}
// Each cookie is followed by "; ", but for the last.
room := lineLimit - linePrefix - len(strings.Join(cookies, "; "))
for i := range cookies {
share := room / count
if i == 0 {
share += room % count
}
cookies[i] += strings.ReplaceAll(text(share), " ", "_")
}
return strings.Join(cookies, "; ")
}
// headerValue returns a value whose line, "<name>: <value>", is n bytes long.
func headerValue(name string, n int) string {
return text(n - len(name) - len(": "))
}
// formFields returns a form body of mostBodyRead bytes holding count fields.
func formFields(count int) string {
fields := make([]string, count)
for i := range fields {
fields[i] = "f" + strconv.Itoa(i) + "="
}
room := mostBodyRead - len(strings.Join(fields, "&"))
for i := range fields {
share := room / count
if i == 0 {
share += room % count
}
fields[i] += strings.ReplaceAll(text(share), " ", "+")
}
return strings.Join(fields, "&")
}
// multipartBody returns a multipart body of mostBodyRead bytes, with the
// boundary "b", holding fields of about size bytes each.
func multipartBody(size int) string {
var body strings.Builder
const end = "--b--\r\n"
for i := 0; ; i++ {
head := "--b\r\nContent-Disposition: form-data; name=\"f" +
strconv.Itoa(i) + "\"\r\n\r\n"
room := mostBodyRead - body.Len() - len(
end,
) - len(
head,
) - len(
"\r\n",
)
if room <= 0 {
break
}
body.WriteString(head + text(min(size, room)) + "\r\n")
}
body.WriteString(end)
return body.String()
}
// The kinds of body the Core Rule Set reads, and the header that names a
// request's cookies.
const (
formType = "application/x-www-form-urlencoded"
multipartType = "multipart/form-data; boundary=b"
cookieHeader = "Cookie"
)
// fullLine is the length of each of the request line and the three or four
// header lines that together fill rproxy's limit, with room for the line
// endings and the other headers.
const fullLine = lineLimit - 64
// worstCaseRequests are, for each kind of thing a visitor sends, the requests
// that keep the Core Rule Set busiest, within the bounds above. The last
// holds the most of everything at once: Cookie lines keep it busier than
// other header lines.
func worstCaseRequests() []visitorRequest {
fullCookieLine := cookieLine(1)[:fullLine-len("Cookie: ")]
return append(
append(worstCaseHeaders(), worstCaseBodies()...),
visitorRequest{
name: "the longest request line, three Cookie lines and body",
target: withLine()[:fullLine-len("POST HTTP/1.1")],
header: http.Header{cookieHeader: {
fullCookieLine, fullCookieLine, fullCookieLine,
}},
contentType: formType, body: formFields(1),
},
)
}
// worstCaseHeaders are the requests of worstCaseRequests with no body.
func worstCaseHeaders() []visitorRequest {
headerFields := http.Header{}
for i := range mostHeaderFields - 1 {
name := "X-Field-" + strconv.Itoa(i)
headerFields.Set(
name,
headerValue(name, headerLimit/mostHeaderFields),
)
}
return []visitorRequest{
{name: "the longest request line", target: withLine()},
{
name: "the longest request line, as query parameters",
target: "/?" + strings.Repeat(
"a=1&",
(lineLimit-len("POST /? HTTP/1.1"))/len("a=1&"),
),
},
{
name: "the longest Cookie line, as one cookie",
target: "/",
header: http.Header{cookieHeader: {cookieLine(1)}},
},
{
name: "the longest Cookie line, as the most cookies",
target: "/",
header: http.Header{cookieHeader: {cookieLine(mostCookies)}},
},
{name: "the most header fields", target: "/", header: headerFields},
{
name: "the longest header lines",
target: "/",
header: http.Header{
"User-Agent": {headerValue("User-Agent", fullLine)},
"Referer": {headerValue("Referer", fullLine)},
"Accept": {headerValue("Accept", fullLine)},
"X-Long": {headerValue("X-Long", fullLine)},
},
},
}
}
// worstCaseBodies are the requests of worstCaseRequests whose body holds
// the most the Core Rule Set reads.
func worstCaseBodies() []visitorRequest {
const (
nested = (mostBodyRead - len(`{"a":1}`)) / len(`{"a":}`)
jsonText = `{"a":"` + "%s" + `"}`
)
return []visitorRequest{
{
name: "a form body of one field", target: "/",
contentType: formType, body: formFields(1),
},
{
name: "a form body of the most fields Coraza keeps", target: "/",
contentType: formType, body: formFields(1000),
},
{
name: "a JSON body of one string", target: "/",
contentType: "application/json",
body: strings.Replace(
jsonText,
"%s",
text(mostBodyRead-len(jsonText)+len("%s")),
1,
),
},
{
name: "a JSON body nested deep", target: "/",
contentType: "application/json",
body: strings.Repeat(`{"a":`, nested) + "1" +
strings.Repeat("}", nested),
},
{
name: "an XML body of many elements", target: "/",
contentType: "application/xml",
body: "<a>" + strings.Repeat(
"<b>x</b>",
(mostBodyRead-len("<a></a>"))/len("<b>x</b>"),
) + "</a>",
},
{
name: "a multipart body of one field", target: "/",
contentType: multipartType, body: multipartBody(mostBodyRead),
},
{
name: "a multipart body of many fields", target: "/",
contentType: multipartType, body: multipartBody(1),
},
}
}
// inspectionTime returns how long crs takes to inspect tc, and fails t unless
// it reads all of tc's body.
func inspectionTime(
t *testing.T, crs *coreruleset.CoreRuleSet, tc visitorRequest,
) time.Duration {
t.Helper()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, tc.target, nil,
)
maps.Copy(req.Header, tc.header)
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
began := time.Now()
_, read, _ := crs.Inspect(
req, netip.MustParseAddr("192.0.2.1"), strings.NewReader(tc.body),
)
took := time.Since(began)
if string(read) != tc.body {
t.Errorf("read %d bytes of %d", len(read), len(tc.body))
}
return took
}
// TestTheCoreRuleSetInspectsEachWorstCaseRequestInTime runs one at a time, so
// that the tests running in parallel do not slow it. Other work on the host
// can still slow one try, so a request fails only when three tries in a row
// take too long.
//
//nolint:paralleltest // one at a time, as above
func TestTheCoreRuleSetInspectsEachWorstCaseRequestInTime(t *testing.T) {
const tries = 3
// The settings that make the Core Rule Set slowest: the highest paranoia
// level, no rule switched off, and the most of a body read.
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 4, BodyInspectionSize: mostBodyRead,
})
for _, tc := range worstCaseRequests() {
t.Run(tc.name, func(t *testing.T) {
var took time.Duration
for range tries {
took = inspectionTime(t, crs, tc)
if took <= inspectionTimeLimit {
return
}
}
t.Errorf(
"took %s on the last of %d tries, over %s",
took, tries, inspectionTimeLimit,
)
})
}
}