From 5e1d19bda3983bc654338b6b91e98252a06954dc Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 3 Apr 2026 21:49:34 +0530 Subject: [PATCH] feat(middleware): add CSRF protection for state-changing requests X-CSRF-Token was listed in allowed CORS headers but never validated. Combined with Allow-Credentials: true and SameSite=None cookies, all POST mutations were CSRF-vulnerable. Adds middleware requiring Content-Type: application/json or X-Requested-With header on POST/PUT/DELETE/PATCH requests. Browsers cannot send these headers cross-origin without CORS preflight. OAuth callback and token routes are exempted (provider redirects, client credentials flow). Fixes: H11 (High) --- internal/http_handlers/csrf.go | 51 ++++++++++++++++++++++++++++++ internal/http_handlers/provider.go | 2 ++ internal/server/http_routes.go | 1 + 3 files changed, 54 insertions(+) create mode 100644 internal/http_handlers/csrf.go diff --git a/internal/http_handlers/csrf.go b/internal/http_handlers/csrf.go new file mode 100644 index 00000000..694b0d7b --- /dev/null +++ b/internal/http_handlers/csrf.go @@ -0,0 +1,51 @@ +package http_handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// CSRFMiddleware protects against CSRF by requiring state-changing requests +// (POST, PUT, DELETE, PATCH) to include a custom header that browsers will +// not send cross-origin without a CORS preflight. +// OAuth callback POST routes are exempt as they originate from provider redirects. +func (h *httpProvider) CSRFMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + method := c.Request.Method + if method == "GET" || method == "HEAD" || method == "OPTIONS" { + c.Next() + return + } + + // Exempt OAuth callback routes (provider POST redirects) + if strings.HasPrefix(c.Request.URL.Path, "/oauth_callback/") { + c.Next() + return + } + + // Exempt /oauth/token (client credentials flow, no cookies) + if c.Request.URL.Path == "/oauth/token" || c.Request.URL.Path == "/oauth/revoke" { + c.Next() + return + } + + // Require Content-Type to be application/json or the presence of + // X-Requested-With header. Browsers cannot send these cross-origin + // without a CORS preflight check succeeding first. + contentType := c.Request.Header.Get("Content-Type") + xRequestedWith := c.Request.Header.Get("X-Requested-With") + + if strings.Contains(contentType, "application/json") || xRequestedWith != "" { + c.Next() + return + } + + c.JSON(http.StatusForbidden, gin.H{ + "error": "csrf_validation_failed", + "error_description": "State-changing requests must include Content-Type: application/json or X-Requested-With header", + }) + c.Abort() + } +} diff --git a/internal/http_handlers/provider.go b/internal/http_handlers/provider.go index 8e16870d..dcb0bf85 100644 --- a/internal/http_handlers/provider.go +++ b/internal/http_handlers/provider.go @@ -101,6 +101,8 @@ type Provider interface { ContextMiddleware() gin.HandlerFunc // CORSMiddleware is the middleware that adds the cors headers to the response CORSMiddleware() gin.HandlerFunc + // CSRFMiddleware protects against CSRF on state-changing requests + CSRFMiddleware() gin.HandlerFunc // LoggerMiddleware is the middleware that logs the request LoggerMiddleware() gin.HandlerFunc } diff --git a/internal/server/http_routes.go b/internal/server/http_routes.go index af377fac..17508a1d 100644 --- a/internal/server/http_routes.go +++ b/internal/server/http_routes.go @@ -15,6 +15,7 @@ func (s *server) NewRouter() *gin.Engine { router.Use(s.Dependencies.HTTPProvider.LoggerMiddleware()) router.Use(s.Dependencies.HTTPProvider.ContextMiddleware()) router.Use(s.Dependencies.HTTPProvider.CORSMiddleware()) + router.Use(s.Dependencies.HTTPProvider.CSRFMiddleware()) router.Use(s.Dependencies.HTTPProvider.ClientCheckMiddleware()) router.GET("/", s.Dependencies.HTTPProvider.RootHandler())