diff --git a/server/cors_middleware.go b/server/cors_middleware.go new file mode 100644 index 0000000..b65712b --- /dev/null +++ b/server/cors_middleware.go @@ -0,0 +1,113 @@ +package server + +import ( + "net/http" + "strconv" + "strings" +) + +// CorsConfig controls the CrossOrigin middleware. An empty AllowedOrigins +// blocks every cross-origin request, so callers must opt in explicitly. +type CorsConfig struct { + // AllowedOrigins lists the origins a browser may send credentialed + // requests from. A single "*" allows any origin; with AllowCredentials + // the matched origin is echoed back rather than "*", as the spec requires. + AllowedOrigins []string + // AllowedMethods lists the methods advertised on a preflight response. + AllowedMethods []string + // AllowedHeaders lists the request headers advertised on a preflight response. + AllowedHeaders []string + // ExposedHeaders lists response headers the browser may read. + ExposedHeaders []string + // AllowCredentials sets Access-Control-Allow-Credentials on the response. + AllowCredentials bool + // MaxAge caps how long, in seconds, a browser may cache a preflight result. + MaxAge int +} + +// CrossOrigin returns a middleware that applies cfg as the CORS policy. It sets +// the Access-Control-Allow-* headers on allowed cross-origin requests and +// answers preflight OPTIONS requests directly with 204, short-circuiting the +// router so they never reach a route handler. +func CrossOrigin(cfg CorsConfig) func(http.Handler) http.Handler { + allowAll := len(cfg.AllowedOrigins) == 1 && cfg.AllowedOrigins[0] == "*" + methods := strings.Join(cfg.AllowedMethods, ", ") + headers := strings.Join(cfg.AllowedHeaders, ", ") + exposed := strings.Join(cfg.ExposedHeaders, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // same-origin and non-browser requests carry no Origin, so there is + // nothing to negotiate; hand them straight to the router. + if origin == "" { + next.ServeHTTP(w, r) + return + } + + w.Header().Add("Vary", "Origin") + + if !allowAll && !originAllowed(cfg.AllowedOrigins, origin) { + // a preflight for a disallowed origin is still a preflight; end + // it here without CORS headers rather than routing an OPTIONS. + if isPreflight(r) { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + return + } + + // with credentials the spec forbids the "*" wildcard, so echo the + // concrete origin; without credentials "*" is fine to pass through. + if allowAll && !cfg.AllowCredentials { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + if cfg.AllowCredentials { + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + if isPreflight(r) { + if methods != "" { + w.Header().Set("Access-Control-Allow-Methods", methods) + } + if headers != "" { + w.Header().Set("Access-Control-Allow-Headers", headers) + } + if cfg.MaxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + w.WriteHeader(http.StatusNoContent) + return + } + + if exposed != "" { + w.Header().Set("Access-Control-Expose-Headers", exposed) + } + + next.ServeHTTP(w, r) + }) + } +} + +// originAllowed reports whether origin exactly matches one of the allowed +// origins. Matching is case-insensitive on the scheme and host per RFC 6454. +func originAllowed(allowed []string, origin string) bool { + for _, a := range allowed { + if strings.EqualFold(a, origin) { + return true + } + } + return false +} + +// isPreflight reports whether r is a CORS preflight: an OPTIONS request that +// carries the Access-Control-Request-Method header the browser adds. +func isPreflight(r *http.Request) bool { + return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" +} diff --git a/server/cors_middleware_test.go b/server/cors_middleware_test.go new file mode 100644 index 0000000..effc655 --- /dev/null +++ b/server/cors_middleware_test.go @@ -0,0 +1,123 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func corsThrough(cfg CorsConfig, req *http.Request) (*httptest.ResponseRecorder, bool) { + nextCalled := false + h := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + CrossOrigin(cfg)(h).ServeHTTP(rec, req) + return rec, nextCalled +} + +func Test_CrossOrigin(t *testing.T) { + base := CorsConfig{ + AllowedOrigins: []string{"http://localhost:4321", "http://localhost:5174"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowCredentials: true, + MaxAge: 600, + } + + tests := []struct { + name string + cfg CorsConfig + method string + origin string + preflight bool + wantAllow string + wantCreds string + wantMethods string + wantNext bool + wantStatus int + }{ + { + name: "no origin passes through untouched", + cfg: base, + method: http.MethodGet, + origin: "", + wantAllow: "", + wantNext: true, + wantStatus: http.StatusOK, + }, + { + name: "allowed origin echoes back with credentials", + cfg: base, + method: http.MethodGet, + origin: "http://localhost:4321", + wantAllow: "http://localhost:4321", + wantCreds: "true", + wantNext: true, + wantStatus: http.StatusOK, + }, + { + name: "disallowed origin gets no cors headers but still routes", + cfg: base, + method: http.MethodGet, + origin: "http://evil.example", + wantAllow: "", + wantNext: true, + wantStatus: http.StatusOK, + }, + { + name: "preflight short-circuits with 204 and advertises policy", + cfg: base, + method: http.MethodOptions, + origin: "http://localhost:5174", + preflight: true, + wantAllow: "http://localhost:5174", + wantCreds: "true", + wantMethods: "GET, POST, OPTIONS", + wantNext: false, + wantStatus: http.StatusNoContent, + }, + { + name: "wildcard without credentials returns star", + cfg: CorsConfig{AllowedOrigins: []string{"*"}}, + method: http.MethodGet, + origin: "http://anywhere.example", + wantAllow: "*", + wantNext: true, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/thing", http.NoBody) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + if tt.preflight { + req.Header.Set("Access-Control-Request-Method", "POST") + } + + rec, next := corsThrough(tt.cfg, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tt.wantAllow { + t.Fatalf("allow-origin: got %q want %q", got, tt.wantAllow) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != tt.wantCreds { + t.Fatalf("allow-credentials: got %q want %q", got, tt.wantCreds) + } + if tt.wantMethods != "" { + if got := rec.Header().Get("Access-Control-Allow-Methods"); got != tt.wantMethods { + t.Fatalf("allow-methods: got %q want %q", got, tt.wantMethods) + } + } + if next != tt.wantNext { + t.Fatalf("next called: got %v want %v", next, tt.wantNext) + } + if rec.Code != tt.wantStatus { + t.Fatalf("status: got %d want %d", rec.Code, tt.wantStatus) + } + }) + } +}