waf: refuse request lines and header lines over 8 KiB first (closes #339)
check / check (push) Failing after 1m47s

The header limits now also refuse a request line over 8 KiB with 414
(action request_line_too_long) and a header line over 8 KiB, Host
included, with 431. Like the other header limits they are fixed, run
first and make no ban. One long query string or cookie no longer keeps
the Core Rule Set busy for a third of a second.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-13 20:29:27 +00:00
1 parent 3550956cbb
commit b0ac6fd9ce
10 files changed
+241 -39

No files matched your search

+130 -1
View File
@@ -39,6 +39,7 @@ const (
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
lineLimit = 8192
// cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie"
// tooLarge is how waf refuses a request over the header limits.
@@ -104,11 +105,139 @@ func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
fillHeaders(req, headerFieldLimit+1)
do(t, req)
// The longest header line is X-Request-ID's, with its 26-character id.
wantHeuristic(
t,
s.out.requestLine(t),
"header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`,
`{"longest_header_line_bytes": 40, "header_line_bytes_limit": 8192,
"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`,
)
}
// longQuery returns a path whose request line, sent by Go's client as
// "GET <path> HTTP/1.1", is lineBytes long.
func longQuery(lineBytes int) string {
const around = len("GET /?q= HTTP/1.1")
return "/?q=" + strings.Repeat("a", lineBytes-around)
}
func TestARequestLineOverTheLimitIsRefusedWith414BeforeAnyOtherCheck(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
name string
lineBytes int
// cookies is how many cookies the request carries.
cookies int
status int
action string
}{
{
"at the limit", lineLimit, 0, http.StatusOK,
requestlog.ActionForward,
},
{
"over the limit", lineLimit + 1, 0, http.StatusRequestURITooLong,
requestlog.ActionRequestLineTooLong,
},
{
"over the limit and the cookie limit", lineLimit + 1,
cookieLimit + 1, http.StatusRequestURITooLong,
requestlog.ActionRequestLineTooLong,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, longQuery(tc.lineBytes))
if tc.cookies > 0 {
req.Header.Set(cookieHeader, cookies(tc.cookies))
}
got := do(t, req)
line := s.out.requestLine(t)
if tc.status == http.StatusOK {
wantDisposition(t, got, tc.status, waf.DispositionAllow)
} else {
wantDisposition(t, got, tc.status, waf.DispositionDeny)
}
wantLine(t, line, tc.status, tc.action)
refused := tc.status != http.StatusOK
if refused != (line.DurationCoreRuleSetMS == nil) {
t.Errorf(
"Core Rule Set time %v for a request refused: %t",
line.DurationCoreRuleSetMS,
refused,
)
}
})
}
}
func TestAHeaderLineOverTheLimitIsRefusedWith431(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
make func(*http.Request)
// refused is true for a request over the limit, and false for one at it.
refused bool
}{
{"a header line at the limit", func(req *http.Request) {
req.Header.Set("X-Long", strings.Repeat("a", lineLimit-len("X-Long: ")))
}, false},
{"a header line over the limit", func(req *http.Request) {
req.Header.Set("X-Long", strings.Repeat("a", lineLimit+1-len("X-Long: ")))
}, true},
{"one cookie over the limit", func(req *http.Request) {
req.Header.Set(cookieHeader, "a="+strings.Repeat("a", lineLimit))
}, true},
{"a Host line over the limit", func(req *http.Request) {
req.Host = strings.Repeat("a", lineLimit+1-len("Host: "))
}, true},
{"a header sent twice, each line within the limit", func(req *http.Request) {
req.Header.Add("X-Long", strings.Repeat("a", 8000))
req.Header.Add("X-Long", strings.Repeat("a", 8000))
}, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", nil)
req := s.question(siteA, client, "/")
tc.make(req)
got := do(t, req)
if tc.refused {
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
} else {
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
}
})
}
}
func TestRequestLineRefusalNamesItsHeuristicLengthAndLimit(t *testing.T) {
t.Parallel()
s := startWAF(t, "", nil)
do(t, s.question(siteA, client, longQuery(lineLimit+1)))
wantHeuristic(
t,
s.out.requestLine(t),
"header_limits",
`{"request_line_bytes": 8193, "request_line_bytes_limit": 8192}`,
)
}