check / check (push) Waiting to run
Coraza v3.8.1 runs the Core Rule Set 4.25.0 (coraza-coreruleset v4.25.0) after the rule files, with the six changes and the default SWWAF_WAF_DISABLED_RULES that SPEC.md gives; no body, no response. The parameter names in the third and fourth changes are matched in any case, as Coraza does. SWWAF_WAF_DISABLED_RULES refuses 900000 to 900999, smallwebwaf's own rules. A request with more than 1000 query parameters adds 5 (rule 900300). In block mode a match is refused with 403, an offence counted toward the error burst; in detect mode it is let through. Both log waf_rule_ids, waf_score and duration_waf, raise waf_block, and count smallwebwaf_waf_matches_total. Judgement call: waf_block is raised in block mode too. Deviation: no engine-error path; with no body read, Coraza cannot fail. Model: opus-5-5
229 lines
7.7 KiB
Go
229 lines
7.7 KiB
Go
// 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.
|
|
package waf
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
coreruleset "github.com/corazawaf/coraza-coreruleset/v4"
|
|
"github.com/corazawaf/coraza/v3"
|
|
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
|
|
"github.com/corazawaf/coraza/v3/types"
|
|
)
|
|
|
|
// 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.
|
|
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.
|
|
SecRuleEngine DetectionOnly
|
|
SecRequestBodyAccess Off
|
|
SecResponseBodyAccess Off
|
|
|
|
Include @crs-setup.conf.example
|
|
|
|
SecAction "id:900000,phase:1,pass,nolog,\
|
|
setvar:tx.blocking_paranoia_level=%d"
|
|
|
|
# The first change: PUT, PATCH and DELETE are allowed besides GET, HEAD,
|
|
# POST and OPTIONS.
|
|
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.
|
|
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/'"
|
|
|
|
# The sixth: only the rules for requests are loaded, and no response is
|
|
# inspected.
|
|
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.
|
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
|
|
|
# The fourth: the query parameters in which gitea sends names within a
|
|
# repository or its own records, or a page of its own site, are not
|
|
# checked against the lists of system files, shell paths and command
|
|
# names. Coraza takes one rule id per directive.
|
|
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
!ARGS:artifactName|!ARGS:redirect_to"
|
|
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
!ARGS:artifactName|!ARGS:redirect_to"
|
|
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
!ARGS:artifactName|!ARGS:redirect_to"
|
|
|
|
# The fifth, for Referer: it is not checked for a Unix command without
|
|
# arguments, or for Java starting a process. The cookies are left out in
|
|
# 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,\
|
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
|
`
|
|
|
|
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
|
// without, the rest of the fifth change.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
|
|
|
// Params are what New needs.
|
|
type Params struct {
|
|
// ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4.
|
|
ParanoiaLevel int
|
|
// DisabledRules are the ids of the rules switched off
|
|
// (SWWAF_WAF_DISABLED_RULES).
|
|
DisabledRules []int
|
|
}
|
|
|
|
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
|
// for concurrent use.
|
|
type CoreRuleSet struct {
|
|
waf coraza.WAF
|
|
}
|
|
|
|
// New returns the Core Rule Set with the six changes, at params'
|
|
// paranoia level and without the rules it switches off.
|
|
func New(params Params) (*CoreRuleSet, error) {
|
|
text := fmt.Sprintf(directives, params.ParanoiaLevel)
|
|
|
|
if len(params.DisabledRules) > 0 {
|
|
ids := make([]string, len(params.DisabledRules))
|
|
for i, id := range params.DisabledRules {
|
|
ids[i] = strconv.Itoa(id)
|
|
}
|
|
|
|
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
|
|
}
|
|
|
|
waf, err := coraza.NewWAF(coraza.NewWAFConfig().
|
|
WithRootFS(coreruleset.FS).
|
|
WithDirectives(text))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
|
}
|
|
|
|
return &CoreRuleSet{waf: waf}, nil
|
|
}
|
|
|
|
// Result is what the Core Rule Set found in a request.
|
|
type Result struct {
|
|
// RuleIDs are the ids of the rules that matched, in the order they
|
|
// ran.
|
|
RuleIDs []int
|
|
// Score is the request's anomaly score: what those rules add up to.
|
|
Score int
|
|
}
|
|
|
|
// 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 {
|
|
tx := c.waf.NewTransaction()
|
|
// With no body read, there is nothing whose closing can fail.
|
|
defer func() { _ = tx.Close() }()
|
|
|
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
|
|
|
for name, values := range r.Header {
|
|
for _, value := range values {
|
|
if name == "Cookie" {
|
|
value = withoutCookiesNotRead(value)
|
|
if value == "" {
|
|
continue // it held those cookies alone
|
|
}
|
|
}
|
|
|
|
tx.AddRequestHeader(name, value)
|
|
}
|
|
}
|
|
|
|
// Go's server takes these two out of the headers.
|
|
tx.AddRequestHeader("Host", r.Host)
|
|
|
|
for _, encoding := range r.TransferEncoding {
|
|
tx.AddRequestHeader("Transfer-Encoding", encoding)
|
|
}
|
|
|
|
tx.ProcessRequestHeaders()
|
|
// With no body read, this runs the rest of the rules, and cannot fail.
|
|
_, _ = tx.ProcessRequestBody()
|
|
|
|
var ids []int
|
|
|
|
for _, matched := range tx.MatchedRules() {
|
|
// The rules that look for attacks have a severity; the others set
|
|
// the Core Rule Set up and add up the score.
|
|
rule := matched.Rule()
|
|
if rule.Severity() != types.RuleSeverityUnset {
|
|
ids = append(ids, rule.ID())
|
|
}
|
|
}
|
|
|
|
return Result{RuleIDs: ids, Score: score(tx)}
|
|
}
|
|
|
|
// score returns the anomaly score the Core Rule Set added up in tx, a
|
|
// transaction it has run, or 0 if a rule that adds it up is switched off.
|
|
func score(tx types.Transaction) int {
|
|
// The score is in a variable of the transaction, which only Coraza's
|
|
// interface for plugins reads.
|
|
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
|
|
|
values := state.Variables().TX().Get("blocking_inbound_anomaly_score")
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
|
|
n, _ := strconv.Atoi(values[0])
|
|
|
|
return n
|
|
}
|
|
|
|
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
|
// cookies in cookiesNotRead.
|
|
func withoutCookiesNotRead(value string) string {
|
|
var kept []string
|
|
|
|
for cookie := range strings.SplitSeq(value, ";") {
|
|
name, _, _ := strings.Cut(strings.TrimSpace(cookie), "=")
|
|
if !slices.Contains(cookiesNotRead, name) {
|
|
kept = append(kept, cookie)
|
|
}
|
|
}
|
|
|
|
return strings.Join(kept, ";")
|
|
}
|