Core Rule Set: random passwords pass on the remaining password fields (closes #288)
check / check (push) Successful in 4m23s
check / check (push) Successful in 4m23s
Each app's sign-in, sign-up, password change and reset fields that had no exclusion now skip the 35 rules random passwords set off, on that app's paths and only when the query has no field of that name. Added gitea, Vaultwarden's admin token, WordPress pass1/pass2/post_password and its REST password, Drupal's forms and JSON, MediaWiki retype and password on the pages that take them, Mastodon's challenge, and Nextcloud's confirm, reset, change, share, LDAP renewal, encryption and OCS fields. Vaultwarden's clients send a hash, which needs no exclusion. Model: opus-5-5
This commit is contained in:
1 parent
8ab4280792
commit
3e7837aa41
15 files changed
+1043
-104
No files matched your search
@@ -588,6 +588,12 @@ 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.
|
||||
//
|
||||
@@ -661,27 +667,45 @@ func randomPasswords() []string {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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()
|
||||
|
||||
if r.headers[0] != jsonBody {
|
||||
return formBody(name, passwords...)
|
||||
}
|
||||
var fields []string
|
||||
|
||||
values := make([]string, len(passwords))
|
||||
for _, name := range names {
|
||||
if r.headers[0] != jsonBody {
|
||||
fields = append(fields, formBody(name, passwords...))
|
||||
|
||||
for i, password := range passwords {
|
||||
pair, err := json.Marshal(map[string]string{name: password})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
continue
|
||||
}
|
||||
|
||||
values[i] = strings.Trim(string(pair), "{}")
|
||||
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]))
|
||||
}
|
||||
}
|
||||
|
||||
return "{" + strings.Join(values, ",") + "}"
|
||||
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
|
||||
@@ -795,8 +819,9 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
|
||||
crs := readingBodies(t)
|
||||
misfiring := misfiringPasswords(t)
|
||||
names := []string{name}
|
||||
|
||||
body := passwordBody(t, r, name, misfiring...)
|
||||
body := passwordBody(t, r, names, misfiring...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
@@ -806,7 +831,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
}
|
||||
|
||||
for _, password := range misfiring {
|
||||
body := passwordBody(t, r, name, password)
|
||||
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)
|
||||
@@ -816,8 +841,9 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
@@ -826,9 +852,9 @@ func wantPasswordAttacksPass(t *testing.T, r request, name string) {
|
||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||
"<img src=x onerror=x()>",
|
||||
} {
|
||||
body := passwordBody(t, r, name, attack)
|
||||
body := passwordBody(t, r, names, 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,
|
||||
t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names,
|
||||
attack, got)
|
||||
}
|
||||
}
|
||||
@@ -843,7 +869,7 @@ func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||
body := passwordBody(t, r, name, attack)
|
||||
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)
|
||||
@@ -851,6 +877,64 @@ func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
|
||||
wantPasswordAttacksPass(t, r, sent.names...)
|
||||
|
||||
for _, name := range sent.names {
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// gitea's password fields, each sent to one of the paths that takes it.
|
||||
fields := []passwordFields{
|
||||
{"/user/login", formData, []string{passwordKey}},
|
||||
{"/user/sign_up", formData, []string{passwordKey, retypeKey}},
|
||||
{"/user/link_account_signin", formData, []string{passwordKey}},
|
||||
{"/user/link_account_signup", formData, []string{passwordKey, retypeKey}},
|
||||
{"/user/activate", formData, []string{passwordKey}},
|
||||
{"/user/recover_account", formData, []string{passwordKey}},
|
||||
{"/user/settings/change_password", formData, []string{passwordKey, retypeKey}},
|
||||
{
|
||||
"/user/settings/account", formData,
|
||||
[]string{"old_password", passwordKey, retypeKey},
|
||||
},
|
||||
{"/user/settings/account/delete", formData, []string{passwordKey}},
|
||||
{"/-/admin/users/new", formData, []string{passwordKey}},
|
||||
{"/-/admin/users/1/edit", formData, []string{passwordKey}},
|
||||
{"/admin/users/1/edit", formData, []string{passwordKey}},
|
||||
{"/api/v1/admin/users", jsonBody, []string{passwordKey}},
|
||||
{"/api/v1/admin/users/alice", jsonBody, []string{passwordKey}},
|
||||
}
|
||||
|
||||
wantPasswordFieldsPass(t, fields)
|
||||
}
|
||||
|
||||
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -864,6 +948,24 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
{nextcloudSignIn, passwordField},
|
||||
{mediawikiAPI, passwordField},
|
||||
{mediawikiAPI, "lgpassword="},
|
||||
{mediawikiAPI, retypeKey + "="},
|
||||
{mediawikiAccountChange, passwordField},
|
||||
{mediawikiAccountChange, retypeKey + "="},
|
||||
{mediawikiSignUp, retypeKey + "="},
|
||||
{"/wiki/Special:UserLogin", retypeKey + "="},
|
||||
{"/user/sign_up", passwordField},
|
||||
{"/user/settings/account", "old_password="},
|
||||
{wpLogin, "pass1="},
|
||||
{wpLogin, "post_password="},
|
||||
{"/wp-admin/profile.php", "pass2="},
|
||||
{"/wp-json/wp/v2/users", passwordField},
|
||||
{drupalRegister, "pass%5Bpass1%5D="},
|
||||
{"/user/1/edit", "current_pass="},
|
||||
{"/auth/challenge", "form_challenge%5Bcurrent_password%5D="},
|
||||
{"/login/confirm", passwordField},
|
||||
{"/settings/personal/changepassword", "newpassword="},
|
||||
{"/apps/encryption/ajax/adminRecovery", "confirmPassword="},
|
||||
{"/admin", "token="},
|
||||
} {
|
||||
r := request{
|
||||
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
|
||||
@@ -877,6 +979,35 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1503,6 +1634,25 @@ func TestWordPressPasswordsPass(t *testing.T) {
|
||||
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/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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user