Compare commits

...
1 Commits
Author SHA1 Message Date
sneak 7a627f44e1 waf: decide every visitor request within rproxy's timeout (closes #394)
check / check (push) In progress
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:52:21 +00:00
9 changed files with 411 additions and 63 deletions

No files matched your search

+1 -1
View File
@@ -348,7 +348,7 @@ Every setting the code reads, with its default:
| `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `8K`; `off` reads none. |
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. | | `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
+22 -16
View File
@@ -704,13 +704,13 @@ files, the limits and the bans still apply to the client that sends it.
rules is loaded yet. rules is loaded yet.
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up - `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up
to that size. It takes at most `64K`, since `waf` gets no more of a body than to that size. It takes at most `8K`, so that `waf` answers in time (see
that (see "The answer"). Form data and multipart bodies are read up to the "Answering in time"). Form data and multipart bodies are read up to the size.
size. JSON and XML bodies are read only when no larger than the size, since JSON and XML bodies are read only when no larger than the size, since those
those cannot be read in part. Any other body is not read: the Core Rule Set cannot be read in part. Any other body is not read: the Core Rule Set would
would read it as form data, where binary content such as a git push trips read it as form data, where binary content such as a git push trips rules
rules written for text. A body the Core Rule Set cannot parse adds 5 to the written for text. A body the Core Rule Set cannot parse adds 5 to the score,
score, and so does a multipart body cut by the limit in the middle of a part's and so does a multipart body cut by the limit in the middle of a part's
header. Body inspection suits apps whose forms carry no code. header. Body inspection suits apps whose forms carry no code.
- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask - `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask
for, such as `/wp-login.php` in front of an app that is not WordPress. This is for, such as `/wp-login.php` in front of an app that is not WordPress. This is
@@ -2397,9 +2397,7 @@ In `observe` mode a request that `enforce` mode would refuse is answered
answered `404`. answered `404`.
The Core Rule Set inspects the body a question carries only while The Core Rule Set inspects the body a question carries only while
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. It inspects at most 64 KiB `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set, and at most 8 KiB of it.
less one byte. So it does not inspect a JSON or XML body of 64 KiB or more,
which may have been cut short.
### Answering in time ### Answering in time
@@ -2474,12 +2472,20 @@ client in `SWW_WAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In
`observe` mode such a request is answered `allow` with `would_action`, and no `observe` mode such a request is answered `allow` with `would_action`, and no
other check runs on it. other check runs on it.
The Core Rule Set runs each of its rules on each cookie, so thousands of small The Core Rule Set is the one check a visitor can keep busy for long. It runs
cookies would keep it busy for over a second. One 30 KB cookie or query string each of its rules on each part of a request, so thousands of small cookies would
would keep it busy for a third of a second or more. A request with several long keep it busy for over a second. These fixed limits bound what it reads:
`Cookie` lines, each within the limits, can keep it as busy. Enough such
requests at once can still overload `waf` past - the header limits above;
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. - `rproxy`'s limit of 32 KiB on the request line and headers together (see
"`rproxy`'s limits");
- 8 KiB of a body, the most `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` takes.
A request can reach all of these at once. At paranoia level 4, with no rule
switched off, the Core Rule Set takes under a second on one core of a current
server to inspect it. So `waf` answers it within the default 2 seconds, even
after the default 800 milliseconds of waiting for GeoJS. Enough such requests at
once can still overload `waf` past `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
### The report ### The report
+3 -4
View File
@@ -251,10 +251,9 @@ tag that no request can get stops the start. A request can get only these:
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core - `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
Rule Set does not inspect. Rule Set does not inspect.
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how - `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how
much of a request body the Core Rule Set reads, a size of at most `64K`. A much of a request body the Core Rule Set reads, a size of at most `8K`. A
question carries no more of a body than 64 KiB, and the Core Rule Set reads at larger size would let one request keep it busy past `rproxy`'s timeout (see
most 64 KiB less one byte of it, whatever the value (see "The answer" in "Answering in time" in [`SPEC.md`](../SPEC.md)).
[`SPEC.md`](../SPEC.md)).
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
Set exclusions, written as Coraza directives. It is loaded before the Core Set exclusions, written as Coraza directives. It is loaded before the Core
Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
+6 -6
View File
@@ -365,8 +365,8 @@ var (
"it names the file to look clients up in", "it names the file to look clients up in",
) )
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New( errOver8K = errors.New(
"is more than 64K, the most waf gets of a body") "is more than 8K, the most the Core Rule Set can read in time")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -1601,16 +1601,16 @@ func durationOrDays(value string) (time.Duration, error) {
} }
// parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set // parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 64K, since waf // reads: a size as parseSize reads it, or off, but at most 8K, so that one
// gets no more of a body than that. // request cannot keep the Core Rule Set busy past rproxy's timeout.
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) { func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
limit, err := parseSize(value) limit, err := parseSize(value)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if limit > 64*kibibyte { if limit > 8*kibibyte {
return 0, fmt.Errorf("%q %w", value, errOver64K) return 0, fmt.Errorf("%q %w", value, errOver8K)
} }
return limit, nil return limit, nil
+12 -10
View File
@@ -616,7 +616,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
coreRuleSetScoreThreshold: "10", coreRuleSetScoreThreshold: "10",
coreRuleSetDisabledRules: "942100, 920350", coreRuleSetDisabledRules: "942100, 920350",
coreRuleSetExemptPaths: "/api/, /static/", coreRuleSetExemptPaths: "/api/, /static/",
coreRuleSetBodyInspectionSize: "32K", coreRuleSetBodyInspectionSize: "4K",
exclusionsFile: "/etc/sww/crs-exclusions.conf", exclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
config.WAF{ config.WAF{
@@ -625,7 +625,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
CoreRuleSetScoreThreshold: 10, CoreRuleSetScoreThreshold: 10,
CoreRuleSetDisabledRules: []int{942100, 920350}, CoreRuleSetDisabledRules: []int{942100, 920350},
CoreRuleSetExemptPaths: []string{"/api/", "/static/"}, CoreRuleSetExemptPaths: []string{"/api/", "/static/"},
CoreRuleSetBodyInspectionSize: 32 << 10, CoreRuleSetBodyInspectionSize: 4 << 10,
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf", CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
}, },
@@ -762,15 +762,15 @@ func TestInvalidAppsStopsTheStart(t *testing.T) {
} }
} }
func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) { func TestCoreRuleSetBodyInspectionSizeOf8K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{coreRuleSetBodyInspectionSize: "64K"}, environment{coreRuleSetBodyInspectionSize: "8K"},
) )
if cfg.CoreRuleSetBodyInspectionSize != 64<<10 { if cfg.CoreRuleSetBodyInspectionSize != 8<<10 {
t.Errorf("64K read as %d", cfg.CoreRuleSetBodyInspectionSize) t.Errorf("8K read as %d", cfg.CoreRuleSetBodyInspectionSize)
} }
} }
@@ -819,11 +819,13 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
coreRuleSetBodyInspectionSize, "128KB", coreRuleSetBodyInspectionSize, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"128KB" is not a size such as 512K, 100M or 5G, or off`,
}, },
{coreRuleSetBodyInspectionSize, "65K", `"65K" is more than 64K, the most waf ` +
`gets of a body`},
{ {
coreRuleSetBodyInspectionSize, "65537", coreRuleSetBodyInspectionSize, "9K",
`"65537" is more than 64K, the most waf gets of a body`, `"9K" is more than 8K, the most the Core Rule Set can read in time`,
},
{
coreRuleSetBodyInspectionSize, "8193",
`"8193" is more than 8K, the most the Core Rule Set can read in time`,
}, },
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
+10 -8
View File
@@ -1512,13 +1512,15 @@ const (
wpTags = "/wp-json/wp/v2/tags" 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. // 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() t.Helper()
return shared(t, coreruleset.Params{ 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 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{ for _, attack := range []string{
sqlInjection, sqlInjection,
@@ -1693,7 +1695,7 @@ func wordpressRESTAddresses(route string) []string {
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) { func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
t.Parallel() t.Parallel()
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
content := `{"content":"` + wordpressBlock + `"}` content := `{"content":"` + wordpressBlock + `"}`
for _, route := range []string{ for _, route := range []string{
@@ -1919,7 +1921,7 @@ func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(
wantResult(t, crs, get(target+command), matched(932235)) wantResult(t, crs, get(target+command), matched(932235))
} }
bodies := readingBodiesUpTo64K(t) bodies := readingBodiesUpTo8K(t)
for _, p := range wordpressBodyFields(command) { for _, p := range wordpressBodyFields(command) {
r := request{http.MethodPost, p.target, []string{p.contentType}} r := request{http.MethodPost, p.target, []string{p.contentType}}
@@ -2143,7 +2145,7 @@ func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
t.Parallel() t.Parallel()
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
for _, v := range wordpressWords() { for _, v := range wordpressWords() {
for _, field := range []string{"title", "excerpt", "name"} { for _, field := range []string{"title", "excerpt", "name"} {
@@ -2262,7 +2264,7 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
length = "Content-Length: 2" length = "Content-Length: 2"
) )
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
saving := request{ saving := request{
http.MethodPost, http.MethodPost,
wpPost, wpPost,
+345
View File
@@ -0,0 +1,345 @@
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
}
// longestTarget returns a target whose request line, "POST <target>
// HTTP/1.1", is lineLimit bytes long.
func longestTarget() 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()
}
// jsonArrayOfMostElements returns a JSON array of at most mostBodyRead bytes
// holding as many elements as fit, each the number 1.
func jsonArrayOfMostElements() string {
return "[1" + strings.Repeat(
",1",
(mostBodyRead-len("[1]"))/len(",1"),
) + "]"
}
// 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"
jsonType = "application/json"
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, and the JSON body of the most elements keeps it busier
// than the other bodies.
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: longestTarget()[:fullLine-len("POST HTTP/1.1")],
header: http.Header{cookieHeader: {
fullCookieLine, fullCookieLine, fullCookieLine,
}},
contentType: jsonType, body: jsonArrayOfMostElements(),
},
)
}
// 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: longestTarget()},
{
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":}`)
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: jsonType,
body: `{"a":"` + text(
mostBodyRead-len(`{"a":""}`),
) + `"}`,
},
{
name: "a JSON body of the most elements", target: "/",
contentType: jsonType, body: jsonArrayOfMostElements(),
},
{
name: "a JSON body nested deep", target: "/",
contentType: jsonType,
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,
)
})
}
}
+8 -14
View File
@@ -228,12 +228,9 @@ func newReputation(
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in // up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS, // SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It // or nil while SWW_WAF_CORE_RULE_SET_MODE is off. An exclusions file that
// reads at most QuestionBodyMaxBytes less one byte: a question's body of // coreruleset.New refuses is an error in every mode. While the mode is off, a
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // set file is loaded with the Core Rule Set only to check it.
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) { func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff && if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
cfg.CoreRuleSetExclusionsFile == "" { cfg.CoreRuleSetExclusionsFile == "" {
@@ -241,14 +238,11 @@ func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
} }
coreRuleSet, err := coreruleset.New(coreruleset.Params{ coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel, ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
DisabledRules: cfg.CoreRuleSetDisabledRules, DisabledRules: cfg.CoreRuleSetDisabledRules,
BodyInspectionSize: min( BodyInspectionSize: cfg.CoreRuleSetBodyInspectionSize,
cfg.CoreRuleSetBodyInspectionSize, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
QuestionBodyMaxBytes-1, Apps: cfg.Apps,
),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
+4 -4
View File
@@ -1787,7 +1787,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel() t.Parallel()
s := startWAF(t, "", map[string]string{ s := startWAF(t, "", map[string]string{
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "64K", coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "8K",
}) })
ask := func(contentType, body string) answer { ask := func(contentType, body string) answer {
@@ -1802,10 +1802,10 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
got := ask(formData, "id=1'%20OR%20'1'='1") got := ask(formData, "id=1'%20OR%20'1'='1")
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)
// The first 64 KiB of a longer JSON body cannot be parsed, which is no // The first 8 KiB of a longer JSON body cannot be parsed, which is no
// attack, so the Core Rule Set does not read it. // attack, so the Core Rule Set does not read it.
cut := `{"text":"` + strings.Repeat("x", waf.QuestionBodyMaxBytes) cut := `{"text":"` + strings.Repeat("x", 8<<10)
got = ask("application/json", cut[:waf.QuestionBodyMaxBytes]) got = ask("application/json", cut)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow) wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
} }