SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 2m47s
check / check (push) Failing after 2m47s
Each app's fixes are one .conf file, whose first rule skips the rest unless Inspect sets tx.app_<name>, and one entry in apps. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
This commit is contained in:
1 parent
c1bb813a5e
commit
2d6a91c7b1
15 files changed
+892
-358
No files matched your search
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
@@ -235,6 +236,11 @@ type Config struct {
|
||||
WAFExemptPaths []string
|
||||
WAFBodyLimit int64
|
||||
CoreRuleSetExclusionsFile string
|
||||
// Apps are the names of the apps each hostname serves, by hostname in
|
||||
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
||||
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
||||
// request gets every app's fixes.
|
||||
Apps map[string][]string
|
||||
// TrapPaths are the paths a request for which is a clear sign of
|
||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
@@ -404,6 +410,11 @@ var (
|
||||
errNotTrapPath = errors.New(
|
||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||
)
|
||||
errNotAppsEntry = errors.New(
|
||||
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
|
||||
)
|
||||
errNotHostname = errors.New("is not a DNS name such as git.example.com")
|
||||
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
|
||||
errNotWAFMode = errors.New("is not off, detect or block")
|
||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||
errNotRuleID = errors.New(
|
||||
@@ -577,6 +588,7 @@ func FromEnvironment(
|
||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||
CoreRuleSetExclusionsFile: env.filePath(
|
||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||
Apps: env.apps("SWW_APPS"),
|
||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||
@@ -832,6 +844,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// apps reads the setting that is the apps each hostname serves. It is nil
|
||||
// while the setting is unset, and logged as null, unlike a setting set
|
||||
// empty.
|
||||
func (e *environment) apps(name string) map[string][]string {
|
||||
value, set := e.lookup(name)
|
||||
if !set {
|
||||
e.settings = append(e.settings, slog.Any(name, nil))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
e.settings = append(e.settings, slog.String(name, value))
|
||||
|
||||
apps, err := parseApps(value)
|
||||
e.check(name, err)
|
||||
|
||||
return apps
|
||||
}
|
||||
|
||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||
// by default.
|
||||
func (e *environment) trapPaths(name string) []string {
|
||||
@@ -1849,6 +1880,65 @@ func parseTrapPaths(value string) ([]string, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// parseApps reads the apps each hostname serves: entries separated by
|
||||
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
||||
// names coreruleset.AppNames returns, such as
|
||||
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
||||
// hostname is a DNS name, without a wildcard. It returns them by hostname
|
||||
// in lowercase, since a hostname is matched without regard to case. An
|
||||
// empty value lists no hostname.
|
||||
func parseApps(value string) (map[string][]string, error) {
|
||||
apps := map[string][]string{}
|
||||
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
for entry := range strings.SplitSeq(value, ";") {
|
||||
hostname, list, found := strings.Cut(entry, ":")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
if hostname == "" {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
if !isZone(hostname) {
|
||||
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
||||
}
|
||||
|
||||
if _, listed := apps[hostname]; listed {
|
||||
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
||||
}
|
||||
|
||||
names, err := parseList(list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
if !slices.Contains(coreruleset.AppNames(), name) {
|
||||
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
||||
strings.Join(coreruleset.AppNames(), ", "))
|
||||
}
|
||||
|
||||
if slices.Contains(names[:i], name) {
|
||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||
}
|
||||
}
|
||||
|
||||
apps[hostname] = names
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||
// lastSetupRuleID.
|
||||
@@ -2455,7 +2545,8 @@ func MaskZoneKey(zone string) string {
|
||||
return masked + "." + rest
|
||||
}
|
||||
|
||||
// isZone reports whether each label of zone is as parseZones takes it.
|
||||
// isZone reports whether each label of zone is as parseZones takes it. It
|
||||
// also checks the hostnames of SWW_APPS, which are DNS names too.
|
||||
func isZone(zone string) bool {
|
||||
for label := range strings.SplitSeq(zone, ".") {
|
||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||
|
||||
@@ -89,6 +89,7 @@ const (
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||
apps = "SWW_APPS"
|
||||
trapPaths = "SWW_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||
@@ -582,6 +583,93 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
want map[string][]string
|
||||
}{
|
||||
{environment{}, nil},
|
||||
{environment{apps: ""}, map[string][]string{}},
|
||||
{
|
||||
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||
map[string][]string{
|
||||
"git.example.com": {"gitea"}, "cloud.example.com": {"gitea"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
||||
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
want string
|
||||
}{
|
||||
{environment{}, `"` + apps + `":null`},
|
||||
{environment{apps: ""}, `"` + apps + `":""`},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||
|
||||
if !strings.Contains(out.String(), tc.want) {
|
||||
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
notEntry = " is not a hostname, : and a list of apps, " +
|
||||
"such as git.example.com:gitea"
|
||||
notHostname = " is not a DNS name such as git.example.com"
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ value, want string }{
|
||||
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||
{"git.example.com:gitea;", `""` + notEntry},
|
||||
{":gitea", `":gitea"` + notEntry},
|
||||
{"git.example.com:", `"git.example.com:"` + notEntry},
|
||||
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
||||
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
||||
{"git example.com:gitea", `"git example.com"` + notHostname},
|
||||
{
|
||||
"git.example.com:gitea,nextcloud",
|
||||
`"nextcloud" is not an app sww has fixes for; ` +
|
||||
`the apps are drupal, gitea, mediawiki, wordpress`,
|
||||
},
|
||||
{
|
||||
"git.example.com:gitea;GIT.example.com:gitea",
|
||||
`"git.example.com" is listed twice`,
|
||||
},
|
||||
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||
} {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
||||
|
||||
want := apps + ": " + tc.want
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2589,6 +2677,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
wafExemptPaths: "",
|
||||
wafBodyLimit: off,
|
||||
exclusionsFile: "",
|
||||
apps: "", // logged as null, which decodes as ""
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Package coreruleset 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_BODY_LIMIT is set, with the eleven changes sww
|
||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||
// response.
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
||||
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||
// SWW_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
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -32,9 +34,8 @@ import (
|
||||
|
||||
// directives are the Core Rule Set as sww runs it, with the
|
||||
// paranoia level for %d, bodyDirectives for the first %s while
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||
// drupalExclusions for the fifth, and mediawikiExclusions for the sixth.
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||
// directives of every app in apps 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_DISABLED_RULES and the exclusions file refuse, so that no
|
||||
@@ -82,53 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
||||
# before the rules of phase 1 too.
|
||||
%s
|
||||
|
||||
# The eighth change comes before the rules for the same reason.
|
||||
# The apps' fixes come before the rules for the same reason.
|
||||
%s
|
||||
|
||||
# The ninth change comes before the rules for the same reason.
|
||||
%s
|
||||
|
||||
# So does the tenth.
|
||||
%s
|
||||
|
||||
# And the eleventh.
|
||||
%s
|
||||
|
||||
# The sixth: only the rules for requests are loaded, and no response is
|
||||
# The fifth: 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.
|
||||
# localhost. Coraza matches a parameter name here 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 this name 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.
|
||||
# The fourth: Referer 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.
|
||||
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
|
||||
# The sixth: 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.
|
||||
@@ -178,39 +154,49 @@ 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.
|
||||
//
|
||||
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||
// with the fixes that let them pass.
|
||||
type app struct {
|
||||
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||
// rules. Their first rule skips to the marker they end with, unless
|
||||
// Inspect sets the transaction variable named for the app, such as
|
||||
// app_gitea. The skip reaches only phase 1, so every rule in them is
|
||||
// phase 1.
|
||||
directives string
|
||||
// cookies are the cookies the Core Rule Set reads a request without.
|
||||
cookies []string
|
||||
}
|
||||
|
||||
//go:embed gitea.conf
|
||||
var giteaExclusions string
|
||||
var giteaDirectives string
|
||||
|
||||
// wordpressExclusions are the ninth change: the fields of WordPress's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed wordpress.conf
|
||||
var wordpressExclusions string
|
||||
var wordpressDirectives string
|
||||
|
||||
// drupalExclusions are the tenth change: the fields of Drupal's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed drupal.conf
|
||||
var drupalExclusions string
|
||||
var drupalDirectives string
|
||||
|
||||
// mediawikiExclusions are the eleventh change: the fields of MediaWiki's
|
||||
// own requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed mediawiki.conf
|
||||
var mediawikiExclusions string
|
||||
var mediawikiDirectives string
|
||||
|
||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||
// without, the rest of the fifth change.
|
||||
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
||||
//nolint:gochecknoglobals // a constant cannot be a map
|
||||
var apps = map[string]app{
|
||||
"drupal": {directives: drupalDirectives},
|
||||
"gitea": {
|
||||
directives: giteaDirectives,
|
||||
cookies: []string{"gitea_flash", "redirect_to"},
|
||||
},
|
||||
"mediawiki": {directives: mediawikiDirectives},
|
||||
"wordpress": {directives: wordpressDirectives},
|
||||
}
|
||||
|
||||
// AppNames returns the names of the apps whose fixes sww has, in
|
||||
// alphabetical order.
|
||||
func AppNames() []string {
|
||||
return slices.Sorted(maps.Keys(apps))
|
||||
}
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
@@ -226,6 +212,10 @@ type Params struct {
|
||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||
// there is none.
|
||||
ExclusionsFile string
|
||||
// Apps are the names of the apps each hostname serves, by hostname in
|
||||
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
||||
// apps only, or of every app while Apps is nil.
|
||||
Apps map[string][]string
|
||||
}
|
||||
|
||||
// The ids of sww's own rules, which an exclusions file may not
|
||||
@@ -317,13 +307,15 @@ func checkExclusions(exclusions string) error {
|
||||
type CoreRuleSet struct {
|
||||
engine coraza.WAF
|
||||
bodyLimit int64
|
||||
apps map[string][]string
|
||||
}
|
||||
|
||||
// New returns the Core Rule Set with the eleven 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.
|
||||
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
||||
// 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 {
|
||||
@@ -346,9 +338,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var fixes strings.Builder
|
||||
for _, name := range AppNames() {
|
||||
fixes.WriteString(apps[name].directives + "\n")
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||
mediawikiExclusions)
|
||||
fixes.String())
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
@@ -366,7 +362,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||
}
|
||||
|
||||
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
||||
return &CoreRuleSet{
|
||||
engine: engine, bodyLimit: params.BodyLimit, apps: params.Apps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Result is what the Core Rule Set found in a request.
|
||||
@@ -378,11 +376,12 @@ type Result struct {
|
||||
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.
|
||||
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||
// with the query, and its headers, the Cookie header without those apps'
|
||||
// cookies, 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) {
|
||||
@@ -390,13 +389,24 @@ func (c *CoreRuleSet) Inspect(
|
||||
// Closing would remove the files Coraza wrote, and it writes none.
|
||||
defer func() { _ = tx.Close() }()
|
||||
|
||||
// The transaction's variables are set only through Coraza's interface
|
||||
// for plugins.
|
||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||
|
||||
var cookiesNotRead []string
|
||||
|
||||
for _, name := range c.appsFor(r.Host) {
|
||||
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||
}
|
||||
|
||||
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)
|
||||
value = withoutCookies(value, cookiesNotRead)
|
||||
if value == "" {
|
||||
continue // it held those cookies alone
|
||||
}
|
||||
@@ -489,9 +499,26 @@ func score(tx types.Transaction) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
||||
// cookies in cookiesNotRead.
|
||||
func withoutCookiesNotRead(value string) string {
|
||||
// appsFor returns the names of the apps whose fixes a request for host,
|
||||
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||
// those SWW_APPS lists for host's name, without the port and in
|
||||
// lowercase.
|
||||
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||
if c.apps == nil {
|
||||
return AppNames()
|
||||
}
|
||||
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostname = host // it has no port
|
||||
}
|
||||
|
||||
return c.apps[strings.ToLower(hostname)]
|
||||
}
|
||||
|
||||
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||
// named in cookiesNotRead.
|
||||
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||
var kept []string
|
||||
|
||||
for cookie := range strings.SplitSeq(value, ";") {
|
||||
|
||||
@@ -36,12 +36,14 @@ var loaded = struct {
|
||||
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
||||
|
||||
// loaderFor returns the function that loads the Core Rule Set with params
|
||||
// once, making it the first time params are asked for.
|
||||
// once, making it the first time params are asked for. %#v writes a nil
|
||||
// Apps, which gives every app's fixes, apart from an empty one, which
|
||||
// gives none.
|
||||
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
|
||||
loaded.Lock()
|
||||
defer loaded.Unlock()
|
||||
|
||||
settings := fmt.Sprintf("%+v", params)
|
||||
settings := fmt.Sprintf("%#v", params)
|
||||
|
||||
load, found := loaded.loaders[settings]
|
||||
if !found {
|
||||
@@ -202,8 +204,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a request one of the
|
||||
// eleven changes is for, and still finds result in refused, a request like
|
||||
// it that the change is not for.
|
||||
// six changes or an app's fixes are for, and still finds result in
|
||||
// refused, a request like it that they are not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||
result coreruleset.Result,
|
||||
@@ -464,6 +466,108 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
||||
matched(930120, 932160))
|
||||
}
|
||||
|
||||
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
||||
// for each kind of fix, each with what the Core Rule Set finds in it
|
||||
// without them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaFixed = []struct {
|
||||
request request
|
||||
result coreruleset.Result
|
||||
}{
|
||||
// A search of code, on the path of gitea's search.
|
||||
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
||||
// A file name, in a parameter gitea sends names in, on any path.
|
||||
{get("/?path=.gitignore"), matched(930120)},
|
||||
// A file name, in a cookie of gitea's.
|
||||
{
|
||||
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
||||
matched(930120),
|
||||
},
|
||||
}
|
||||
|
||||
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
||||
// one that serves another app.
|
||||
const (
|
||||
giteaHost = "git.example"
|
||||
otherHost = "blog.example"
|
||||
)
|
||||
|
||||
// gitea is gitea's name in SWW_APPS.
|
||||
const gitea = "gitea"
|
||||
|
||||
// withHost returns r sent to host.
|
||||
func withHost(r request, host string) request {
|
||||
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
||||
}
|
||||
|
||||
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
||||
// giving apps.
|
||||
func servingApps(t *testing.T, apps map[string][]string) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, nil)
|
||||
|
||||
for _, fixed := range giteaFixed {
|
||||
for _, host := range []string{giteaHost, otherHost} {
|
||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
||||
|
||||
// The hostname is matched without the port and without regard to case.
|
||||
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
||||
for _, fixed := range giteaFixed {
|
||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, apps := range []map[string][]string{
|
||||
{giteaHost: {gitea}},
|
||||
// SWW_APPS set empty lists no hostname.
|
||||
{},
|
||||
} {
|
||||
crs := servingApps(t, apps)
|
||||
|
||||
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
||||
for _, fixed := range giteaFixed {
|
||||
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"wordpress"},
|
||||
})
|
||||
|
||||
// A search, which only WordPress's fixes let through.
|
||||
search := get("/?s=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||
// exclusions file at path.
|
||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||
@@ -619,15 +723,16 @@ const (
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused, at the defaults and
|
||||
// while bodies are read. Each request runs as its own parallel subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
// requests in the file name in testdata is refused by defaults, which
|
||||
// reads no body, or by bodies, which reads it. Each request runs as its
|
||||
// own parallel subtest.
|
||||
func wantRecordedRequestsPass(
|
||||
t *testing.T, name string, defaults, bodies *coreruleset.CoreRuleSet,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -791,13 +896,16 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
|
||||
// requests pass defaults, which reads no body, and that those with a body
|
||||
// pass bodies, which reads it.
|
||||
func wantWordPressRequestsPass(
|
||||
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -817,6 +925,16 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
wantWordPressRequestsPass(t, defaults, bodies)
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
@@ -893,6 +1011,20 @@ func wordpressWords() []struct {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostnameListingWordPressLetsItsRecordedRequestsThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The requests were recorded on otherHost.
|
||||
apps := map[string][]string{otherHost: {"wordpress"}}
|
||||
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
wantWordPressRequestsPass(t, servingApps(t, apps), bodies)
|
||||
}
|
||||
|
||||
// wordpressBlock is the comment that starts every block of WordPress's
|
||||
// HTML, which rule 941180 matches.
|
||||
const wordpressBlock = "<!-- wp:paragraph -->"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The tenth change: on Drupal's paths, some fields skip the rules that refused
|
||||
# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
||||
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
||||
# search, a name, a file name or an address. Each exclusion runs with the
|
||||
# request, before the rules it switches off, so this file is loaded before the
|
||||
@@ -25,6 +25,12 @@
|
||||
# 932230: ls -la
|
||||
# 932235: python3
|
||||
|
||||
# Unless SWW_APPS lists drupal for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_drupal when it does.
|
||||
# The skip reaches only phase 1, so every rule in this file must be phase 1.
|
||||
SecRule &TX:app_drupal "@eq 0" \
|
||||
"id:900790,phase:1,pass,nolog,skipAfter:END_DRUPAL"
|
||||
|
||||
# Every admin page's filters and autocompletes, and the page to return to:
|
||||
# title: /admin/content?title=python3
|
||||
# name: /admin/content/media?name=python3
|
||||
@@ -228,3 +234,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
|
||||
|
||||
SecMarker END_DRUPAL
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
@@ -34,7 +36,35 @@ const drupalName = "name="
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt", atDefaults(t), readingBodies(t))
|
||||
}
|
||||
|
||||
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"drupal"},
|
||||
})
|
||||
|
||||
// A search, which only Drupal's fixes let through.
|
||||
search := get("/search?keys=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestHostnameListingDrupalLetsItsRecordedRequestsThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The requests were recorded on example.com.
|
||||
apps := map[string][]string{"example.com": {"drupal"}}
|
||||
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt", servingApps(t, apps), bodies)
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||
# They are the fields where gitea sends a search, a file name or a branch name.
|
||||
# Each exclusion runs with the request, before the rules it switches off, those
|
||||
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
||||
# Each comment names a request the rule refused.
|
||||
|
||||
# Unless SWW_APPS lists gitea for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_gitea when it does.
|
||||
# The skip reaches only phase 1, so every rule in this file must be phase 1.
|
||||
SecRule &TX:app_gitea "@eq 0" \
|
||||
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||
|
||||
# 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, on any path:
|
||||
# 930120: .gitignore
|
||||
# 932160: bin/docker-entrypoint
|
||||
# 932260: docker-build
|
||||
SecAction "id:900591,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||
|
||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||
# text searched for in q, which skips these rules there:
|
||||
@@ -155,8 +199,8 @@ SecRule REQUEST_FILENAME \
|
||||
# 942140: information_schema.sql
|
||||
# 944110: java/lang/Runtime.java
|
||||
# 944130: java.lang.String.md
|
||||
# Each request skips the rules that refused it. The fourth change already has
|
||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
||||
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||
|
||||
# A file's commit graph, in file.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||
@@ -281,3 +325,5 @@ SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:900640,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
|
||||
SecMarker END_GITEA
|
||||
@@ -1,4 +1,4 @@
|
||||
# The eleventh change: on MediaWiki's paths, some fields skip the rules that
|
||||
# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that
|
||||
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
||||
# sends a search, a title, a name, a file name, an address, a summary or a
|
||||
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
||||
@@ -21,6 +21,12 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists mediawiki for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_mediawiki when it does.
|
||||
# The skip reaches only phase 1, so every rule in this file must be phase 1.
|
||||
SecRule &TX:app_mediawiki "@eq 0" \
|
||||
"id:900890,phase:1,pass,nolog,skipAfter:END_MEDIAWIKI"
|
||||
|
||||
# The pages and their titles, the searches, and the special pages' fields
|
||||
# for a page, a user, a file or an address:
|
||||
# title: /index.php?title=Python3_notes&action=history
|
||||
@@ -175,3 +181,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
SecRule &ARGS_GET:/^(?:wpreason-other|user-reason|mwprotect-reason)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/"
|
||||
|
||||
SecMarker END_MEDIAWIKI
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
@@ -45,7 +47,35 @@ const (
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", atDefaults(t), readingBodies(t))
|
||||
}
|
||||
|
||||
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"mediawiki"},
|
||||
})
|
||||
|
||||
// A search, which only MediaWiki's fixes let through.
|
||||
search := get("/index.php?title=Special:Search&search=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestHostnameListingMediaWikiLetsItsRecordedRequestsThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The requests were recorded on example.com.
|
||||
apps := map[string][]string{"example.com": {"mediawiki"}}
|
||||
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", servingApps(t, apps), bodies)
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# The ninth change: on WordPress's paths, some fields skip the rules that refused
|
||||
# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
||||
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||
# of a body applies only when the query has no parameter of that name, so it
|
||||
# matters only while SWW_WAF_BODY_LIMIT is set.
|
||||
|
||||
# Unless SWW_APPS lists wordpress for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_wordpress when it does.
|
||||
# The skip reaches only phase 1, so every rule in this file must be phase 1.
|
||||
SecRule &TX:app_wordpress "@eq 0" \
|
||||
"id:900690,phase:1,pass,nolog,skipAfter:END_WORDPRESS"
|
||||
|
||||
# WordPress takes a call to its REST API at any of four addresses:
|
||||
# /wp-json/wp/v2/posts/1
|
||||
# /index.php/wp-json/wp/v2/posts/1
|
||||
@@ -195,3 +201,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$"
|
||||
# so skips 932340 there.
|
||||
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
||||
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
||||
|
||||
SecMarker END_WORDPRESS
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
apps = "SWW_APPS"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
)
|
||||
@@ -290,6 +292,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Only gitea's fixes let a file name through in path, a parameter
|
||||
// gitea sends file names in.
|
||||
const fileName = "/?path=.gitignore"
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
wafMode: block, apps: siteA + ":gitea",
|
||||
})
|
||||
|
||||
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||
http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||
http.StatusForbidden, discriminator.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
}
|
||||
|
||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -221,7 +221,8 @@ func newReputation(
|
||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||
// or nil while SWW_WAF_MODE is off. It
|
||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||
@@ -236,6 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||
Apps: cfg.Apps,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||
|
||||
Reference in new issue
Block a user