Compare commits

..
Author SHA1 Message Date
sneak eb5f4016aa rproxy: leave no visitor without an answer as the response time runs out (closes #295)
check / check (push) Successful in 3m55s
A visitor could get a closed connection and no answer when
SWRP_MAX_RESPONSE_TIME_MS ran out about when the answer started: the
write deadline passed before the first byte was written.

- An answer rproxy gives itself has at least 10 seconds to be sent.
- An app's answer that arrives once the time has run out is answered
  504.
- Otherwise the app's status and headers are sent at once, before the
  body is read, with at least 10 seconds. The exact limit applies to
  the rest.

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

Model: opus-5-5
2026-11-10 17:43:10 +01:00
clawbot 3268ec95c1 Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Successful in 4m37s
A password manager's random password set off 35 Core Rule Set rules,
about 1 in 40 refused. The password fields the app exclusions name now
skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki
wpPassword, lgpassword and password, Mastodon's sign-in, sign-up,
password change, move and delete passwords, and Nextcloud's sign-in
password. The tests draw 2,000 random passwords from a fixed seed, plus
one rare password for each rule the 2,000 do not set off. Those that set
off a rule in a field no exclusion names are sent to every password
field. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password now pass;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.

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

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

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

Model: opus-5-5
2026-11-10 15:26:11 +01:00
19 changed files with 1123 additions and 269 deletions

No files matched your search

+24 -2
View File
@@ -12,7 +12,7 @@ RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
# Test phase, same shape and for the same reason. The go directive in
# go.mod is a minimum, so this Go may be newer than the linter's. The
@@ -20,7 +20,7 @@ RUN golangci-lint run --config .golangci.yml ./...
# needs the C compiler it carries.
#
# golang 1.27.1-trixie, 2026-09-19
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test-setup
WORKDIR /src
@@ -35,14 +35,36 @@ COPY . .
RUN go mod tidy -diff || \
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
# The two passes below are separate stages so that BuildKit runs them at
# the same time; they share no compiled code, because -race changes every
# package's build.
#
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
# after this step, and writing it into the image takes seconds. The tests
# are built with the no_fs_access tag, as the binary is in the build stage.
FROM test-setup AS test-race
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
# Second pass: the recorded requests, too slow for make test under -race.
FROM test-setup AS test-recorded
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -tags no_fs_access,recorded -timeout 90s -cover -run '^TestOrdinary' ./internal/coreruleset/... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -tags no_fs_access,recorded -timeout 90s -run '^TestOrdinary' -v ./internal/coreruleset/...; exit 1; }
# The test phase that script/test and the build stage name. Nothing is
# wanted from the two passes; the copies are what make BuildKit run them.
# It holds nothing else, so script/test's image takes no time to write.
FROM scratch AS test
COPY --from=test-race /src/go.sum /src/go.sum
COPY --from=test-recorded /src/go.sum /src/go.sum
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
# writes pass the test phase's check. Nothing else depends on it, so only
# script/tidy, which names the stage after it, builds it.
+6 -1
View File
@@ -602,6 +602,10 @@ 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, on WordPress, Drupal, MediaWiki and Nextcloud;
- signing in, signing up, changing the password, and moving or deleting the
account, on Mastodon.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
@@ -695,7 +699,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`.
+77 -2
View File
@@ -919,7 +919,20 @@ 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.
- `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 +1000,18 @@ 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.
- `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 +1091,20 @@ 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:
- `wpPassword` on MediaWiki's pages;
- `lgpassword` and `password` 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.
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 +1231,27 @@ 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]`;
- `password` on `/api/v1/accounts`, in form data or JSON.
They skip each rule that random passwords set off:
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
`' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password reach Mastodon;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1324,6 +1384,19 @@ 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.
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;
@@ -2172,7 +2245,9 @@ 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.
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
View File
@@ -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.
+281 -196
View File
@@ -1,8 +1,10 @@
package coreruleset_test
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"net/http/httptest"
"net/netip"
@@ -573,38 +575,6 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
}
}
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
// An SQL injection, a script injection and a shell command after a ";", as
// query values. Every app's tests check that its fields still refuse the
// first two.
@@ -624,134 +594,296 @@ const passwordField = "password="
//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 the field name. Coraza checks each value of a field
// on its own, and keeps every value of a JSON key sent more than once.
func passwordBody(t *testing.T, r request, name string, passwords ...string) string {
t.Helper()
if r.headers[0] != jsonBody {
return formBody(name, passwords...)
}
values := make([]string, len(passwords))
for i, password := range passwords {
pair, err := json.Marshal(map[string]string{name: password})
if err != nil {
t.Fatal(err)
}
values[i] = strings.Trim(string(pair), "{}")
}
return "{" + strings.Join(values, ",") + "}"
}
// formBody returns a form data body that sends each of values in the field
// name.
func formBody(name string, values ...string) string {
fields := make([]string, len(values))
for i, value := range values {
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
}
return strings.Join(fields, "&")
}
// setsOffARule reports whether body, form data sent to / as a browser
// sends it, sets off a rule in crs.
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
"http://git.example/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
"Gecko/20100101 Firefox/131.0")
req.Header.Set("Accept", "text/html")
// The body is read from memory, which cannot fail.
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
return len(result.RuleIDs) > 0
}
// findMisfiringPasswords returns those of randomPasswords that set off a
// rule in the form data field password on /, which no exclusion names, while
// bodies are read. It runs once, for every test that asks. Each body sends
// ten passwords, and a body that sets off a rule is sent again one password
// at a time.
//
//nolint:gochecknoglobals // the tests share it
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})()
if err != nil {
return nil, err
}
passwords := randomPasswords()
misfires := make([]bool, len(passwords))
var inspecting sync.WaitGroup
for worker := range workers {
inspecting.Go(func() {
for first := worker * perBody; first < len(passwords); first += workers * perBody {
last := min(first+perBody, len(passwords))
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
continue
}
for i := first; i < last; i++ {
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
}
}
})
}
inspecting.Wait()
var misfiring []string
for i, password := range passwords {
if misfires[i] {
misfiring = append(misfiring, password)
}
}
return misfiring, nil
})
// misfiringPasswords returns those of randomPasswords that set off a rule in
// a field no exclusion names. The rules that misfire on a password read its
// value, not the name of its field. So the other random passwords set off no
// rule in any field.
func misfiringPasswords(t *testing.T) []string {
t.Helper()
misfiring, err := findMisfiringPasswords()
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
for _, rare := range rarePasswords {
if !slices.Contains(misfiring, rare) {
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
}
}
return misfiring
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Only the passwords misfiringPasswords returns set off
// a rule in some field, so only they are sent, all in one body. If it is
// refused, each is sent again on its own, to name those refused.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
crs := readingBodies(t)
misfiring := misfiringPasswords(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}
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
name, _, _ := strings.Cut(header, ":")
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)
}
})
body := passwordBody(t, r, name, misfiring...)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
}
// TestReadingBodiesOnlyAddsMatches checks that every rule a recorded
// request with a body matches at the defaults also matches while bodies
// are read, with a score no lower. It checks every 40th such request of
// each app, as recorded and with an SQL injection added to its query, which
// the defaults must match.
func TestReadingBodiesOnlyAddsMatches(t *testing.T) {
t.Parallel()
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
return
}
defaults, bodies := atDefaults(t), readingBodies(t)
for _, name := range []string{
"drupal-requests.txt",
"mastodon-requests.txt",
"mediawiki-requests.txt",
"nextcloud-requests.txt",
"vaultwarden-requests.txt",
"wordpress-requests.txt",
} {
withBody := 0
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)
}
for _, password := range misfiring {
body := passwordBody(t, r, name, password)
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
// 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 {
// 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 the field name of r's body, pass while bodies are read.
func wantPasswordAttacksPass(t *testing.T, r request, name string) {
t.Helper()
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)
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
"<img src=x onerror=x()>",
} {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
attack, 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) {
// 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, 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)
}
}
}
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="},
} {
r := request{
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
[]string{formData},
}
got, _ := inspectBody(t, crs, r, sent.field+"example")
if got.Score < threshold {
t.Errorf("POST %s let through: %+v", r.target, got)
}
}
}
func TestGiteaAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{
@@ -804,46 +936,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 +1001,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 +1493,16 @@ 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 TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
@@ -1478,6 +1560,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 {
+40
View File
@@ -184,6 +184,46 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
# The password, pass, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
"id:900819,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
# A person's name and email address, when signing up, when editing an
# account and when commenting without an account. A name of python3 refused
# signing up, and python3@example.com refused saving an account. An address
+17 -7
View File
@@ -31,12 +31,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,7 +103,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{reply, drupalName + attack},
{drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack},
@@ -172,6 +165,23 @@ 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 TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel()
+79
View File
@@ -176,6 +176,85 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
# The passwords named above skip each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \
"id:900965,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/"
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
"id:900966,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
# The web app's reports have the JSON keys forward and forward_to_domains. A
# report forwarded to another server lists that server in forward_to_domains.
# Their names contain .forward, a file in a home folder, so 930120 refused every
+59 -14
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -54,12 +55,6 @@ const (
mastodonStatus = "status="
)
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt")
}
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
// and 932260 in the query, on a path where it skips them, ending in "=".
//
@@ -129,10 +124,6 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
{http.MethodPost, "/admin/report_notes", "report_note[content]="},
@@ -145,6 +136,19 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, mastodonRoles, "user_role[name]="},
{http.MethodPost, mastodonTerms, "terms_of_service[text]="},
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
{http.MethodPost, mastodonSignUp, "reason="},
}
// mastodonPasswordForms sends each password field in a form data body, on
// a path where it skips the four rules and the rules that random passwords
// set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="},
{
http.MethodPost, "/settings/migration/redirect",
@@ -152,7 +156,14 @@ var mastodonFormFields = []mastodonBodyField{
},
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, passwordField},
{http.MethodPost, mastodonSignUp, "reason="},
}
// mastodonPasswordJSON sends the password of signing up through the API in
// a JSON body, with %s for its value.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var mastodonPasswordJSON = mastodonBodyField{
http.MethodPost, mastodonSignUp, `{"password":%s}`,
}
// mastodonJSONFields sends each field that skips the four rules in a JSON
@@ -178,7 +189,6 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
{http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`},
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
@@ -287,6 +297,34 @@ 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 TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel()
@@ -299,7 +337,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 +348,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 +369,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)
+80
View File
@@ -108,6 +108,86 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
# wpPassword, lgpassword and password skip each rule random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
"id:900917,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
SecRule &ARGS_GET:wpPassword "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
"id:900918,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/"
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
# or prependtext, and the REST API in the JSON field source. Each of these
# rules refused saving or previewing a page whose wikitext held a template, a
+39 -18
View File
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"net/http"
"slices"
"strings"
"testing"
)
@@ -42,16 +43,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 +131,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 +141,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 +158,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 +236,23 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
func TestMediaWikiPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mediawikiPasswordFields {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
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 +273,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 +332,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}}
+40
View File
@@ -61,6 +61,46 @@ 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 folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\
+33 -8
View File
@@ -16,6 +16,9 @@ import (
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
const nextcloudSignIn = "/login"
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
@@ -48,12 +51,6 @@ const (
// nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}`
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
// and 932260, each on a path where it skips them, ending in "=".
//
@@ -83,8 +80,7 @@ var nextcloudQueryFields = []string{
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="},
{"/login", passwordField},
{nextcloudSignIn, "user="},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
@@ -301,6 +297,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
return scores
}
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
// bodies, gives signing in with value, a query value, as the password.
func nextcloudPasswordScore(t *testing.T, value string) int {
t.Helper()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
return got.Score
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
@@ -310,9 +317,23 @@ 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 TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel()
@@ -361,6 +382,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Errorf("%s let through: %d", request, score)
}
}
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
t.Errorf("password %s let through: %d", commandInjection, score)
}
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
+180
View File
@@ -0,0 +1,180 @@
//go:build recorded
// The tests that check the apps' recorded requests in testdata. The recorded
// tag keeps them out of the test phase's pass with the race detector, and
// its second pass, without the race detector, runs them.
package coreruleset_test
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
}
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// recordedRequests returns the recorded ordinary requests in the file name
// in testdata, each with its body.
func recordedRequests(t *testing.T, name string) []recordedRequest {
t.Helper()
path := filepath.Join("testdata", name)
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and
// while bodies are read. A request with neither a body nor a Content-Type
// header is checked only at the defaults: with neither, the set that reads
// bodies has nothing more to check. Each request runs as its own parallel
// subtest.
func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
got, _ := inspectBody(t, defaults, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
name, _, _ := strings.Cut(header, ":")
return strings.EqualFold(name, "Content-Type")
})
if recorded.Body == "" && !typed {
return
}
got, _ = inspectBody(t, bodies, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
r.method, r.target, recorded.Body, got)
}
})
}
}
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt")
}
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt")
}
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
}
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
}
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "wordpress-requests.txt")
}
-6
View File
@@ -27,12 +27,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.
+37 -3
View File
@@ -96,11 +96,45 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
# Signing in, in pwd, where a password holds any characters:
# 930120: a password ending in /etc/hosts
# Signing in, in pwd, which skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
SecRule &ARGS_GET:pwd "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
# Saving through the REST API, the JSON fields title, excerpt and name skip these
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
+6
View File
@@ -163,6 +163,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 +175,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.
+86
View File
@@ -481,6 +481,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 +625,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()
+36 -10
View File
@@ -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 {
@@ -237,6 +248,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)
@@ -252,7 +274,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.setResponseDeadline()
rq.out.afterStatus = rq.setResponseDeadline
return nil
}
@@ -326,14 +348,14 @@ func (rq *request) send(status int, body []byte) {
// 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)))
}
}
@@ -549,16 +571,20 @@ func (rq *request) responseReceived() {
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