- 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
643 lines
26 KiB
Go
643 lines
26 KiB
Go
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"
|
|
)
|
|
|
|
// Unit tests for the CORS hardening (D-04), the cors:<profile> policies
|
|
// (D-05) and the turnstile factory parameters (D-18) of phase 19.3.
|
|
|
|
func corsRequest(method, target, origin string, preflight bool) *http.Request {
|
|
r := httptest.NewRequest(method, target, nil)
|
|
if origin != "" {
|
|
r.Header.Set("Origin", origin)
|
|
}
|
|
if preflight {
|
|
r.Header.Set("Access-Control-Request-Method", "POST")
|
|
r.Header.Set("Access-Control-Request-Headers", "content-type")
|
|
}
|
|
return r
|
|
}
|
|
|
|
func serveCORS(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
return rec
|
|
}
|
|
|
|
func okHandler(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }
|
|
|
|
func TestCORSCredentialsWildcardRefused(t *testing.T) {
|
|
_, err := LoadCORSConfig(writeHTTPConfig(t, `
|
|
cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["https://www.example.com", "*"]
|
|
supports_credentials: true
|
|
`))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors.supports_credentials") || !strings.Contains(err.Error(), "http.cors.allowed_origins") {
|
|
t.Fatalf("global error = %v", err)
|
|
}
|
|
|
|
_, err = LoadCORSProfiles(writeHTTPConfig(t, `
|
|
cors_profiles:
|
|
spa:
|
|
allowed_origins: ["*"]
|
|
supports_credentials: true
|
|
`))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.supports_credentials") {
|
|
t.Fatalf("profile error = %v", err)
|
|
}
|
|
|
|
// The same origins without credentials load.
|
|
if _, err := LoadCORSConfig(writeHTTPConfig(t, "cors:\n paths: [\"api/*\"]\n allowed_origins: [\"*\"]\n")); err != nil {
|
|
t.Fatalf("* without credentials: %v", err)
|
|
}
|
|
// Credentials with exact origins and patterns load.
|
|
if _, err := LoadCORSConfig(writeHTTPConfig(t, `
|
|
cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["https://www.example.com", "https://*.example.com"]
|
|
allowed_origins_patterns: ['^https://[a-z]+\.example\.org$']
|
|
supports_credentials: true
|
|
`)); err != nil {
|
|
t.Fatalf("credentials with exact origins: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCORSPatternErrors(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, pattern, want string
|
|
}{
|
|
{"uncompilable", `^https://(www\.example\.com$`, "does not compile"},
|
|
{"hash delimited", `#^https://www\.example\.com$#`, "PHP delimiters"},
|
|
{"slash delimited with flags", `/^https://www\.example\.com$/i`, "PHP delimiters"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
yaml := "cors:\n paths: [\"api/*\"]\n allowed_origins_patterns:\n - 'https://ok\\.example\\.com'\n - '" + tc.pattern + "'\n"
|
|
_, err := LoadCORSConfig(writeHTTPConfig(t, yaml))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors.allowed_origins_patterns[1]") || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("global error = %v", err)
|
|
}
|
|
pyaml := "cors_profiles:\n spa:\n allowed_origins_patterns:\n - '" + tc.pattern + "'\n"
|
|
_, err = LoadCORSProfiles(writeHTTPConfig(t, pyaml))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.allowed_origins_patterns[0]") {
|
|
t.Fatalf("profile error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
// A pattern that merely contains # or / later is not delimited.
|
|
if phpDelimited(`^https://x\.example\.com/#$`) || !phpDelimited(`#x#`) || phpDelimited(`#x#1`) {
|
|
t.Fatal("phpDelimited misclassifies")
|
|
}
|
|
}
|
|
|
|
func TestCORSWildcardOriginEntry(t *testing.T) {
|
|
h := pathScopedCORS(CORSConfig{
|
|
Paths: []string{"api/*"},
|
|
AllowedOrigins: []string{"https://*.example.com"},
|
|
AllowedMethods: []string{"GET"},
|
|
SupportsCredentials: true,
|
|
}, http.HandlerFunc(okHandler))
|
|
for origin, allowed := range map[string]bool{
|
|
"https://www.example.com": true,
|
|
"https://a.b.example.com": true,
|
|
"https://example.com": false,
|
|
"https://example.com.evil.org": false,
|
|
"https://wwwexample.com": false,
|
|
"http://www.example.com": false,
|
|
"https://www.example.com.": false,
|
|
} {
|
|
rec := serveCORS(h, corsRequest(http.MethodGet, "/api/x", origin, false))
|
|
got := rec.Header().Get("Access-Control-Allow-Origin")
|
|
if allowed && got != origin {
|
|
t.Errorf("%s: Allow-Origin = %q, want the origin", origin, got)
|
|
}
|
|
if !allowed && got != "" {
|
|
t.Errorf("%s: Allow-Origin = %q, want none", origin, got)
|
|
}
|
|
}
|
|
if got := wildcardOriginPattern("https://*.example.com"); got != `^https://.*\.example\.com$` {
|
|
t.Fatalf("pattern = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSVaryOnDynamicAnswers(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
cfg CORSConfig
|
|
}{
|
|
{"credentials", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com"}, AllowedMethods: []string{"GET", "POST"}, SupportsCredentials: true}},
|
|
{"patterns", CORSConfig{Paths: []string{"api/*"}, AllowedOriginsPatterns: []string{`^https://www\.example\.com$`}, AllowedMethods: []string{"*"}}},
|
|
{"several origins", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com", "https://admin.example.com"}, AllowedMethods: []string{"*"}}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := pathScopedCORS(tc.cfg, http.HandlerFunc(okHandler))
|
|
for _, req := range []struct {
|
|
label string
|
|
r *http.Request
|
|
allow bool
|
|
}{
|
|
{"allowed GET", corsRequest(http.MethodGet, "/api/x", "https://www.example.com", false), true},
|
|
{"disallowed GET", corsRequest(http.MethodGet, "/api/x", "https://evil.example.org", false), false},
|
|
{"no origin GET", corsRequest(http.MethodGet, "/api/x", "", false), false},
|
|
{"allowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://www.example.com", true), true},
|
|
{"disallowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://evil.example.org", true), false},
|
|
} {
|
|
rec := serveCORS(h, req.r)
|
|
if !varyHas(rec.Header(), "Origin") {
|
|
t.Errorf("%s: Vary = %q, want Origin", req.label, rec.Header().Values("Vary"))
|
|
}
|
|
got := rec.Header().Get("Access-Control-Allow-Origin")
|
|
if req.allow && got != "https://www.example.com" {
|
|
t.Errorf("%s: Allow-Origin = %q", req.label, got)
|
|
}
|
|
if !req.allow && got != "" {
|
|
t.Errorf("%s: Allow-Origin = %q, want none", req.label, got)
|
|
}
|
|
if !req.allow && rec.Header().Get("Access-Control-Allow-Credentials") != "" {
|
|
t.Errorf("%s: credentials header on a refused origin", req.label)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Vary is added once, next to an existing Vary value.
|
|
h := http.Header{}
|
|
h.Add("Vary", "Accept-Encoding, origin")
|
|
addVary(h, "Origin")
|
|
if got := h.Values("Vary"); len(got) != 1 {
|
|
t.Fatalf("Vary = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSSubdomainCredentialed(t *testing.T) {
|
|
const www = "https://www.example.com"
|
|
app := transportApp(t, "production", map[string]string{
|
|
"app.yaml": "name: transport-test\nurl: https://api.example.com\n",
|
|
"http.yaml": transportBodyLimits + `cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["https://www.example.com"]
|
|
allowed_methods: ["GET", "POST"]
|
|
allowed_headers: ["Content-Type", "X-Requested-With"]
|
|
exposed_headers: ["X-Request-Id"]
|
|
max_age: 600
|
|
supports_credentials: true
|
|
`,
|
|
})
|
|
plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) {
|
|
r.Get("/api/me", okHandler)
|
|
r.Post("/api/auth/refresh", okHandler)
|
|
}}
|
|
h, err := Assemble(app, []party.Plugin{plugin})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
label string
|
|
r *http.Request
|
|
code int
|
|
}{
|
|
{"GET", corsRequest(http.MethodGet, "https://api.example.com/api/me", www, false), http.StatusOK},
|
|
{"POST", corsRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", www, false), http.StatusOK},
|
|
{"preflight", corsRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", www, true), http.StatusNoContent},
|
|
} {
|
|
rec := serveCORS(h, tc.r)
|
|
hd := rec.Header()
|
|
if rec.Code != tc.code {
|
|
t.Fatalf("%s: status %d", tc.label, rec.Code)
|
|
}
|
|
if hd.Get("Access-Control-Allow-Origin") != www || hd.Get("Access-Control-Allow-Credentials") != "true" {
|
|
t.Errorf("%s: Allow-Origin %q Allow-Credentials %q", tc.label, hd.Get("Access-Control-Allow-Origin"), hd.Get("Access-Control-Allow-Credentials"))
|
|
}
|
|
if !varyHas(hd, "Origin") {
|
|
t.Errorf("%s: Vary = %q", tc.label, hd.Values("Vary"))
|
|
}
|
|
if hd.Get("Access-Control-Allow-Methods") != "GET, POST" || hd.Get("Access-Control-Allow-Headers") != "Content-Type, X-Requested-With" {
|
|
t.Errorf("%s: methods %q headers %q", tc.label, hd.Get("Access-Control-Allow-Methods"), hd.Get("Access-Control-Allow-Headers"))
|
|
}
|
|
if hd.Get("Access-Control-Expose-Headers") != "X-Request-Id" || hd.Get("Access-Control-Max-Age") != "600" {
|
|
t.Errorf("%s: expose %q max-age %q", tc.label, hd.Get("Access-Control-Expose-Headers"), hd.Get("Access-Control-Max-Age"))
|
|
}
|
|
}
|
|
// The apex host and another site get nothing.
|
|
for _, origin := range []string{"https://example.com", "https://www.example.org"} {
|
|
rec := serveCORS(h, corsRequest(http.MethodGet, "https://api.example.com/api/me", origin, false))
|
|
if rec.Header().Get("Access-Control-Allow-Origin") != "" || rec.Header().Get("Access-Control-Allow-Credentials") != "" {
|
|
t.Errorf("%s: answered with CORS headers %v", origin, rec.Header())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCORSStarUnchanged(t *testing.T) {
|
|
cfg := CORSConfig{
|
|
Paths: []string{"api/*"},
|
|
AllowedMethods: []string{"*"},
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedHeaders: []string{"*"},
|
|
}
|
|
h := pathScopedCORS(cfg, http.HandlerFunc(okHandler))
|
|
|
|
rec := serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "https://app.example.test", false))
|
|
want := http.Header{
|
|
"Access-Control-Allow-Origin": {"*"},
|
|
"Access-Control-Allow-Methods": {"*"},
|
|
"Access-Control-Allow-Headers": {"*"},
|
|
}
|
|
assertHeaders(t, "GET", rec.Header(), want)
|
|
|
|
// Without an Origin the * answer is the same.
|
|
rec = serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "", false))
|
|
assertHeaders(t, "GET without Origin", rec.Header(), want)
|
|
|
|
rec = serveCORS(h, corsRequest(http.MethodOptions, "/api/genres", "https://app.example.test", true))
|
|
assertHeaders(t, "OPTIONS", rec.Header(), http.Header{
|
|
"Access-Control-Allow-Origin": {"*"},
|
|
"Access-Control-Allow-Methods": {"POST"},
|
|
"Access-Control-Allow-Headers": {"content-type"},
|
|
"Cache-Control": {"no-cache, private"},
|
|
"Content-Type": {"text/html; charset=UTF-8"},
|
|
"Vary": {"Access-Control-Request-Method, Access-Control-Request-Headers"},
|
|
})
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("OPTIONS status %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// assertHeaders requires got to carry exactly the header set want.
|
|
func assertHeaders(t *testing.T, label string, got, want http.Header) {
|
|
t.Helper()
|
|
for k, v := range want {
|
|
if g := got.Values(k); strings.Join(g, "|") != strings.Join(v, "|") {
|
|
t.Errorf("%s: %s = %q, want %q", label, k, g, v)
|
|
}
|
|
}
|
|
for k := range got {
|
|
if _, ok := want[k]; !ok {
|
|
t.Errorf("%s: unexpected header %s: %q", label, k, got.Values(k))
|
|
}
|
|
}
|
|
}
|
|
|
|
const spaProfileYAML = `cors_profiles:
|
|
spa:
|
|
allowed_origins: ["https://www.example.com"]
|
|
allowed_methods: ["GET", "POST"]
|
|
allowed_headers: ["Content-Type"]
|
|
supports_credentials: true
|
|
open:
|
|
allowed_origins: ["*"]
|
|
allowed_methods: ["GET"]
|
|
`
|
|
|
|
func TestCORSProfilePreflightRoute(t *testing.T) {
|
|
const www = "https://www.example.com"
|
|
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Group("/x", Use("cors:spa"), func(g pact.Router) {
|
|
g.Get("/me", okHandler)
|
|
g.Post("/me", okHandler)
|
|
g.Get("/items/{id}", okHandler)
|
|
})
|
|
r.Get("/public", okHandler, "cors:open")
|
|
r.Get("/plain", okHandler)
|
|
}}
|
|
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML})
|
|
router, err := BuildRouter(app, []party.Plugin{plugin})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
options := map[string]int{}
|
|
for _, ri := range router.Routes() {
|
|
if ri.Method == http.MethodOptions {
|
|
if ri.PluginID != corsPreflightOwner {
|
|
t.Errorf("OPTIONS %s owned by %q", ri.Pattern, ri.PluginID)
|
|
}
|
|
options[ri.Pattern]++
|
|
}
|
|
}
|
|
if len(options) != 3 || options["/x/me"] != 1 || options["/x/items/{id}"] != 1 || options["/public"] != 1 {
|
|
t.Fatalf("OPTIONS routes = %v, want one per profiled path", options)
|
|
}
|
|
h, err := router.compile()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := serveCORS(h, corsRequest(http.MethodOptions, "/x/items/7", www, true))
|
|
if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Methods") != "GET, POST" {
|
|
t.Fatalf("spa preflight = %d %v", rec.Code, rec.Header())
|
|
}
|
|
rec = serveCORS(h, corsRequest(http.MethodOptions, "/x/me", "https://evil.example.org", true))
|
|
if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" || !varyHas(rec.Header(), "Origin") {
|
|
t.Fatalf("refused preflight = %d %v", rec.Code, rec.Header())
|
|
}
|
|
rec = serveCORS(h, corsRequest(http.MethodOptions, "/public", "https://any.example.net", true))
|
|
if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "*" || rec.Header().Get("Access-Control-Allow-Credentials") != "" {
|
|
t.Fatalf("open preflight = %d %v", rec.Code, rec.Header())
|
|
}
|
|
// A path without a profile gets no preflight route.
|
|
rec = serveCORS(h, corsRequest(http.MethodOptions, "/plain", www, true))
|
|
if rec.Code == http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Fatalf("plain OPTIONS = %d %v", rec.Code, rec.Header())
|
|
}
|
|
// The profile also applies to the real request.
|
|
rec = serveCORS(h, corsRequest(http.MethodPost, "/x/me", www, false))
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
|
t.Fatalf("POST = %d %v", rec.Code, rec.Header())
|
|
}
|
|
}
|
|
|
|
func TestCORSProfileUnknown(t *testing.T) {
|
|
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Get("/x/me", okHandler, "cors:missing")
|
|
}}
|
|
_, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
|
|
if err == nil || !strings.Contains(err.Error(), `"missing"`) || !strings.Contains(err.Error(), "http.cors_profiles.missing") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
// No profiles configured at all.
|
|
_, err = Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}), []party.Plugin{plugin})
|
|
if err == nil || !strings.Contains(err.Error(), "missing") {
|
|
t.Fatalf("error without profiles = %v", err)
|
|
}
|
|
// A profile name outside the allowed characters.
|
|
_, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles:\n Spa.App:\n allowed_origins: [\"https://x.example.com\"]\n"))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.") {
|
|
t.Fatalf("bad name error = %v", err)
|
|
}
|
|
// cors_profiles that is not a map.
|
|
_, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles: [spa]\n"))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles") {
|
|
t.Fatalf("list error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCORSProfileWithPathsRefused(t *testing.T) {
|
|
_, err := LoadCORSProfiles(writeHTTPConfig(t, `cors_profiles:
|
|
spa:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["https://www.example.com"]
|
|
`))
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") || !strings.Contains(err.Error(), "cors:spa") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
_, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
|
|
spa:
|
|
paths: ["x/*"]
|
|
`}), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") {
|
|
t.Fatalf("BuildRouter error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCORSProfileOverlapGlobal(t *testing.T) {
|
|
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Get("/api/me", okHandler, "cors:spa")
|
|
}}
|
|
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML + `cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["*"]
|
|
`})
|
|
_, err := BuildRouter(app, []party.Plugin{plugin})
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors.paths") || !strings.Contains(err.Error(), "/api/me") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
|
|
// An OPTIONS route already registered on a profiled path collides.
|
|
r := New(nil)
|
|
r.corsProfiles = map[string]*corsPolicy{"spa": newCORSPolicy(CORSConfig{AllowedOrigins: []string{"https://www.example.com"}})}
|
|
r.add("acme.spa", "", nil, http.MethodGet, "/x/me", okHandler, []string{"cors:spa"}, false)
|
|
r.add("acme.other", "", nil, http.MethodOptions, "/x/me", okHandler, nil, false)
|
|
err = r.addCORSPreflights()
|
|
if err == nil || !strings.Contains(err.Error(), "OPTIONS /x/me") || !strings.Contains(err.Error(), "acme.other") {
|
|
t.Fatalf("own OPTIONS error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCORSProfileConflictingProfilesSamePath(t *testing.T) {
|
|
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Get("/x/me", okHandler, "cors:spa")
|
|
r.Post("/x/me", okHandler, "cors:open")
|
|
}}
|
|
_, err := BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
|
|
if err == nil || !strings.Contains(err.Error(), "/x/me") || !strings.Contains(err.Error(), "cors:spa") || !strings.Contains(err.Error(), "cors:open") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
|
|
two := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Group("/x", Use("cors:spa"), func(g pact.Router) {
|
|
g.Get("/me", okHandler, "cors:open")
|
|
})
|
|
}}
|
|
_, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{two})
|
|
if err == nil || !strings.Contains(err.Error(), "two CORS profiles") {
|
|
t.Fatalf("two profiles on one route error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTurnstileFactoryParams(t *testing.T) {
|
|
files := func() map[string]string { return map[string]string{"http.yaml": transportBodyLimits} }
|
|
for _, mw := range []string{"turnstile:open", "turnstile:closed"} {
|
|
if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")}); err != nil {
|
|
t.Fatalf("%s: %v", mw, err)
|
|
}
|
|
}
|
|
for _, mw := range []string{"turnstile:maybe", "turnstile:Closed", "turnstile:"} {
|
|
_, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")})
|
|
if err == nil || !strings.Contains(err.Error(), "turnstile:open or turnstile:closed") {
|
|
t.Fatalf("%s: error = %v", mw, err)
|
|
}
|
|
}
|
|
if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile", "turnstile:open")}); err == nil || !strings.Contains(err.Error(), `"turnstile"`) {
|
|
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"))
|
|
}
|
|
}
|