Gitea setup: Core Rule Set blocks attacks, not ordinary Gitea traffic (closes #198)
check / check (push) Successful in 3m16s

The Gitea setup runs the Core Rule Set in block mode. Rules that refused
ordinary Gitea requests are switched off only where they misfired:

- deploy/gitea/crs-exclusions.conf, loaded through the new setting
  SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE before the Core Rule Set's rules,
  covers Gitea's search, file-name and branch-name fields, Referer, file
  paths, API attachment names and Actions artifact uploads;
- smallwebwaf's defaults gain a seventh change: the registry upload
  path's from parameter skips 932260.

A file that cannot be read, does not parse or names an id from 900000
to 900999 stops servewaf at start, in every mode. internal/waf's tests
and script/gitea-setup send the recorded requests in
internal/waf/testdata/gitea-requests.txt and three attacks.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-06 20:17:07 +00:00
1 parent 21cac70b50
commit 866b948ec0
17 files changed
+1048 -242

No files matched your search

+94 -13
View File
@@ -1,8 +1,9 @@
// 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 "Settings" in SPEC.md describes
// them. It reads no response.
// body while SWWAF_WAF_BODY_LIMIT is set, with the seven changes smallwebwaf
// makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
// "Attack detection" under "Settings" 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
@@ -11,10 +12,13 @@
package waf
import (
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"os"
"regexp"
"slices"
"strconv"
"strings"
@@ -26,12 +30,13 @@ import (
)
// 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.
// paranoia level for %d, bodyDirectives for the first %s while
// SWWAF_WAF_BODY_LIMIT is set, and the exclusions file for the second.
// 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 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. smallwebwaf compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
@@ -69,6 +74,11 @@ 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 sixth: only the rules for requests are loaded, and no response is
# inspected.
Include @owasp_crs/REQUEST-*.conf
@@ -100,6 +110,13 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
# Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!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
@@ -160,6 +177,51 @@ type Params struct {
// 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
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
}
// The ids of smallwebwaf'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.
var numberOrRange = regexp.MustCompile(`(\d+)(?:-(\d+))?`)
// errSmallwebwafRuleID is the error of an exclusions file that names an
// id of smallwebwaf's own rules.
var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules")
// ownRuleNamed returns the first number or range in exclusions, the text
// of an exclusions file, that takes in an id of smallwebwaf's own rules,
// or "" if none does. Comment lines are skipped, as Coraza skips them.
func ownRuleNamed(exclusions string) string {
for line := range strings.Lines(exclusions) {
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
for _, match := range numberOrRange.FindAllStringSubmatch(line, -1) {
first, _ := strconv.Atoi(match[1])
last := first
if match[2] != "" {
last, _ = strconv.Atoi(match[2])
}
if first <= lastOwnRuleID && last >= firstOwnRuleID {
return match[0]
}
}
}
return ""
}
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
@@ -169,16 +231,35 @@ type CoreRuleSet struct {
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.
// New returns the Core Rule Set with the seven 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 that cannot be
// read, does not parse or names an id of smallwebwaf's own rules is an
// error.
func New(params Params) (*CoreRuleSet, error) {
body := ""
if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body)
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)
if named := ownRuleNamed(exclusions); named != "" {
return nil, fmt.Errorf("the exclusions file names %s: %w", named,
errSmallwebwafRuleID)
}
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions)
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))