// 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 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" "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, 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, 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 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, or for Java starting a process. The cookies are left out in # Inspect. SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" ` // 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'" ` // 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 // 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 bodyLimit int64 } // New returns the Core Rule Set with the six changes, at params' // paranoia level, without the rules it switches off, and reading request // bodies up to params' limit. func New(params Params) (*CoreRuleSet, error) { 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)) 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, 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() // 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 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 // 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, ";") }