check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
474 lines
17 KiB
Go
474 lines
17 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, and on its
|
|
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the eight changes sww
|
|
// makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
|
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
|
// response.
|
|
//
|
|
// sww 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 (
|
|
_ "embed"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"regexp"
|
|
"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 sww runs it, with the
|
|
// paranoia level for %d, bodyDirectives for the first %s while
|
|
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second, and
|
|
// giteaExclusions for the third.
|
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
|
// Core Rule Set keeps for the rules that set it up, which
|
|
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, 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. sww compares the request's anomaly
|
|
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
|
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
|
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 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 exclusions file comes before the rules, where the Core Rule Set puts
|
|
# its own file of exclusions that run before the rules, so that they run
|
|
# before the rules of phase 1 too.
|
|
%s
|
|
|
|
# The eighth change comes before the rules for the same reason.
|
|
%s
|
|
|
|
# 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. 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"
|
|
|
|
# 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, for Java starting a process, or for a Java class name. 944130
|
|
# refused every page opened from the page of java.lang.String.md. The
|
|
# cookies are left out in Inspect.
|
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
|
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
|
|
|
# The seventh: a container image push that reuses a layer names the image
|
|
# it takes the layer from in the query parameter from, which is not
|
|
# checked for command names. 932260 refused the push of a layer from
|
|
# owner/python3-app.
|
|
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
|
|
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
|
|
`
|
|
|
|
// 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
|
|
// text/json, and any application or text type ending in +xml or +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 the limit cuts
|
|
# before the colon of a part's header line, or between the carriage return
|
|
# and the line feed that end a part's header line or the empty line after
|
|
# its headers, adds 5 too, since Coraza takes the line the limit cuts for a
|
|
# malformed header. Coraza parses any form data body.
|
|
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'"
|
|
`
|
|
|
|
// giteaExclusions are the eighth change: the fields of gitea's own
|
|
// requests that skip the rules that refused them there, as Coraza
|
|
// directives.
|
|
//
|
|
//go:embed gitea.conf
|
|
var giteaExclusions string
|
|
|
|
// 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 SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4.
|
|
ParanoiaLevel int
|
|
// DisabledRules are the ids of the rules switched off
|
|
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
|
|
DisabledRules []int
|
|
// BodyLimit is the most of a request body the Core Rule Set reads
|
|
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
|
|
BodyLimit int64
|
|
// ExclusionsFile is the path of a file of exclusions loaded before the
|
|
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
|
// there is none.
|
|
ExclusionsFile string
|
|
}
|
|
|
|
// The ids of sww's own rules, which an exclusions file may not
|
|
// name.
|
|
const (
|
|
firstOwnRuleID = 900000
|
|
lastOwnRuleID = 900999
|
|
)
|
|
|
|
// numberOrRange matches a number, or a range of numbers such as
|
|
// 900000-900999. Coraza reads a number with a + before it too, so the
|
|
// end of a range may have one.
|
|
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
|
|
|
|
// errSwwRuleID is the error of an exclusions file that names an
|
|
// id of sww's own rules.
|
|
var errSwwRuleID = errors.New("an id of sww's own rules")
|
|
|
|
// errChangesNoRule is the error of an exclusions file that holds a
|
|
// directive that removes or changes rules as it loads, such as
|
|
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
|
|
// the directive finds none of them.
|
|
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
|
|
"switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " +
|
|
"with a ctl exclusion")
|
|
|
|
// directivesIn returns the directives in text as Coraza reads them: it
|
|
// skips blank lines and comment lines, and joins a line ending in \ to
|
|
// the next, without the spaces around either.
|
|
func directivesIn(text string) []string {
|
|
var directives []string
|
|
|
|
joined := ""
|
|
|
|
for line := range strings.Lines(text) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
if start, found := strings.CutSuffix(line, `\`); found {
|
|
joined += start
|
|
|
|
continue
|
|
}
|
|
|
|
directives = append(directives, joined+line)
|
|
joined = ""
|
|
}
|
|
|
|
return directives
|
|
}
|
|
|
|
// checkExclusions returns an error if exclusions, the text of an
|
|
// exclusions file, names an id of sww's own rules, alone or in a
|
|
// range, or holds a directive whose name starts with SecRuleRemove or
|
|
// SecRuleUpdate.
|
|
func checkExclusions(exclusions string) error {
|
|
for _, directive := range directivesIn(exclusions) {
|
|
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
|
|
first, _ := strconv.Atoi(match[1])
|
|
|
|
last := first
|
|
if match[2] != "" {
|
|
last, _ = strconv.Atoi(match[2])
|
|
}
|
|
|
|
if first <= lastOwnRuleID && last >= firstOwnRuleID {
|
|
return fmt.Errorf("the exclusions file names %s: %w", match[0],
|
|
errSwwRuleID)
|
|
}
|
|
}
|
|
|
|
// Coraza takes the directive's name up to the first space, without
|
|
// regard to case.
|
|
name, _, _ := strings.Cut(directive, " ")
|
|
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
|
|
strings.HasPrefix(lower, "secruleupdate") {
|
|
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
|
|
errChangesNoRule)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
|
// for concurrent use.
|
|
type CoreRuleSet struct {
|
|
waf coraza.WAF
|
|
bodyLimit int64
|
|
}
|
|
|
|
// New returns the Core Rule Set with the eight changes, at params'
|
|
// paranoia level, without the rules it switches off, reading request
|
|
// bodies up to params' limit, and with params' exclusions file loaded
|
|
// before the Core Rule Set's rules. An exclusions file is an error if it
|
|
// cannot be read or does not parse, or if checkExclusions refuses it.
|
|
func New(params Params) (*CoreRuleSet, error) {
|
|
body := ""
|
|
if params.BodyLimit > 0 {
|
|
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
|
}
|
|
|
|
exclusions := ""
|
|
|
|
if params.ExclusionsFile != "" {
|
|
file, err := os.ReadFile(params.ExclusionsFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the exclusions file: %w", err)
|
|
}
|
|
|
|
exclusions = string(file)
|
|
|
|
err = checkExclusions(exclusions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
|
giteaExclusions)
|
|
|
|
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, bodyLimit: params.BodyLimit}, 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, 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, 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()
|
|
// Closing would remove the files Coraza wrote, and it writes none.
|
|
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()
|
|
|
|
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
|
|
|
|
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)}, read, err
|
|
}
|
|
|
|
// readBody reads body, the body of the request in tx, which has run on
|
|
// the request's headers, while SWW_WAF_CORE_RULE_SET_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
|
|
// 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, ";")
|
|
}
|