package test import ( "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" "net/http" "net/http/httptest" "strings" "testing" ) func newCrossOriginProtectedHandler(publicURL string) (http.Handler, *int) { reached := 0 handler := middleware.CrossOriginProtection(publicURL)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reached++ w.WriteHeader(http.StatusOK) })) return handler, &reached } func sendCrossOriginRequest(handler http.Handler, method string, headers map[string]string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, "https://cms.example.com/v1/user/invite", strings.NewReader(`{"email":"attacker@evil.example"}`)) req.Header.Set("Content-Type", "text/plain") req.AddCookie(&http.Cookie{Name: "jwt", Value: "session"}) for name, value := range headers { req.Header.Set(name, value) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } func TestCrossOriginProtection(t *testing.T) { refused := []struct { name string method string headers map[string]string }{ {"a cross-site form POST", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, {"a same-site but cross-origin POST from a sibling subdomain", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://blog.example.com"}}, {"a cross-site DELETE", http.MethodDelete, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, {"an old browser's cross-origin POST without Sec-Fetch-Site", http.MethodPost, map[string]string{"Origin": "https://evil.example"}}, } for _, tc := range refused { t.Run("Refuses "+tc.name, func(t *testing.T) { handler, reached := newCrossOriginProtectedHandler("https://cms.example.com") rec := sendCrossOriginRequest(handler, tc.method, tc.headers) if rec.Code != http.StatusForbidden { t.Errorf("expected 403, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), apperr.CrossOriginErrorMessage) { t.Errorf("expected the cross-origin message, got %q", rec.Body.String()) } if *reached != 0 { t.Error("the handler must not run") } }) } allowed := []struct { name string method string headers map[string]string }{ {"a same-origin POST from the SPA", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}}, {"a request typed in the address bar", http.MethodPost, map[string]string{"Sec-Fetch-Site": "none"}}, {"a non-browser client with neither header", http.MethodPost, nil}, {"a cross-site GET", http.MethodGet, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, {"a cross-site preflight", http.MethodOptions, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}}, {"a cross-origin POST carrying an explicit Authorization header", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://shop.example", "Authorization": "Bearer rcms_key"}}, {"an old browser's POST from the public URL behind a proxy that rewrites Host", http.MethodPost, map[string]string{"Origin": "https://cms.example.com", "Host": "127.0.0.1:8080"}}, } for _, tc := range allowed { t.Run("Allows "+tc.name, func(t *testing.T) { handler, reached := newCrossOriginProtectedHandler("https://cms.example.com") req := httptest.NewRequest(tc.method, "https://cms.example.com/v1/user/invite", nil) for name, value := range tc.headers { if name == "Host" { req.Host = value continue } req.Header.Set(name, value) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK || *reached != 1 { t.Errorf("expected the handler to run, got %d (reached %d)", rec.Code, *reached) } }) } t.Run("An unparsable public URL still protects against cross-site requests", func(t *testing.T) { handler, reached := newCrossOriginProtectedHandler("::not a url") rec := sendCrossOriginRequest(handler, http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}) if rec.Code != http.StatusForbidden || *reached != 0 { t.Errorf("expected 403 without reaching the handler, got %d (reached %d)", rec.Code, *reached) } }) }