1 Commits
Author SHA1 Message Date
clawbot 4dbd134d49 Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Successful in 6m17s
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. An SQL injection or a path traversal sent as a password now
passes; a script injection is still refused.

Model: opus-5-5
2026-11-10 09:31:51 +00:00
7 changed files with 61 additions and 100 deletions

No files matched your search

+1 -1
View File
@@ -602,7 +602,7 @@ 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 - Nextcloud: its sync client gets through. On Nextcloud's own paths, its
WebDAV methods, searches, file names, account names, comments and WebDAV methods, searches, file names, account names, comments and
documents do too. documents do too.
- A random password, as a password manager makes one, gets through these forms: - A password made of any printable characters gets through these forms:
- signing in, on WordPress, Drupal, MediaWiki and Nextcloud; - signing in, on WordPress, Drupal, MediaWiki and Nextcloud;
- signing in, signing up, changing the password, and moving or deleting the - signing in, signing up, changing the password, and moving or deleting the
account, on Mastodon. account, on Mastodon.
+34 -57
View File
@@ -920,19 +920,13 @@ The changes hold in front of every app, and no setting undoes them:
- `newcontent` of a plugin's or a theme's file saved from its editor, - `newcontent` of a plugin's or a theme's file saved from its editor,
which is PHP. which is PHP.
- `pwd`, the password when signing in, for each rule that random - `pwd`, the password when signing in, for each rule that random
passwords set off: passwords set off (920270, 920540, 921200, 930100, 930110, 930120,
932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210,
932160, 932230, 932250, 932260, 932270, 932280, 932330, 932340, 941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230,
932370, 932380, 933100, 933135, 933180, 933210, 941100, 941120, 942290, 942320, 942540, 942550, 944150). An SQL injection or a path
941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, traversal sent as the password reaches WordPress. A script injection
942540, 942550, 944150. is still refused.
`' 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 - `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 route under `/wp/v2/`, for the rules on system files and commands
(930120, 932120, 932250, 932260). (930120, 932120, 932250, 932260).
@@ -1001,17 +995,12 @@ The changes hold in front of every app, and no setting undoes them:
JSON:API, which hold HTML and any code it shows. JSON:API, which hold HTML and any code it shows.
- `name` and `pass` when signing in (930120, 932120, 932250, 932260). - `name` and `pass` when signing in (930120, 932120, 932250, 932260).
- `pass`, the password when signing in, in form data or JSON, for each rule - `pass`, the password when signing in, in form data or JSON, for each rule
that random passwords set off: that random passwords set off (920270, 920540, 921200, 930100, 930110,
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160, 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380, 941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100, 942540, 942550, 944150). An SQL injection or a path traversal sent as the
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150. password reaches Drupal. A script injection is still refused.
`' 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 - `name` when resetting a password through Drupal's JSON format on
`/user/password`, for the same four rules. `/user/password`, for the same four rules.
- `mail` in that same reset, for 932120 and 932260 only. - `mail` in that same reset, for 932120 and 932260 only.
@@ -1091,20 +1080,16 @@ The changes hold in front of every app, and no setting undoes them:
- `wpTextbox1` on MediaWiki's pages; - `wpTextbox1` on MediaWiki's pages;
- `text`, `appendtext` and `prependtext` on `/api.php`; - `text`, `appendtext` and `prependtext` on `/api.php`;
- `source` in the JSON body of `/rest.php/`. - `source` in the JSON body of `/rest.php/`.
- The passwords when signing in: - The passwords 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):
- `wpPassword` on MediaWiki's pages; - `wpPassword` on MediaWiki's pages;
- `lgpassword` and `password` on `/api.php`. - `lgpassword` and `password` on `/api.php`.
They skip each rule that random passwords set off: An SQL injection or a path traversal sent as a password reaches
MediaWiki. A script injection is still refused.
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 These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name. applies when the query has a parameter of the same name.
@@ -1231,7 +1216,11 @@ 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 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. every report the web app sent. The keys' values keep every rule.
The passwords in a body: The passwords in a body 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):
- on `/auth`, `/settings/migration` and `/settings/delete`, and the paths - on `/auth`, `/settings/migration` and `/settings/delete`, and the paths
under them: under them:
- `user[password]`, `user[password_confirmation]` and - `user[password]`, `user[password_confirmation]` and
@@ -1241,16 +1230,8 @@ The changes hold in front of every app, and no setting undoes them:
- `form_delete_confirmation[password]`; - `form_delete_confirmation[password]`;
- `password` on `/api/v1/accounts`, in form data or JSON. - `password` on `/api/v1/accounts`, in form data or JSON.
They skip each rule that random passwords set off: An SQL injection or a path traversal sent as a password reaches Mastodon. A
script injection is still refused.
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 These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name. applies when the query has a parameter of the same name.
@@ -1385,17 +1366,13 @@ The changes hold in front of every app, and no setting undoes them:
- `name` on `/remote.php/dav/systemtags/`, a new tag. - `name` on `/remote.php/dav/systemtags/`, a new tag.
`password` in the body of `/login`, the password when signing in, skips each `password` in the body of `/login`, the password when signing in, skips each
rule that random passwords set off: rule that random passwords set off (920270, 920540, 921200, 930100, 930110,
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160, 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380, 941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100, 942540, 942550, 944150). It applies only when the query has no `password`
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150. parameter. An SQL injection or a path traversal sent as the password reaches
Nextcloud. A script injection is still refused.
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: Some fields hold sentences a person writes:
- `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS - `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS
+9 -30
View File
@@ -846,35 +846,16 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
} }
} }
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a // wantPasswordAttacksRefused checks that each of attacks, sent as the whole
// shell command and a script in an event handler, each sent as the whole // password in the field name of r's body, is refused while bodies are read.
// password in the field name of r's body, pass while bodies are read. func wantPasswordAttacksRefused(
func wantPasswordAttacksPass(t *testing.T, r request, name string) { t *testing.T, r request, name string, attacks ...string,
) {
t.Helper() t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{ for _, attack := range attacks {
"' 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)
}
}
}
// 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) body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold { 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, t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
@@ -1613,8 +1594,9 @@ func TestWordPressPasswordsPass(t *testing.T) {
signIn := request{http.MethodPost, wpLogin, []string{formData}} signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd") wantRandomPasswordsPass(t, signIn, "pwd")
wantPasswordAttacksPass(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd") // No rule pwd keeps catches the SQL injection or the path traversal.
wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag)
} }
func TestWordPressExclusionsAreNarrow(t *testing.T) { func TestWordPressExclusionsAreNarrow(t *testing.T) {
@@ -1674,9 +1656,6 @@ const injection = "1' OR '1'='1"
// scriptTag is a script injection, not escaped. // scriptTag is a script injection, not escaped.
const scriptTag = "<script>alert(1)</script>" 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 // readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet { func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
+4 -2
View File
@@ -182,8 +182,10 @@ func TestDrupalPasswordPasses(t *testing.T) {
t.Parallel() t.Parallel()
wantRandomPasswordsPass(t, r, "pass") wantRandomPasswordsPass(t, r, "pass")
wantPasswordAttacksPass(t, r, "pass")
wantPasswordAttacksRefused(t, r, "pass") // No rule pass keeps catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, "pass", scriptTag)
}) })
} }
} }
+5 -5
View File
@@ -152,7 +152,6 @@ var mastodonFormFields = []mastodonBodyField{
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mastodonPasswordForms = []mastodonBodyField{ var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonSignIn, mastodonPassword}, {http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="}, {http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="}, {http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="}, {http.MethodPost, "/settings/migration", "account_migration[current_password]="},
@@ -314,8 +313,10 @@ func TestMastodonPasswordsPass(t *testing.T) {
name := strings.TrimSuffix(sent.field, "=") name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name) wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name) // No rule these fields keep catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
}) })
} }
@@ -326,8 +327,7 @@ func TestMastodonPasswordsPass(t *testing.T) {
[]string{jsonBody}} []string{jsonBody}}
wantRandomPasswordsPass(t, r, "password") wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password") wantPasswordAttacksRefused(t, r, "password", scriptTag)
wantPasswordAttacksRefused(t, r, "password")
}) })
} }
+4 -3
View File
@@ -174,7 +174,6 @@ var mediawikiBodyFields = []mediawikiBodyField{
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mediawikiPasswordFields = []mediawikiBodyField{ var mediawikiPasswordFields = []mediawikiBodyField{
{mediawikiSignIn, "wpPassword="}, {mediawikiSignIn, "wpPassword="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiAPI, "lgpassword="}, {mediawikiAPI, "lgpassword="},
{mediawikiAPI, passwordField}, {mediawikiAPI, passwordField},
} }
@@ -253,8 +252,10 @@ func TestMediaWikiPasswordsPass(t *testing.T) {
name := strings.TrimSuffix(sent.field, "=") name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name) wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name) // No rule these fields keep catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
}) })
} }
} }
+4 -2
View File
@@ -288,8 +288,10 @@ func TestNextcloudPasswordPasses(t *testing.T) {
r := request{http.MethodPost, nextcloudSignIn, []string{formData}} r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
wantRandomPasswordsPass(t, r, "password") wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password") // No rule the password keeps catches the SQL injection or the path
// traversal.
wantPasswordAttacksRefused(t, r, "password", scriptTag)
} }
func TestNextcloudSentencesPassInTheTexts(t *testing.T) { func TestNextcloudSentencesPassInTheTexts(t *testing.T) {