package coreruleset_test import ( "encoding/json" "fmt" "net/http" "net/url" "slices" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // 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" ) // Mastodon's paths for the web app's profile editor, for signing up through // the API, for reports, and for warning presets, roles and the terms of // service. const ( mastodonEditor = "/api/v1/profile" mastodonSignUp = "/api/v1/accounts" mastodonReports = "/api/v1/reports" mastodonPresets = "/admin/warning_presets" mastodonRoles = "/admin/roles" mastodonTerms = "/admin/terms_of_service/draft" ) // 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", atDefaults(t), readingBodies(t)) } func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) { t.Parallel() crs := servingApps(t, map[string][]string{ giteaHost: {gitea}, otherHost: {"mastodon"}, }) // A search, which only Mastodon's fixes let through. search := get("/api/v2/search?q=python3") wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{}) wantResult(t, crs, withHost(search, giteaHost), matched(932260)) } func TestHostnameListingMastodonLetsItsRecordedRequestsThrough(t *testing.T) { t.Parallel() apps := map[string][]string{recordedHost: {"mastodon"}} bodies := shared(t, coreruleset.Params{ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, Apps: apps, }) wantRecordedRequestsPass(t, "mastodon-requests.txt", servingApps(t, apps), bodies) } // 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=", "/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, mastodonReports, 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]="}, {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="}, {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="}, {http.MethodPost, "/admin/report_notes", "report_note[content]="}, { http.MethodPost, "/admin/account_moderation_notes", "account_moderation_note[content]=", }, {http.MethodPost, mastodonPresets, "account_warning_preset[title]="}, {http.MethodPost, mastodonPresets, "account_warning_preset[text]="}, {http.MethodPost, mastodonRoles, "user_role[name]="}, {http.MethodPost, mastodonTerms, "terms_of_service[text]="}, {http.MethodPost, mastodonTerms, "terms_of_service[changelog]="}, {http.MethodPost, "/settings/migration", "account_migration[current_password]="}, { http.MethodPost, "/settings/migration/redirect", "form_redirect[current_password]=", }, {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="}, {http.MethodPost, mastodonSignUp, passwordField}, {http.MethodPost, mastodonSignUp, "reason="}, } // 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.MethodPut, "/api/v1/media/1", `{"description":%s}`}, { http.MethodPut, mastodonStatuses + "/1", `{"media_attributes":[{"id":"1","description":%s}]}`, }, {http.MethodPatch, mastodonEditor, `{"display_name":%s}`}, {http.MethodPatch, mastodonEditor, `{"note":%s}`}, {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"name":%s}]}`}, {http.MethodPatch, mastodonEditor, `{"fields_attributes":[{"value":%s}]}`}, {http.MethodPatch, mastodonProfile, `{"display_name":%s}`}, {http.MethodPatch, mastodonProfile, `{"note":%s}`}, {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"name":%s}]}`}, {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`}, {http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, {http.MethodPost, mastodonSignUp, `{"password":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%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}`}, } ) // mastodonForm returns what crs finds in value, sent in sent's form data // field. func mastodonForm(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 } // mastodonJSON returns what crs finds in value, which is not escaped, sent // in sent's JSON field. func mastodonJSON(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, } // mastodonScores returns what crs finds in value, escaped as a query value, // sent in each field that skips the four rules: in the query, in form data // and in JSON. Bodies are read whatever crs is. Each score is keyed by where // value was sent. func mastodonScores( t *testing.T, crs *coreruleset.CoreRuleSet, value string, ) map[string]int { t.Helper() scores := map[string]int{} for _, target := range mastodonQueryFields { scores[target+value] = inspect(t, crs, get(target+value)).Score } for _, sent := range slices.Concat(mastodonFormFields, mastodonStatusForms) { scores[fmt.Sprintf("%v with %s", sent, value)] = mastodonForm(t, sent, value) } unescaped, err := url.QueryUnescape(value) if err != nil { t.Fatal(err) } for _, sent := range slices.Concat(mastodonJSONFields, mastodonStatusJSON) { scores[fmt.Sprintf("%v with %q", sent, unescaped)] = mastodonJSON(t, sent, unescaped) } return scores } func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) { t.Parallel() for _, attack := range mastodonAttacks { for sent, score := range mastodonScores(t, atDefaults(t), attack) { if score < mastodonThreshold { t.Errorf("%s let through: %d", sent, score) } } } } func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, target := range mastodonQueryFields { got := inspect(t, crs, get(target+commandInjection)) if got.Score < mastodonThreshold { t.Errorf("%s%s let through: %+v", target, commandInjection, got) } } for _, sent := range mastodonFormFields { if mastodonForm(t, sent, commandInjection) < mastodonThreshold { t.Errorf("%v let through", sent) } } attack, err := url.QueryUnescape(commandInjection) if err != nil { t.Fatal(err) } for _, sent := range mastodonJSONFields { if mastodonJSON(t, sent, attack) < 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() for _, value := range hardValues { for sent, score := range mastodonScores(t, readingBodies(t), value) { if score >= mastodonThreshold { t.Errorf("%s refused: %d", sent, score) } } } code := url.QueryEscape(mastodonCodePost) for _, sent := range mastodonStatusForms { if mastodonForm(t, sent, code) >= mastodonThreshold { t.Errorf("%v refused", sent) } } for _, sent := range mastodonStatusJSON { if mastodonJSON(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 looking up a link to // a profile or a post, as recorded. const mastodonActivityPub = `Accept: application/ld+json; ` + `profile="https://www.w3.org/ns/activitystreams", application/activity+json, ` + `text/html;q=0.1` // mastodonReport is a report as the web app sends it, with %s for its // forward value. const mastodonReport = `{"account_id":"1","comment":"Spam","forward":%s,` + `"forward_to_domains":[]}` // mastodonForwardedReport is a report the web app forwards to another // server, with %s for that server. const mastodonForwardedReport = `{"account_id":"1","comment":"Spam","forward":true,` + `"forward_to_domains":[%s]}` 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 and posts. for _, target := range []string{"/@admin", "/@admin/1", "/users/admin/statuses/1"} { wantResult(t, crs, get(target, mastodonActivityPub), matched()) } for _, target := range []string{"/", "/@admin/1/other", "/x/@admin"} { wantResult(t, crs, get(target, mastodonActivityPub), matched(920600)) } 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, mastodonEditor, } { 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 mastodonForm(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 mastodonJSON(t, sent, "python3") < mastodonThreshold { t.Errorf("%v let through", sent) } } } func TestMastodonReportKeysPassByNameOnReportsOnly(t *testing.T) { t.Parallel() // A report's forward keys pass by name, and their values are still // checked. for _, sent := range []struct{ body, value string }{ {mastodonReport, "false"}, {mastodonForwardedReport, "remote.example"}, } { report := mastodonBodyField{http.MethodPost, mastodonReports, sent.body} if got := mastodonJSON(t, report, sent.value); got >= mastodonThreshold { t.Errorf("%v refused: %d", report, got) } if got := mastodonJSON(t, report, `"../../etc/passwd"`); got < mastodonThreshold { t.Errorf("%v with a path let through: %d", report, got) } report.target = mastodonStatuses if got := mastodonJSON(t, report, sent.value); got < mastodonThreshold { t.Errorf("%v let through: %d", report, got) } } } // mastodonUsername and mastodonHashtag are the sign-up form's username and // the web app's featured hashtag, which skip only 932120 and 932260. // //nolint:gochecknoglobals // a struct cannot be a constant var ( mastodonUsername = mastodonBodyField{ http.MethodPost, mastodonAccount, "user[account_attributes][username]=", } mastodonHashtag = mastodonBodyField{ http.MethodPost, "/api/v1/featured_tags", `{"name":%s}`, } ) func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) { t.Parallel() // python3 and powershell can be a username or a hashtag; ls -la and // .htaccess cannot. passes, refused := hardValues[:2], hardValues[2:] for _, value := range passes { if mastodonForm(t, mastodonUsername, value) >= mastodonThreshold { t.Errorf("username %s refused", value) } if mastodonJSON(t, mastodonHashtag, value) >= mastodonThreshold { t.Errorf("hashtag %s refused", value) } } for _, value := range slices.Concat( refused, mastodonAttacks, []string{commandInjection}, ) { if mastodonForm(t, mastodonUsername, value) < mastodonThreshold { t.Errorf("username %s let through", value) } unescaped, err := url.QueryUnescape(value) if err != nil { t.Fatal(err) } if mastodonJSON(t, mastodonHashtag, unescaped) < mastodonThreshold { t.Errorf("hashtag %q let through", unescaped) } } // Elsewhere both keep the two rules. signIn := mastodonBodyField{http.MethodPost, mastodonSignIn, mastodonUsername.field} if mastodonForm(t, signIn, "python3") < mastodonThreshold { t.Errorf("%v let through", signIn) } statuses := mastodonBodyField{http.MethodPost, mastodonStatuses, mastodonHashtag.field} if mastodonJSON(t, statuses, "python3") < mastodonThreshold { t.Errorf("%v let through", statuses) } } // mastodonEmail is the email address on the sign-up, sign-in, password // reset and account settings forms, as the start of a form body. const mastodonEmail = "user[email]=" func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) { t.Parallel() for _, target := range []string{mastodonAccount, mastodonSignIn, "/auth/password"} { sent := mastodonBodyField{http.MethodPost, target, mastodonEmail} for _, address := range []string{ "python3%40example.com", "powershell%40example.com", "jane.profile%40example.com", "notes.htaccess%40example.com", } { if mastodonForm(t, sent, address) >= mastodonThreshold { t.Errorf("%v with %s refused", sent, address) } } // The address keeps 932250 and the rules that refuse attacks. for _, value := range slices.Concat( []string{"ls%20-la"}, mastodonAttacks, []string{commandInjection}, ) { if mastodonForm(t, sent, value) < mastodonThreshold { t.Errorf("%v with %s let through", sent, value) } } } // Elsewhere the address keeps every rule. for _, target := range []string{"/oauth/authorize", "/authorize", "/x/auth"} { sent := mastodonBodyField{http.MethodPost, target, mastodonEmail} if mastodonForm(t, sent, "python3%40example.com") < 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"}, {mastodonRoles, "user_role[name]=python3"}, {mastodonSignIn, mastodonPassword + "python3"}, {mastodonSignIn, mastodonEmail + "python3"}, {mastodonSignUp, "password=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) } } }