Core Rule Set reads request bodies up to SWWAF_WAF_BODY_LIMIT (closes #116)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
SWWAF_WAF_BODY_LIMIT (default off, at most 1G) has the Core Rule Set read form data and multipart up to the limit, the rest streaming on, and JSON and XML (with +json, text/json and +xml) no larger than it. The part read is held for the app. A size or time limit met while reading ends the request. Content-Encoding is refused again on these kinds. A body Coraza cannot parse, or a multipart body failing its strict checks, adds 5, but not a multipart body reaching the limit. Coraza is built with no_fs_access, so writes no file. Rule 900300 moves to phase 2. Judgement call: Content-Encoding is refused on a JSON or XML body too large to read, as SPEC.md allows. Model: opus-5-5
This commit is contained in:
+139
-30
@@ -1,12 +1,18 @@
|
||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||
// method, the URL with its query and the headers of a request, with the
|
||||
// six changes smallwebwaf makes to it, as "Attack detection" under
|
||||
// "Configuration surface" in SPEC.md describes them. It reads no request
|
||||
// body and no response.
|
||||
// method, the URL with its query and the headers of a request, and on its
|
||||
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf
|
||||
// makes to it, as "Attack detection" under "Configuration surface" in
|
||||
// SPEC.md describes them. It reads no response.
|
||||
//
|
||||
// smallwebwaf writes only to its state directory, so Coraza is built with
|
||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||
// file in a multipart body, Coraza then counts the bytes instead of
|
||||
// writing them to the system's temporary directory.
|
||||
package waf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -20,15 +26,16 @@ import (
|
||||
)
|
||||
|
||||
// directives are the Core Rule Set as smallwebwaf runs it, with the
|
||||
// paranoia level for %d. Each rule smallwebwaf adds has an id from 900000
|
||||
// to 900999, the ids the Core Rule Set keeps for the rules that set it
|
||||
// up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting switches
|
||||
// one off. Coraza joins a line ending in \ to the next, without the spaces
|
||||
// at the start of the next.
|
||||
// paranoia level for %d, and bodyDirectives for %s while
|
||||
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from
|
||||
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
|
||||
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
|
||||
// switches one off. Coraza joins a line ending in \ to the next, without
|
||||
// the spaces at the start of the next.
|
||||
const directives = `
|
||||
# The engine only detects. smallwebwaf compares the request's anomaly
|
||||
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||
# alike. It reads no body.
|
||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||
SecRuleEngine DetectionOnly
|
||||
SecRequestBodyAccess Off
|
||||
SecResponseBodyAccess Off
|
||||
@@ -44,12 +51,23 @@ SecAction "id:900200,phase:1,pass,nolog,\
|
||||
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
|
||||
|
||||
# The second: Expect and Content-Encoding are taken off the Core Rule Set's
|
||||
# list of the headers it refuses. Content-Encoding stays refused on a body
|
||||
# the Core Rule Set reads, and it reads none.
|
||||
# list of the headers it refuses. Content-Encoding goes back on it for a
|
||||
# body the Core Rule Set reads (900260 in bodyDirectives).
|
||||
SecAction "id:900250,phase:1,pass,nolog,\
|
||||
setvar:'tx.restricted_headers_basic=/proxy/ /lock-token/ /content-range/ \
|
||||
/if/ /x-http-method-override/ /x-http-method/ /x-method-override/ \
|
||||
/x-middleware-subrequest/'"
|
||||
%s
|
||||
# Coraza keeps the first 1000 query parameters of a request, and the first
|
||||
# 1000 fields of a form data or JSON body, and drops the rest, which no
|
||||
# rule then reads, so a request with more adds 5 to the score, as a rule
|
||||
# the Core Rule Set rates critical does. Coraza's recommended
|
||||
# configuration refuses such a request in its rules 200004 and 200005.
|
||||
# This rule runs once the body is read, and before the Core Rule Set adds
|
||||
# up the score in the same phase.
|
||||
SecArgumentsLimit 1000
|
||||
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
|
||||
# The sixth: only the rules for requests are loaded, and no response is
|
||||
# inspected.
|
||||
@@ -57,7 +75,9 @@ Include @owasp_crs/REQUEST-*.conf
|
||||
|
||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
||||
# without regard to case.
|
||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
||||
# body Coraza reads as well as the query parameters, so a field of one of
|
||||
# these names is left out too.
|
||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||
|
||||
@@ -80,15 +100,48 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
# Inspect.
|
||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||
`
|
||||
|
||||
# Coraza keeps the first 1000 query parameters of a request and drops the
|
||||
# rest, which no rule then reads, so a request with more adds 5 to the
|
||||
# score, as a rule the Core Rule Set rates critical does. Coraza's
|
||||
# recommended configuration refuses such a request in its rule 200004.
|
||||
# This rule comes after the Core Rule Set's, which set the score to 0 in
|
||||
# the same phase.
|
||||
SecArgumentsLimit 1000
|
||||
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:1,pass,\
|
||||
// bodyDirectives have the Core Rule Set read the part of a request body
|
||||
// Inspect gives it, which is at most one byte longer than the limit, up to
|
||||
// the limit, %d bytes, and read JSON and XML as Coraza's recommended
|
||||
// configuration has it in its rules 200000, 200001 and 200006, with any
|
||||
// type ending in +xml, and text/json, besides; form data and multipart
|
||||
// Coraza knows by itself. %% stands for a % Coraza reads.
|
||||
const bodyDirectives = `
|
||||
SecRequestBodyAccess On
|
||||
SecRequestBodyLimit %d
|
||||
SecRequestBodyLimitAction ProcessPartial
|
||||
|
||||
SecRule REQUEST_HEADERS:Content-Type \
|
||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?xml" \
|
||||
"id:900410,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=XML"
|
||||
SecRule REQUEST_HEADERS:Content-Type \
|
||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?json" \
|
||||
"id:900420,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=JSON"
|
||||
|
||||
# The rest of the second change: Content-Encoding is refused again on a
|
||||
# body of a kind the Core Rule Set reads, since a compressed body cannot be
|
||||
# inspected.
|
||||
SecRule REQBODY_PROCESSOR "@rx ^(?:URLENCODED|MULTIPART|JSON|XML)$" \
|
||||
"id:900260,phase:1,pass,nolog,\
|
||||
setvar:'tx.restricted_headers_basic=%%{tx.restricted_headers_basic} \
|
||||
/content-encoding/'"
|
||||
|
||||
# A body Coraza fails to parse (900440), and a multipart body that fails
|
||||
# its strict checks (900450), each add 5 to the score, as a rule the Core
|
||||
# Rule Set rates critical does: no rule reads what comes after the fault,
|
||||
# which the app may still read. Coraza's recommended configuration refuses
|
||||
# them in its rules 200002 and 200003. A multipart body that reaches the
|
||||
# limit is let off both (900430), since the limit can cut it inside a
|
||||
# part's header, which Coraza takes for such a fault. Coraza parses any
|
||||
# form data body.
|
||||
SecRule INBOUND_DATA_ERROR "@eq 1" "id:900430,phase:2,pass,nolog,chain"
|
||||
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
|
||||
"ctl:ruleRemoveById=900440,ctl:ruleRemoveById=900450"
|
||||
SecRule REQBODY_ERROR "!@eq 0" "id:900440,phase:2,pass,severity:'CRITICAL',\
|
||||
setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
`
|
||||
|
||||
@@ -105,18 +158,28 @@ type Params struct {
|
||||
// DisabledRules are the ids of the rules switched off
|
||||
// (SWWAF_WAF_DISABLED_RULES).
|
||||
DisabledRules []int
|
||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||
BodyLimit int64
|
||||
}
|
||||
|
||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||
// for concurrent use.
|
||||
type CoreRuleSet struct {
|
||||
waf coraza.WAF
|
||||
waf coraza.WAF
|
||||
bodyLimit int64
|
||||
}
|
||||
|
||||
// New returns the Core Rule Set with the six changes, at params'
|
||||
// paranoia level and without the rules it switches off.
|
||||
// paranoia level, without the rules it switches off, and reading request
|
||||
// bodies up to params' limit.
|
||||
func New(params Params) (*CoreRuleSet, error) {
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel)
|
||||
body := ""
|
||||
if params.BodyLimit > 0 {
|
||||
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body)
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
@@ -134,7 +197,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||
}
|
||||
|
||||
return &CoreRuleSet{waf: waf}, nil
|
||||
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
||||
}
|
||||
|
||||
// Result is what the Core Rule Set found in a request.
|
||||
@@ -148,10 +211,15 @@ type Result struct {
|
||||
|
||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
||||
// method, its URL with the query, and its headers, the Cookie header
|
||||
// without the cookies in cookiesNotRead.
|
||||
func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||
// caller has it, as readBody reads it. It returns what it found, what it
|
||||
// read of body, which the app is still to be sent, and the error that
|
||||
// ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) Inspect(
|
||||
r *http.Request, client netip.Addr, body io.Reader,
|
||||
) (Result, []byte, error) {
|
||||
tx := c.waf.NewTransaction()
|
||||
// With no body read, there is nothing whose closing can fail.
|
||||
// Closing would remove the files Coraza wrote, and it writes none.
|
||||
defer func() { _ = tx.Close() }()
|
||||
|
||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||
@@ -178,7 +246,11 @@ func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
||||
}
|
||||
|
||||
tx.ProcessRequestHeaders()
|
||||
// With no body read, this runs the rest of the rules, and cannot fail.
|
||||
|
||||
read, err := c.readBody(tx, body)
|
||||
|
||||
// This reads the body in memory and runs the rest of the rules, and
|
||||
// cannot fail.
|
||||
_, _ = tx.ProcessRequestBody()
|
||||
|
||||
var ids []int
|
||||
@@ -192,7 +264,44 @@ func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
||||
}
|
||||
}
|
||||
|
||||
return Result{RuleIDs: ids, Score: score(tx)}
|
||||
return Result{RuleIDs: ids, Score: score(tx)}, read, err
|
||||
}
|
||||
|
||||
// readBody reads body, the body of the request in tx, which has run on
|
||||
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is
|
||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||
// when they are no longer than that, since they cannot be read in part.
|
||||
// readBody reads one byte past the limit, to tell which they are, gives
|
||||
// the Core Rule Set what it reads, and returns what it read and the error
|
||||
// that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) {
|
||||
if c.bodyLimit == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
inPart := false
|
||||
// How the body is read is a variable of the transaction, which only
|
||||
// Coraza's interface for plugins reads.
|
||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||
|
||||
switch state.Variables().RequestBodyProcessor().Get() {
|
||||
case "URLENCODED", "MULTIPART":
|
||||
inPart = true
|
||||
case "JSON", "XML":
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
||||
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
||||
// Coraza holds what it reads of the body in memory, up to the
|
||||
// limit, so this cannot fail.
|
||||
_, _, _ = tx.WriteRequestBody(read)
|
||||
}
|
||||
|
||||
return read, err
|
||||
}
|
||||
|
||||
// score returns the anomaly score the Core Rule Set added up in tx, a
|
||||
|
||||
Reference in New Issue
Block a user