- every OPTIONS on a CORS path: 204 with Cache-Control no-cache, private - a preflight echoes the requested method (upper-cased) and headers when * allows any, with Vary and PHP's default Content-Type, as recorded from PHP - README and the routing docs describe the answer
165 lines
6.2 KiB
Go
165 lines
6.2 KiB
Go
package surf
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// Gap (d): pathScopedCORS with a path matching NONE of the configured
|
|
// globs. TestCORSPathScopedHeaders already covers the two named acme
|
|
// groups; this fixture is framework-only (/healthz vs api/*).
|
|
|
|
func TestCORSPathScopedNoMatchIndependentOfAcme(t *testing.T) {
|
|
cfg := CORSConfig{
|
|
Paths: []string{"api/*", "oauth/mcp/*"},
|
|
AllowedMethods: []string{"*"},
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedHeaders: []string{"*"},
|
|
}
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
h := pathScopedCORS(cfg, inner)
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("unmatched path must not set ACAO, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSAllowOriginExactAndPattern(t *testing.T) {
|
|
cfg := CORSConfig{
|
|
Paths: []string{"api/*"},
|
|
AllowedMethods: []string{"GET", "POST"},
|
|
AllowedOrigins: []string{"https://app.example.test"},
|
|
AllowedOriginsPatterns: []string{`^https://.*\.example\.test$`},
|
|
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
|
ExposedHeaders: []string{"X-RateLimit-Limit"},
|
|
MaxAge: 600,
|
|
SupportsCredentials: true,
|
|
}
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
h := pathScopedCORS(cfg, inner)
|
|
|
|
t.Run("exact origin", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
|
|
req.Header.Set("Origin", "https://app.example.test")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example.test" {
|
|
t.Fatalf("ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
if rec.Header().Get("Vary") != "Origin" {
|
|
t.Fatalf("Vary = %q", rec.Header().Get("Vary"))
|
|
}
|
|
if rec.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
|
t.Fatal("missing credentials header")
|
|
}
|
|
if rec.Header().Get("Access-Control-Max-Age") != "600" {
|
|
t.Fatalf("Max-Age = %q", rec.Header().Get("Access-Control-Max-Age"))
|
|
}
|
|
})
|
|
|
|
t.Run("pattern origin", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
|
|
req.Header.Set("Origin", "https://admin.example.test")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("preflight status = %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("Access-Control-Allow-Origin") != "https://admin.example.test" {
|
|
t.Fatalf("ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
})
|
|
|
|
t.Run("disallowed origin", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
|
|
req.Header.Set("Origin", "https://evil.test")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("disallowed origin ACAO = %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCORSOptionsMatchesLaravel pins the OPTIONS answers Laravel's
|
|
// HandleCors gives (recorded from PHP for the feedback widget): a
|
|
// preflight echoes the requested method and headers when any is allowed,
|
|
// with PHP's default Content-Type and Symfony's Cache-Control; a plain
|
|
// OPTIONS is a bare 204 with the Cache-Control.
|
|
func TestCORSOptionsMatchesLaravel(t *testing.T) {
|
|
cfg := CORSConfig{
|
|
Paths: []string{"_feedback/api/*"},
|
|
AllowedMethods: []string{"*"},
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedHeaders: []string{"*"},
|
|
}
|
|
called := false
|
|
h := pathScopedCORS(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/_feedback/api/v1/wk_key/submit", nil)
|
|
req.Header.Set("Origin", "https://app.example.test")
|
|
req.Header.Set("Access-Control-Request-Method", "post")
|
|
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
want := map[string]string{
|
|
"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 || rec.Body.Len() != 0 {
|
|
t.Fatalf("preflight = %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
for k, v := range want {
|
|
if got := rec.Header().Get(k); got != v {
|
|
t.Errorf("preflight %s = %q, want %q", k, got, v)
|
|
}
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodOptions, "/_feedback/api/v1/anything/else", nil))
|
|
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
|
t.Fatalf("plain OPTIONS = %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "no-cache, private" {
|
|
t.Errorf("plain Cache-Control = %q", got)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "" {
|
|
t.Errorf("plain Content-Type = %q", got)
|
|
}
|
|
if got := rec.Header().Get("Vary"); got != "Access-Control-Request-Method" {
|
|
t.Errorf("plain Vary = %q", got)
|
|
}
|
|
if called {
|
|
t.Fatal("an OPTIONS request reached the route handler")
|
|
}
|
|
|
|
// A listed method set is sent as configured, not echoed.
|
|
listed := pathScopedCORS(CORSConfig{Paths: []string{"api/*"}, AllowedMethods: []string{"GET", "POST"}, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"Authorization"}}, http.NotFoundHandler())
|
|
req = httptest.NewRequest(http.MethodOptions, "/api/items", nil)
|
|
req.Header.Set("Origin", "https://app.example.test")
|
|
req.Header.Set("Access-Control-Request-Method", "DELETE")
|
|
req.Header.Set("Access-Control-Request-Headers", "x-custom")
|
|
rec = httptest.NewRecorder()
|
|
listed.ServeHTTP(rec, req)
|
|
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST" {
|
|
t.Errorf("listed methods = %q", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization" {
|
|
t.Errorf("listed headers = %q", got)
|
|
}
|
|
}
|