811 lines
25 KiB
Go
811 lines
25 KiB
Go
package e2e
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
|
|
"github.com/gochat/gochat/internal/middleware"
|
|
)
|
|
|
|
// CSRFE2ETestSuite tests the CSRF middleware's double-submit cookie pattern
|
|
// end-to-end using a lightweight Gin router + httptest.Server (no database).
|
|
//
|
|
// Reference: OWASP CSRF Prevention Cheat Sheet — Double-Submit Cookie pattern.
|
|
// Unlike Chatwoot (Rails form_authenticity_token), this is API-first and stateless.
|
|
type CSRFE2ETestSuite struct {
|
|
suite.Suite
|
|
server *httptest.Server
|
|
router *gin.Engine
|
|
}
|
|
|
|
// SetupSuite builds a minimal Gin router with CSRF middleware and a test endpoint.
|
|
func (s *CSRFE2ETestSuite) SetupSuite() {
|
|
// E2E tests require PostgreSQL; skip in SQLite test mode.
|
|
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
|
}
|
|
|
|
// TearDownSuite shuts down the httptest server (if running).
|
|
func (s *CSRFE2ETestSuite) TearDownSuite() {
|
|
if s.server != nil {
|
|
s.server.Close()
|
|
}
|
|
}
|
|
|
|
// buildServer creates a fresh httptest.Server with the given CSRF config.
|
|
// This allows each test to have its own server configuration without
|
|
// state leakage between tests.
|
|
func (s *CSRFE2ETestSuite) buildServer(cfg middleware.CSRFConfig) *httptest.Server {
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
r.Use(middleware.CSRF(cfg))
|
|
|
|
// Safe-method endpoint — returns JSON with the csrf_token from the context
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
token, exists := c.Get("csrf_token")
|
|
c.JSON(200, gin.H{
|
|
"csrf_token": token,
|
|
"exists": exists,
|
|
})
|
|
})
|
|
|
|
// Unsafe-method endpoints — should only reach handler if CSRF validated
|
|
r.POST("/api/test", func(c *gin.Context) {
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
})
|
|
r.PUT("/api/test", func(c *gin.Context) {
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
})
|
|
r.PATCH("/api/test", func(c *gin.Context) {
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
})
|
|
r.DELETE("/api/test", func(c *gin.Context) {
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
})
|
|
|
|
// HEAD and OPTIONS also treated as safe by default
|
|
r.HEAD("/api/test", func(c *gin.Context) {
|
|
c.Status(200)
|
|
})
|
|
r.OPTIONS("/api/test", func(c *gin.Context) {
|
|
c.Status(204)
|
|
})
|
|
|
|
return httptest.NewServer(r)
|
|
}
|
|
|
|
// parseJSONBody reads the response body and parses it into a map.
|
|
func parseJSONBody(body io.Reader) map[string]interface{} {
|
|
var result map[string]interface{}
|
|
json.NewDecoder(body).Decode(&result)
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 1: Safe method (GET) receives a CSRF cookie
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestSafeMethod_GET_ReceivesCSRFCookie() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false // needed for httptest (no TLS)
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
resp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
// GET should succeed (200 OK)
|
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode,
|
|
"Safe method GET should pass through and return 200")
|
|
|
|
// Response should contain csrf_token in the JSON body
|
|
bodyData := parseJSONBody(resp.Body)
|
|
assert.True(s.T(), bodyData["exists"].(bool),
|
|
"csrf_token should be set in the Gin context")
|
|
assert.NotEmpty(s.T(), bodyData["csrf_token"],
|
|
"csrf_token value should not be empty")
|
|
|
|
// CSRF cookie should be present in the response
|
|
cookies := resp.Cookies()
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range cookies {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie, "CSRF cookie should be set on safe request")
|
|
assert.NotEmpty(s.T(), csrfCookie.Value,
|
|
"CSRF cookie value should not be empty")
|
|
assert.Equal(s.T(), "/", csrfCookie.Path,
|
|
"CSRF cookie path should default to /")
|
|
assert.Equal(s.T(), http.SameSiteStrictMode, csrfCookie.SameSite,
|
|
"CSRF cookie SameSite should default to Strict")
|
|
assert.False(s.T(), csrfCookie.HttpOnly,
|
|
"CSRF cookie HttpOnly should be false (JS must read it for double-submit)")
|
|
assert.Equal(s.T(), 3600, csrfCookie.MaxAge,
|
|
"CSRF cookie MaxAge should default to 3600 seconds")
|
|
|
|
// The cookie value and the body token should match
|
|
assert.Equal(s.T(), csrfCookie.Value, bodyData["csrf_token"],
|
|
"Cookie value should match the context csrf_token")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Unsafe method (POST) with matching header+cookie passes
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_POST_MatchingHeaderAndCookie_Passes() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Step 1: Make a GET request to obtain a CSRF token cookie
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
s.Require().Equal(http.StatusOK, getResp.StatusCode)
|
|
|
|
// Extract the CSRF cookie value
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie, "GET request should set CSRF cookie")
|
|
tokenValue := csrfCookie.Value
|
|
|
|
// Step 2: Make a POST request with the token in both cookie and header
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.Header.Set("X-CSRF-Token", tokenValue)
|
|
postReq.AddCookie(csrfCookie)
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
// POST should succeed (200 OK) when tokens match
|
|
assert.Equal(s.T(), http.StatusOK, postResp.StatusCode,
|
|
"POST with matching header+cookie should pass through")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: Unsafe method (POST) without cookie → 403
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_POST_NoCookie_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// POST without any CSRF cookie
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
// Intentionally do NOT set cookie or header
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, postResp.StatusCode,
|
|
"POST without CSRF cookie should be rejected with 403")
|
|
|
|
bodyData := parseJSONBody(postResp.Body)
|
|
assert.Equal(s.T(), "CSRF token missing from cookie", bodyData["error"],
|
|
"Error message should indicate missing cookie token")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Unsafe method (POST) without header → 403
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_POST_NoHeader_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Step 1: Get a CSRF cookie via GET
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie, "GET should provide CSRF cookie")
|
|
|
|
// Step 2: POST with cookie but WITHOUT the X-CSRF-Token header
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.AddCookie(csrfCookie)
|
|
// Intentionally do NOT set X-CSRF-Token header
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, postResp.StatusCode,
|
|
"POST with cookie but no header should be rejected with 403")
|
|
|
|
bodyData := parseJSONBody(postResp.Body)
|
|
assert.Equal(s.T(), "CSRF token missing from header", bodyData["error"],
|
|
"Error message should indicate missing header token")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Unsafe method (POST) with mismatched header vs cookie → 403
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_POST_MismatchedToken_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Step 1: Get a valid CSRF cookie via GET
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie, "GET should provide CSRF cookie")
|
|
|
|
// Step 2: POST with the valid cookie but a DIFFERENT (wrong) header value
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.AddCookie(csrfCookie)
|
|
postReq.Header.Set("X-CSRF-Token", "this-is-a-fake-token-value")
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, postResp.StatusCode,
|
|
"POST with mismatched tokens should be rejected with 403")
|
|
|
|
bodyData := parseJSONBody(postResp.Body)
|
|
assert.Equal(s.T(), "CSRF token mismatch", bodyData["error"],
|
|
"Error message should indicate token mismatch")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 6: CSRF disabled → no validation, passes through
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestCSRFDisabled_PassesThrough() {
|
|
cfg := middleware.CSRFConfig{
|
|
Enabled: false,
|
|
}
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// POST without any CSRF token should succeed when CSRF is disabled
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
// No cookie, no header — but should still pass through
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, postResp.StatusCode,
|
|
"When CSRF is disabled, POST should pass through without validation")
|
|
|
|
// No CSRF cookie should be set on GET when disabled
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, getResp.StatusCode,
|
|
"When CSRF is disabled, GET should pass through")
|
|
|
|
// Check that no CSRF cookie was set
|
|
for _, c := range getResp.Cookies() {
|
|
assert.NotEqual(s.T(), "_gochat_csrf", c.Name,
|
|
"CSRF cookie should not be set when CSRF is disabled")
|
|
}
|
|
|
|
// Body should not have csrf_token in context
|
|
bodyData := parseJSONBody(getResp.Body)
|
|
assert.Nil(s.T(), bodyData["csrf_token"],
|
|
"csrf_token should not be set in context when CSRF is disabled")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 7: Custom cookie/header names work
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestCustomCookieAndHeaderNames() {
|
|
cfg := middleware.CSRFConfig{
|
|
Enabled: true,
|
|
Secret: "test-secret-for-custom-names",
|
|
CookieName: "my_custom_csrf",
|
|
HeaderName: "X-Custom-CSRF",
|
|
TokenLength: 32,
|
|
SafeMethods: []string{"GET", "HEAD", "OPTIONS"},
|
|
CookieSecure: false,
|
|
CookieHTTPOnly: false,
|
|
CookieSameSite: "Lax",
|
|
CookiePath: "/api",
|
|
ExpirySeconds: 7200,
|
|
}
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Step 1: GET should set a cookie with the custom name
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, getResp.StatusCode)
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "my_custom_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie, "Custom cookie name 'my_custom_csrf' should be set")
|
|
assert.NotEmpty(s.T(), csrfCookie.Value)
|
|
assert.Equal(s.T(), "/api", csrfCookie.Path,
|
|
"Custom cookie path should be '/api'")
|
|
assert.Equal(s.T(), http.SameSiteLaxMode, csrfCookie.SameSite,
|
|
"Custom SameSite should be Lax")
|
|
assert.Equal(s.T(), 7200, csrfCookie.MaxAge,
|
|
"Custom MaxAge should be 7200 seconds")
|
|
|
|
tokenValue := csrfCookie.Value
|
|
|
|
// Step 2: POST with the custom header name should pass
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.Header.Set("X-Custom-CSRF", tokenValue)
|
|
postReq.AddCookie(csrfCookie)
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, postResp.StatusCode,
|
|
"POST with matching custom header+cookie should pass")
|
|
|
|
// Step 3: POST without custom header (using default X-CSRF-Token instead) → 403
|
|
postReq2, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq2.Header.Set("X-CSRF-Token", tokenValue) // Wrong header name
|
|
postReq2.AddCookie(csrfCookie)
|
|
|
|
postResp2, err := http.DefaultClient.Do(postReq2)
|
|
s.Require().NoError(err)
|
|
defer postResp2.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, postResp2.StatusCode,
|
|
"POST with default header name (instead of custom) should be rejected")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 8: OPTIONS preflight passes (safe method)
|
|
// ============================================================================
|
|
|
|
func (s *CSRFE2ETestSuite) TestOPTIONS_Preflight_PassesAndSetsCookie() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("OPTIONS", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
// OPTIONS is a safe method and should pass through
|
|
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode,
|
|
"OPTIONS preflight should pass through and return 204")
|
|
|
|
// OPTIONS should also set a CSRF cookie (it's a safe method)
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
assert.NotNil(s.T(), csrfCookie,
|
|
"OPTIONS should set a CSRF cookie since it's a safe method")
|
|
if csrfCookie != nil {
|
|
assert.NotEmpty(s.T(), csrfCookie.Value,
|
|
"CSRF cookie from OPTIONS should have a non-empty value")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Additional edge-case tests
|
|
// ============================================================================
|
|
|
|
// Test HEAD (another safe method) sets cookie
|
|
func (s *CSRFE2ETestSuite) TestHEAD_SafeMethod_SetsCookie() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("HEAD", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode,
|
|
"HEAD should pass through as a safe method")
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
assert.NotNil(s.T(), csrfCookie,
|
|
"HEAD should set a CSRF cookie since it's a safe method")
|
|
}
|
|
|
|
// Test PUT (unsafe method) requires CSRF validation
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_PUT_NoToken_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("PUT", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, resp.StatusCode,
|
|
"PUT without CSRF token should be rejected with 403")
|
|
}
|
|
|
|
// Test DELETE (unsafe method) requires CSRF validation
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_DELETE_NoToken_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("DELETE", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, resp.StatusCode,
|
|
"DELETE without CSRF token should be rejected with 403")
|
|
}
|
|
|
|
// Test PATCH (unsafe method) requires CSRF validation
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_PATCH_NoToken_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("PATCH", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
s.Require().NoError(err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, resp.StatusCode,
|
|
"PATCH without CSRF token should be rejected with 403")
|
|
}
|
|
|
|
// Test case-insensitive matching: header value uses different case than cookie
|
|
func (s *CSRFE2ETestSuite) TestUnsafeMethod_POST_CaseInsensitiveMatch_Passes() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Step 1: Get CSRF cookie via GET
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie)
|
|
|
|
// The middleware uses strings.EqualFold for comparison.
|
|
// Since tokens are hex strings (lowercase by default), we test with uppercase conversion.
|
|
tokenUpper := strings.ToUpper(csrfCookie.Value)
|
|
if tokenUpper == csrfCookie.Value {
|
|
// Token was already uppercase; skip this test (unlikely for hex tokens)
|
|
s.T().Log("Token was already uppercase, case-insensitive test is trivially passing")
|
|
}
|
|
|
|
// Step 2: POST with uppercase header matching lowercase cookie (EqualFold)
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.Header.Set("X-CSRF-Token", tokenUpper)
|
|
postReq.AddCookie(csrfCookie)
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
// strings.EqualFold should match regardless of case
|
|
assert.Equal(s.T(), http.StatusOK, postResp.StatusCode,
|
|
"POST with case-different but EqualFold-matching tokens should pass")
|
|
}
|
|
|
|
// Test that the full double-submit flow works end-to-end:
|
|
// GET → extract cookie → POST with header = cookie value → success
|
|
func (s *CSRFE2ETestSuite) TestFullDoubleSubmitFlow() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Phase 1: Client fetches CSRF token via GET
|
|
getResp, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer getResp.Body.Close()
|
|
s.Require().Equal(http.StatusOK, getResp.StatusCode)
|
|
|
|
var csrfCookie *http.Cookie
|
|
for _, c := range getResp.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
csrfCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(csrfCookie)
|
|
|
|
// Phase 2: Client echoes cookie value in header for the state-changing request
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.Header.Set("X-CSRF-Token", csrfCookie.Value)
|
|
postReq.AddCookie(csrfCookie)
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusOK, postResp.StatusCode,
|
|
"Full double-submit flow: GET→cookie→POST(header=cookie) should succeed")
|
|
|
|
bodyData := parseJSONBody(postResp.Body)
|
|
assert.Equal(s.T(), "ok", bodyData["status"],
|
|
"POST handler should return status=ok when CSRF validated")
|
|
}
|
|
|
|
// Test that each GET request generates a NEW token (no caching/reuse of old tokens)
|
|
func (s *CSRFE2ETestSuite) TestGET_GeneratesNewTokenEachRequest() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// First GET
|
|
resp1, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer resp1.Body.Close()
|
|
|
|
var cookie1 *http.Cookie
|
|
for _, c := range resp1.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
cookie1 = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(cookie1)
|
|
|
|
// Second GET
|
|
resp2, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer resp2.Body.Close()
|
|
|
|
var cookie2 *http.Cookie
|
|
for _, c := range resp2.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
cookie2 = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(cookie2)
|
|
|
|
// Tokens should differ (random generation)
|
|
assert.NotEqual(s.T(), cookie1.Value, cookie2.Value,
|
|
"Each GET request should generate a fresh CSRF token")
|
|
}
|
|
|
|
// Test that an old cookie token cannot be used after a new GET refreshes it
|
|
func (s *CSRFE2ETestSuite) TestStaleToken_AfterRefresh_Returns403() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = false
|
|
|
|
server := s.buildServer(cfg)
|
|
defer server.Close()
|
|
|
|
// Phase 1: Get first token
|
|
resp1, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer resp1.Body.Close()
|
|
|
|
var oldCookie *http.Cookie
|
|
for _, c := range resp1.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
oldCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(oldCookie)
|
|
oldToken := oldCookie.Value
|
|
|
|
// Phase 2: Get a new token (refresh)
|
|
resp2, err := http.Get(server.URL + "/api/test")
|
|
s.Require().NoError(err)
|
|
defer resp2.Body.Close()
|
|
|
|
var newCookie *http.Cookie
|
|
for _, c := range resp2.Cookies() {
|
|
if c.Name == "_gochat_csrf" {
|
|
newCookie = c
|
|
break
|
|
}
|
|
}
|
|
s.Require().NotNil(newCookie)
|
|
|
|
// Phase 3: POST with old cookie + old header (both stale)
|
|
// The server uses the cookie from the current request, so if we send
|
|
// the old cookie value, it will still match the old header. But if
|
|
// we send the new cookie + old header, it should fail.
|
|
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
|
s.Require().NoError(err)
|
|
postReq.AddCookie(newCookie) // New cookie
|
|
postReq.Header.Set("X-CSRF-Token", oldToken) // Old header value
|
|
|
|
postResp, err := http.DefaultClient.Do(postReq)
|
|
s.Require().NoError(err)
|
|
defer postResp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, postResp.StatusCode,
|
|
"POST with mismatched tokens (new cookie, old header) should be rejected")
|
|
}
|
|
|
|
// Test CookieSecure flag is set when configured
|
|
func (s *CSRFE2ETestSuite) TestCookieSecureFlag() {
|
|
cfg := middleware.DefaultCSRFConfig()
|
|
cfg.Enabled = true
|
|
cfg.CookieSecure = true // Secure flag enabled
|
|
|
|
// Use httptest.Server directly on the router (no TLS) to check cookie flags
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
r.Use(middleware.CSRF(cfg))
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
token, _ := c.Get("csrf_token")
|
|
c.JSON(200, gin.H{"csrf_token": token})
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/test", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
// Parse Set-Cookie header to verify Secure flag
|
|
setCookieHeader := w.Header().Get("Set-Cookie")
|
|
assert.Contains(s.T(), setCookieHeader, "Secure",
|
|
"Cookie should have Secure flag when CookieSecure=true")
|
|
}
|
|
|
|
// Test CookieHTTPOnly flag is set when configured
|
|
func (s *CSRFE2ETestSuite) TestCookieHTTPOnlyFlag() {
|
|
cfg := middleware.CSRFConfig{
|
|
Enabled: true,
|
|
Secret: "test-httponly-secret",
|
|
CookieName: "_gochat_csrf_httponly",
|
|
HeaderName: "X-CSRF-Token",
|
|
TokenLength: 32,
|
|
SafeMethods: []string{"GET", "HEAD", "OPTIONS"},
|
|
CookieSecure: false,
|
|
CookieHTTPOnly: true, // HttpOnly enabled (unusual for double-submit, but configurable)
|
|
CookieSameSite: "Strict",
|
|
CookiePath: "/",
|
|
ExpirySeconds: 3600,
|
|
}
|
|
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
r.Use(middleware.CSRF(cfg))
|
|
r.GET("/api/test", func(c *gin.Context) {
|
|
token, _ := c.Get("csrf_token")
|
|
c.JSON(200, gin.H{"csrf_token": token})
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/test", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
setCookieHeader := w.Header().Get("Set-Cookie")
|
|
assert.Contains(s.T(), setCookieHeader, "HttpOnly",
|
|
"Cookie should have HttpOnly flag when CookieHTTPOnly=true")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test Suite Runner
|
|
// ============================================================================
|
|
|
|
func TestCSRFE2ETestSuite(t *testing.T) {
|
|
suite.Run(t, new(CSRFE2ETestSuite))
|
|
} |