fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)
- Router.wrap applies the route's profile outside recovery, body limit and locale, whatever its position in the list or the group nesting - bufferedResponse.commit merges Vary instead of replacing it - TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary required by name in the gate
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
@@ -457,3 +460,183 @@ func TestTurnstileFactoryParams(t *testing.T) {
|
||||
t.Fatalf("bare turnstile: error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// guardRoutesPlugin is a routesPluginFunc that also registers named
|
||||
// middleware.
|
||||
type guardRoutesPlugin struct {
|
||||
routesPluginFunc
|
||||
mw map[string]pact.Middleware
|
||||
}
|
||||
|
||||
func (p guardRoutesPlugin) Middlewares() map[string]pact.Middleware { return p.mw }
|
||||
|
||||
// refusalMiddlewares answers 401 (deny) and 403 (forbid) without calling
|
||||
// next, and 413 from readall when the body exceeds the route's limit.
|
||||
func refusalMiddlewares() map[string]pact.Middleware {
|
||||
refuse := func(status int) pact.Middleware {
|
||||
return func(http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"error":"refused"}`))
|
||||
})
|
||||
}
|
||||
}
|
||||
return map[string]pact.Middleware{
|
||||
"deny": refuse(http.StatusUnauthorized),
|
||||
"forbid": refuse(http.StatusForbidden),
|
||||
"readall": func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := io.ReadAll(r.Body); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// assertSPAProfileHeaders requires the spa profile's answer for an allowed
|
||||
// origin.
|
||||
func assertSPAProfileHeaders(t *testing.T, label string, rec *httptest.ResponseRecorder, status int) {
|
||||
t.Helper()
|
||||
const www = "https://www.example.com"
|
||||
if rec.Code != status {
|
||||
t.Fatalf("%s: status %d, want %d (body %q)", label, rec.Code, status, rec.Body.String())
|
||||
}
|
||||
hd := rec.Header()
|
||||
if got := hd.Get("Access-Control-Allow-Origin"); got != www {
|
||||
t.Errorf("%s: Allow-Origin = %q, want %q", label, got, www)
|
||||
}
|
||||
if got := hd.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("%s: Allow-Credentials = %q, want true", label, got)
|
||||
}
|
||||
if !varyHas(hd, "Origin") {
|
||||
t.Errorf("%s: Vary = %q, want Origin", label, hd.Values("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSProfileHeadersOnRefusals(t *testing.T) {
|
||||
const www = "https://www.example.com"
|
||||
panicky := func(http.ResponseWriter, *http.Request) { panic("boom") }
|
||||
plugin := guardRoutesPlugin{
|
||||
routesPluginFunc: routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
||||
r.Get("/deny", okHandler, "deny", "cors:spa")
|
||||
r.Get("/forbid", okHandler, "forbid", "cors:spa")
|
||||
r.Group("/guarded", Use("deny"), func(g pact.Router) {
|
||||
g.Get("/me", okHandler, "cors:spa")
|
||||
})
|
||||
r.Get("/throttled", okHandler, "throttle:1,1", "cors:spa")
|
||||
r.Post("/upload", okHandler, "body.limit:16", "readall", "cors:spa")
|
||||
r.Get("/panic", panicky, "cors:spa")
|
||||
r.GroupRaw("/raw", Use("cors:spa"), func(g pact.Router) {
|
||||
g.Get("/panic", panicky)
|
||||
})
|
||||
}},
|
||||
mw: refusalMiddlewares(),
|
||||
}
|
||||
h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("guard_401", func(t *testing.T) {
|
||||
assertSPAProfileHeaders(t, "deny", serveCORS(h, corsRequest(http.MethodGet, "/deny", www, false)), http.StatusUnauthorized)
|
||||
})
|
||||
t.Run("guard_403", func(t *testing.T) {
|
||||
assertSPAProfileHeaders(t, "forbid", serveCORS(h, corsRequest(http.MethodGet, "/forbid", www, false)), http.StatusForbidden)
|
||||
})
|
||||
t.Run("group_guard_401", func(t *testing.T) {
|
||||
assertSPAProfileHeaders(t, "group deny", serveCORS(h, corsRequest(http.MethodGet, "/guarded/me", www, false)), http.StatusUnauthorized)
|
||||
})
|
||||
t.Run("throttle_429", func(t *testing.T) {
|
||||
assertSPAProfileHeaders(t, "first", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusOK)
|
||||
assertSPAProfileHeaders(t, "second", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusTooManyRequests)
|
||||
})
|
||||
t.Run("body_limit_413", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader(strings.Repeat("x", 64)))
|
||||
req.Header.Set("Origin", www)
|
||||
assertSPAProfileHeaders(t, "upload", serveCORS(h, req), http.StatusRequestEntityTooLarge)
|
||||
})
|
||||
t.Run("panic_500_json", func(t *testing.T) {
|
||||
rec := serveCORS(h, corsRequest(http.MethodGet, "/panic", www, false))
|
||||
assertSPAProfileHeaders(t, "panic", rec, http.StatusInternalServerError)
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "json") {
|
||||
t.Errorf("panic Content-Type = %q, want JSON", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
t.Run("panic_500_raw", func(t *testing.T) {
|
||||
assertSPAProfileHeaders(t, "raw panic", serveCORS(h, corsRequest(http.MethodGet, "/raw/panic", www, false)), http.StatusInternalServerError)
|
||||
})
|
||||
t.Run("disallowed_origin", func(t *testing.T) {
|
||||
rec := serveCORS(h, corsRequest(http.MethodGet, "/deny", "https://evil.example.org", false))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d, want 401", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want none", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("Allow-Credentials = %q, want none", got)
|
||||
}
|
||||
if !varyHas(rec.Header(), "Origin") {
|
||||
t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary"))
|
||||
}
|
||||
})
|
||||
|
||||
turnstilePlugin := routesPluginFunc{id: "acme.guest", routes: func(r pact.Router) {
|
||||
r.Post("/guest", okHandler, "turnstile:closed", "cors:spa")
|
||||
}}
|
||||
post := func(h http.Handler) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/guest", strings.NewReader(`{"name":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", www)
|
||||
return serveCORS(h, req)
|
||||
}
|
||||
t.Run("turnstile_422", func(t *testing.T) {
|
||||
// BuildRouter builds one verifier per router, so each Turnstile
|
||||
// case assembles its own app. An empty token is refused before any
|
||||
// siteverify call.
|
||||
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML})
|
||||
if err := app.Publish[*bouncer.Turnstile](bouncer.NewTurnstile("test-secret", false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := Assemble(app, []party.Plugin{turnstilePlugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSPAProfileHeaders(t, "turnstile without a token", post(h), http.StatusUnprocessableEntity)
|
||||
})
|
||||
t.Run("turnstile_503", func(t *testing.T) {
|
||||
h, err := Assemble(transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{turnstilePlugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSPAProfileHeaders(t, "turnstile without a secret in production", post(h), http.StatusServiceUnavailable)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCORSProfileVaryKeptWithHandlerVary(t *testing.T) {
|
||||
const www = "https://www.example.com"
|
||||
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
||||
r.Get("/x/me", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Vary", "Accept-Language")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}, "cors:spa")
|
||||
}}
|
||||
h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", www, false))
|
||||
assertSPAProfileHeaders(t, "handler Vary", rec, http.StatusOK)
|
||||
if !varyHas(rec.Header(), "Accept-Language") {
|
||||
t.Errorf("Vary = %q, want Accept-Language too", rec.Header().Values("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user