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

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 was merged in pull request #380.
This commit is contained in:
clawbot committed 2026-11-13 21:56:09 +01:00
1 parent 3550956cbb
commit 53d9bf6af6
10 files changed
+241 -39

No files matched your search

+7 -2
View File
@@ -21,7 +21,12 @@ const (
// the app.
ActionForward = "forward"
// ActionRequestHeaderTooLarge is a request over the header limits.
// ActionRequestLineTooLong is a request whose request line is too long for
// the header limits.
ActionRequestLineTooLong = "request_line_too_long"
// ActionRequestHeaderTooLarge is a request whose headers are over the
// header limits.
ActionRequestHeaderTooLarge = "request_header_too_large"
// ActionClientIsRateLimited is a request refused because it took its
@@ -143,7 +148,7 @@ type Line struct {
// taken with a request it would have refused: ActionClientDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
// ActionBlockedByCoreRuleSet, or, in waf,
// ActionBlockedByCoreRuleSet, or, in waf, ActionRequestLineTooLong or
// ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
+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}`,
)
}
+52 -7
View File
@@ -10,16 +10,22 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The header limits: the most cookies and header fields a request may have.
// Real clients never reach them.
// The header limits: the most cookies and header fields a request may have,
// and the longest its request line and each header line may be, in bytes, not
// counting the line ending. Real clients rarely reach them. maxLineBytes is
// what nginx allows by default.
const (
maxCookies = 50
maxHeaderFields = 100
maxLineBytes = 8 << 10
)
// headerLimits refuses a request with more than maxHeaderFields header fields
// or more than maxCookies cookies, with 431. Its figures are the request's
// header fields and cookies, and the limit of each.
// headerLimits refuses a request with a request line longer than maxLineBytes,
// with 414. It refuses a request with a header line longer than maxLineBytes,
// more than maxHeaderFields header fields or more than maxCookies cookies, with
// 431. Its figures are the request line's length and its limit for 414. For
// 431 they are the longest header line, the header fields and the cookies,
// each with its limit.
type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
@@ -29,20 +35,59 @@ func (headerLimits) check(
rq *request,
_ time.Time,
) *verdict {
requestLine := rq.requestLineBytes()
if requestLine > maxLineBytes {
return &verdict{
action: requestlog.ActionRequestLineTooLong,
figures: map[string]any{
"request_line_bytes": requestLine,
"request_line_bytes_limit": maxLineBytes,
},
}
}
longest := rq.longestHeaderLineBytes()
fields, cookies := rq.headerCounts()
if fields <= maxHeaderFields && cookies <= maxCookies {
if longest <= maxLineBytes && fields <= maxHeaderFields &&
cookies <= maxCookies {
return nil
}
return &verdict{
action: requestlog.ActionRequestHeaderTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"longest_header_line_bytes": longest,
"header_line_bytes_limit": maxLineBytes,
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
},
}
}
// requestLineBytes returns the length of the request's request line, such as
// "GET /search?q=x HTTP/1.1", not counting its line ending. The target is the
// one the visitor sent, which rproxy's question carries unchanged.
func (rq *request) requestLineBytes() int {
return len(rq.in.Method) + len(" ") + len(rq.in.RequestURI) +
len(" ") + len(rq.in.Proto)
}
// longestHeaderLineBytes returns the length of the request's longest header
// line, such as "Cookie: a=1", not counting its line ending. Each value of a
// header sent twice is a line of its own, and Host is a line too.
func (rq *request) longestHeaderLineBytes() int {
longest := len("Host: ") + len(rq.in.Host)
for name, values := range rq.in.Header {
for _, value := range values {
longest = max(longest, len(name)+len(": ")+len(value))
}
}
return longest
}
// headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core
+2 -2
View File
@@ -41,8 +41,8 @@ const (
// beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up.
// header_limits comes first: it only counts, and a request it refuses causes no
// other work.
// header_limits comes first: it only measures and counts, and a request it
// refuses causes no other work.
func beforeLookup() []heuristic {
return []heuristic{
headerLimits{},
+3 -1
View File
@@ -177,7 +177,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
// check is the one place where a request can be refused. It returns nil to let
// the request through. The heuristics of checkClient decide: a request they
// refuse is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
// Rule Set, or 414 or 431 for the header limits. In observe mode nothing is refused,
// and the log line names what enforce mode would have done. ctx is the
// request's own context.
func (rq *request) check(ctx context.Context) *refusal {
@@ -196,6 +196,8 @@ func (rq *request) check(ctx context.Context) *refusal {
case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionRequestLineTooLong:
return &refusal{status: http.StatusRequestURITooLong, action: action}
case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{
status: http.StatusRequestHeaderFieldsTooLarge,
+6 -1
View File
@@ -1690,7 +1690,12 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
strings.NewReader("a body"))
req.Header.Set(waf.ContentLengthHeader, "1073741824")
req.Header.Set(forwardedFor, otherClient)
req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
// 40,000 bytes of headers, over rproxy's 32 KiB, in lines within the
// header limits.
for i := range 5 {
req.Header.Set("X-Long-"+strconv.Itoa(i), strings.Repeat("x", 8000))
}
got := do(t, req)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)