// 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, ";") }