waf: decide every visitor request within rproxy's timeout (closes #394)
check / check (push) Failing after 2m58s
check / check (push) Failing after 2m58s
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
This commit is contained in:
1 parent
23788920f7
commit
8c236369f0
9 files changed
+402
-63
No files matched your search
@@ -1512,13 +1512,15 @@ const (
|
||||
wpTags = "/wp-json/wp/v2/tags"
|
||||
)
|
||||
|
||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||
// readingBodiesUpTo8K returns the Core Rule Set as sww runs it by default,
|
||||
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
|
||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
func readingBodiesUpTo8K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: 64<<10 - 1,
|
||||
ParanoiaLevel: 1,
|
||||
DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: mostBodyRead,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1582,7 +1584,7 @@ func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo8K(t)
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
@@ -1693,7 +1695,7 @@ func wordpressRESTAddresses(route string) []string {
|
||||
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodiesUpTo64K(t)
|
||||
crs := readingBodiesUpTo8K(t)
|
||||
content := `{"content":"` + wordpressBlock + `"}`
|
||||
|
||||
for _, route := range []string{
|
||||
@@ -1919,7 +1921,7 @@ func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(
|
||||
wantResult(t, crs, get(target+command), matched(932235))
|
||||
}
|
||||
|
||||
bodies := readingBodiesUpTo64K(t)
|
||||
bodies := readingBodiesUpTo8K(t)
|
||||
|
||||
for _, p := range wordpressBodyFields(command) {
|
||||
r := request{http.MethodPost, p.target, []string{p.contentType}}
|
||||
@@ -2143,7 +2145,7 @@ func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
|
||||
func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodiesUpTo64K(t)
|
||||
crs := readingBodiesUpTo8K(t)
|
||||
|
||||
for _, v := range wordpressWords() {
|
||||
for _, field := range []string{"title", "excerpt", "name"} {
|
||||
@@ -2262,7 +2264,7 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||
length = "Content-Length: 2"
|
||||
)
|
||||
|
||||
crs := readingBodiesUpTo64K(t)
|
||||
crs := readingBodiesUpTo8K(t)
|
||||
saving := request{
|
||||
http.MethodPost,
|
||||
wpPost,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user