-
Notifications
You must be signed in to change notification settings - Fork 0
Implement CORs #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement CORs #13
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| // CORSConfig configures the CORS middleware. | ||
| type CORSConfig struct { | ||
| // AllowedOrigins is the list of exact origins permitted to access the API | ||
| // from a browser, e.g. "https://fmsg.io". A single entry of "*" allows any | ||
| // origin (only valid when credentials are not used). An empty list | ||
| // disables CORS entirely. | ||
| AllowedOrigins []string | ||
| // AllowedMethods are the HTTP methods returned in the preflight response. | ||
| AllowedMethods []string | ||
| // AllowedHeaders are the request headers returned in the preflight response. | ||
| AllowedHeaders []string | ||
| // MaxAge controls how long browsers may cache the preflight result. | ||
| MaxAge time.Duration | ||
| } | ||
|
|
||
| // DefaultCORSConfig returns a CORSConfig populated with values appropriate for | ||
| // this API: GET/POST/PUT/DELETE/OPTIONS plus Authorization and Content-Type | ||
| // request headers, with a 10 minute preflight cache. Callers must still set | ||
| // AllowedOrigins. | ||
| func DefaultCORSConfig() CORSConfig { | ||
| return CORSConfig{ | ||
| AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, | ||
| AllowedHeaders: []string{"Authorization", "Content-Type"}, | ||
| MaxAge: 10 * time.Minute, | ||
| } | ||
| } | ||
|
|
||
| // NewCORS returns a Gin middleware that handles CORS preflight requests and | ||
| // adds the Access-Control-Allow-* headers to matching cross-origin responses. | ||
| // | ||
| // Behaviour: | ||
| // - Requests without an Origin header pass through untouched. | ||
| // - When Origin matches an entry in AllowedOrigins (or AllowedOrigins is | ||
| // {"*"}), the appropriate Access-Control-Allow-* headers are added. | ||
| // - OPTIONS preflight requests are short-circuited with 204 so they never | ||
| // reach downstream auth middleware (which would reject them for missing | ||
| // the Authorization header). | ||
| // - When Origin is present but not allowed, the request is allowed to | ||
| // continue without CORS headers; the browser will then block the | ||
| // response, which is the standard CORS failure mode. | ||
| func NewCORS(cfg CORSConfig) gin.HandlerFunc { | ||
| if len(cfg.AllowedOrigins) == 0 { | ||
| // CORS disabled; return a no-op middleware. | ||
| return func(c *gin.Context) { c.Next() } | ||
| } | ||
|
|
||
| trimmedOrigins := make([]string, 0, len(cfg.AllowedOrigins)) | ||
| allowed := make(map[string]struct{}, len(cfg.AllowedOrigins)) | ||
| for _, o := range cfg.AllowedOrigins { | ||
| origin := strings.TrimSpace(o) | ||
| if origin == "" { | ||
| continue | ||
| } | ||
| trimmedOrigins = append(trimmedOrigins, origin) | ||
| allowed[origin] = struct{}{} | ||
| } | ||
| if len(trimmedOrigins) == 0 { | ||
| // CORS disabled; return a no-op middleware. | ||
| return func(c *gin.Context) { c.Next() } | ||
| } | ||
| allowAny := len(trimmedOrigins) == 1 && trimmedOrigins[0] == "*" | ||
|
|
||
| methods := strings.Join(cfg.AllowedMethods, ", ") | ||
| headers := strings.Join(cfg.AllowedHeaders, ", ") | ||
| maxAge := strconv.Itoa(int(cfg.MaxAge.Seconds())) | ||
|
|
||
| return func(c *gin.Context) { | ||
| origin := c.GetHeader("Origin") | ||
| if origin == "" { | ||
| c.Next() | ||
| return | ||
| } | ||
|
|
||
| // Always advertise that the response varies by Origin so caches | ||
| // (browser + intermediaries) don't serve a response keyed only on | ||
| // the URL across different origins. | ||
| c.Writer.Header().Add("Vary", "Origin") | ||
|
|
||
| _, ok := allowed[origin] | ||
| if !ok && !allowAny { | ||
| // Not an allowed origin. Don't add CORS headers; let the request | ||
| // proceed (the browser will block the response). | ||
| c.Next() | ||
| return | ||
| } | ||
|
|
||
| if allowAny { | ||
| c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | ||
| } else { | ||
| c.Writer.Header().Set("Access-Control-Allow-Origin", origin) | ||
| } | ||
|
|
||
| if c.Request.Method == http.MethodOptions { | ||
| // Preflight. | ||
| c.Writer.Header().Add("Vary", "Access-Control-Request-Method") | ||
| c.Writer.Header().Add("Vary", "Access-Control-Request-Headers") | ||
| if methods != "" { | ||
|
Comment on lines
+104
to
+108
|
||
| c.Writer.Header().Set("Access-Control-Allow-Methods", methods) | ||
| } | ||
| if headers != "" { | ||
| c.Writer.Header().Set("Access-Control-Allow-Headers", headers) | ||
| } | ||
| if cfg.MaxAge > 0 { | ||
| c.Writer.Header().Set("Access-Control-Max-Age", maxAge) | ||
| } | ||
| c.AbortWithStatus(http.StatusNoContent) | ||
| return | ||
| } | ||
|
|
||
| c.Next() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| package middleware | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| ) | ||
|
|
||
| func init() { | ||
| gin.SetMode(gin.TestMode) | ||
| } | ||
|
|
||
| func newCORSTestRouter(origins []string) *gin.Engine { | ||
| r := gin.New() | ||
| cfg := DefaultCORSConfig() | ||
| cfg.AllowedOrigins = origins | ||
| r.Use(NewCORS(cfg)) | ||
| r.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) | ||
| r.POST("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) | ||
| return r | ||
| } | ||
|
|
||
| func TestCORS_NoOriginPassesThrough(t *testing.T) { | ||
| r := newCORSTestRouter([]string{"https://fmsg.io"}) | ||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodGet, "/x", nil) | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", w.Code) | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q, want empty", got) | ||
| } | ||
| } | ||
|
|
||
| func TestCORS_AllowedOriginGetsHeaders(t *testing.T) { | ||
| r := newCORSTestRouter([]string{"https://fmsg.io"}) | ||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodGet, "/x", nil) | ||
| req.Header.Set("Origin", "https://fmsg.io") | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", w.Code) | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q, want https://fmsg.io", got) | ||
| } | ||
| if got := w.Header().Get("Vary"); got == "" { | ||
| t.Errorf("Vary header missing") | ||
| } | ||
| } | ||
|
|
||
| func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) { | ||
| r := newCORSTestRouter([]string{"https://fmsg.io"}) | ||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodGet, "/x", nil) | ||
| req.Header.Set("Origin", "https://evil.example") | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Fatalf("status = %d, want 200", w.Code) | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q, want empty", got) | ||
| } | ||
| } | ||
|
|
||
| func TestCORS_PreflightShortCircuits(t *testing.T) { | ||
| r := gin.New() | ||
| cfg := DefaultCORSConfig() | ||
| cfg.AllowedOrigins = []string{"https://fmsg.io"} | ||
| r.Use(NewCORS(cfg)) | ||
| // Downstream middleware that would reject if reached. | ||
| r.Use(func(c *gin.Context) { | ||
| c.AbortWithStatus(http.StatusUnauthorized) | ||
| }) | ||
| r.POST("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) | ||
|
|
||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodOptions, "/x", nil) | ||
| req.Header.Set("Origin", "https://fmsg.io") | ||
| req.Header.Set("Access-Control-Request-Method", "POST") | ||
| req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type") | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if w.Code != http.StatusNoContent { | ||
| t.Fatalf("status = %d, want 204", w.Code) | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q", got) | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" { | ||
| t.Errorf("Access-Control-Allow-Methods missing") | ||
| } | ||
| if got := w.Header().Get("Access-Control-Allow-Headers"); got == "" { | ||
| t.Errorf("Access-Control-Allow-Headers missing") | ||
| } | ||
| if got := w.Header().Get("Access-Control-Max-Age"); got == "" { | ||
| t.Errorf("Access-Control-Max-Age missing") | ||
| } | ||
| } | ||
|
|
||
| func TestCORS_Wildcard(t *testing.T) { | ||
| r := newCORSTestRouter([]string{"*"}) | ||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodGet, "/x", nil) | ||
| req.Header.Set("Origin", "https://anything.example") | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q, want *", got) | ||
| } | ||
| } | ||
|
|
||
| func TestCORS_DisabledWhenNoOrigins(t *testing.T) { | ||
| r := newCORSTestRouter(nil) | ||
| w := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodGet, "/x", nil) | ||
| req.Header.Set("Origin", "https://fmsg.io") | ||
| r.ServeHTTP(w, req) | ||
|
|
||
| if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { | ||
| t.Errorf("Access-Control-Allow-Origin = %q, want empty", got) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.