1 Commits
Author SHA1 Message Date
clawbot c0071a7e8c Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Failing after 7m54s
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 10:41:25 +00:00
7 changed files with 100 additions and 61 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 password made of any printable characters gets through these forms: - A random password, as a password manager makes one, 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.
+57 -34
View File
@@ -920,13 +920,19 @@ 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 (920270, 920540, 921200, 930100, 930110, 930120, passwords set off:
932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130,
941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230, 932160, 932230, 932250, 932260, 932270, 932280, 932330, 932340,
942290, 942320, 942540, 942550, 944150). An SQL injection or a path 932370, 932380, 933100, 933135, 933180, 933210, 941100, 941120,
traversal sent as the password reaches WordPress. A script injection 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
is still refused. 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 - `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).
@@ -995,12 +1001,17 @@ 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 (920270, 920540, 921200, 930100, 930110, that random passwords set off:
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100, 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
942540, 942550, 944150). An SQL injection or a path traversal sent as the 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
password reaches Drupal. A script injection is still refused. 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 - `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.
@@ -1080,16 +1091,20 @@ 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, for each rule that random passwords set off - The passwords when signing in:
(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`.
An SQL injection or a path traversal sent as a password reaches They skip each rule that random passwords set off:
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.
@@ -1216,11 +1231,7 @@ 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 skip each rule that random passwords set off The passwords in a body:
(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
@@ -1230,8 +1241,16 @@ 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.
An SQL injection or a path traversal sent as a password reaches Mastodon. A They skip each rule that random passwords set off:
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.
@@ -1366,13 +1385,17 @@ 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 (920270, 920540, 921200, 930100, 930110, rule that random passwords set off:
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100, 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
942540, 942550, 944150). It applies only when the query has no `password` 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
parameter. An SQL injection or a path traversal sent as the password reaches 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
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
+30 -9
View File
@@ -846,16 +846,35 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
} }
} }
// wantPasswordAttacksRefused checks that each of attacks, sent as the whole // wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
// password in the field name of r's body, is refused while bodies are read. // shell command and a script in an event handler, each sent as the whole
func wantPasswordAttacksRefused( // password in the field name of r's body, pass while bodies are read.
t *testing.T, r request, name string, attacks ...string, func wantPasswordAttacksPass(t *testing.T, r request, name 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 attacks { 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)
}
}
}
// 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,
@@ -1594,9 +1613,8 @@ 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")
// No rule pwd keeps catches the SQL injection or the path traversal. wantPasswordAttacksRefused(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag)
} }
func TestWordPressExclusionsAreNarrow(t *testing.T) { func TestWordPressExclusionsAreNarrow(t *testing.T) {
@@ -1656,6 +1674,9 @@ 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 {
+2 -4
View File
@@ -182,10 +182,8 @@ func TestDrupalPasswordPasses(t *testing.T) {
t.Parallel() t.Parallel()
wantRandomPasswordsPass(t, r, "pass") wantRandomPasswordsPass(t, r, "pass")
wantPasswordAttacksPass(t, r, "pass")
// No rule pass keeps catches the SQL injection or the path wantPasswordAttacksRefused(t, r, "pass")
// traversal.
wantPasswordAttacksRefused(t, r, "pass", scriptTag)
}) })
} }
} }
+5 -5
View File
@@ -152,6 +152,7 @@ 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]="},
@@ -313,10 +314,8 @@ 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)
// No rule these fields keep catches the SQL injection or the path wantPasswordAttacksRefused(t, r, name)
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
}) })
} }
@@ -327,7 +326,8 @@ func TestMastodonPasswordsPass(t *testing.T) {
[]string{jsonBody}} []string{jsonBody}}
wantRandomPasswordsPass(t, r, "password") wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password", scriptTag) wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
}) })
} }
+3 -4
View File
@@ -174,6 +174,7 @@ 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},
} }
@@ -252,10 +253,8 @@ 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)
// No rule these fields keep catches the SQL injection or the path wantPasswordAttacksRefused(t, r, name)
// traversal.
wantPasswordAttacksRefused(t, r, name, scriptTag)
}) })
} }
} }
+2 -4
View File
@@ -288,10 +288,8 @@ 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")
// No rule the password keeps catches the SQL injection or the path wantPasswordAttacksRefused(t, r, "password")
// traversal.
wantPasswordAttacksRefused(t, r, "password", scriptTag)
} }
func TestNextcloudSentencesPassInTheTexts(t *testing.T) { func TestNextcloudSentencesPassInTheTexts(t *testing.T) {