1
0

security_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. package middleware
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "github.com/mhsanaei/3x-ui/v3/internal/web/session"
  8. "github.com/gin-contrib/sessions"
  9. "github.com/gin-contrib/sessions/cookie"
  10. "github.com/gin-gonic/gin"
  11. )
  12. func TestCSRFMiddlewareAllowsSafeMethods(t *testing.T) {
  13. gin.SetMode(gin.TestMode)
  14. router := gin.New()
  15. router.Use(CSRFMiddleware())
  16. router.GET("/safe", func(c *gin.Context) {
  17. c.String(http.StatusOK, "ok")
  18. })
  19. rec := httptest.NewRecorder()
  20. req := httptest.NewRequest(http.MethodGet, "/safe", nil)
  21. router.ServeHTTP(rec, req)
  22. if rec.Code != http.StatusOK {
  23. t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
  24. }
  25. }
  26. func TestCSRFMiddlewareRejectsMissingTokenAndAcceptsValidToken(t *testing.T) {
  27. gin.SetMode(gin.TestMode)
  28. router := gin.New()
  29. store := cookie.NewStore([]byte("01234567890123456789012345678901"))
  30. router.Use(sessions.Sessions("3x-ui", store))
  31. router.GET("/token", func(c *gin.Context) {
  32. token, err := session.EnsureCSRFToken(c)
  33. if err != nil {
  34. t.Fatal(err)
  35. }
  36. c.String(http.StatusOK, token)
  37. })
  38. router.POST("/submit", CSRFMiddleware(), func(c *gin.Context) {
  39. c.String(http.StatusOK, "ok")
  40. })
  41. tokenRec := httptest.NewRecorder()
  42. tokenReq := httptest.NewRequest(http.MethodGet, "/token", nil)
  43. router.ServeHTTP(tokenRec, tokenReq)
  44. if tokenRec.Code != http.StatusOK {
  45. t.Fatalf("token status = %d, want %d", tokenRec.Code, http.StatusOK)
  46. }
  47. cookies := tokenRec.Result().Cookies()
  48. token := tokenRec.Body.String()
  49. missingRec := httptest.NewRecorder()
  50. missingReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
  51. for _, cookie := range cookies {
  52. missingReq.AddCookie(cookie)
  53. }
  54. router.ServeHTTP(missingRec, missingReq)
  55. if missingRec.Code != http.StatusForbidden {
  56. t.Fatalf("missing token status = %d, want %d", missingRec.Code, http.StatusForbidden)
  57. }
  58. validRec := httptest.NewRecorder()
  59. validReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
  60. for _, cookie := range cookies {
  61. validReq.AddCookie(cookie)
  62. }
  63. validReq.Header.Set(session.CSRFHeaderName, token)
  64. router.ServeHTTP(validRec, validReq)
  65. if validRec.Code != http.StatusOK {
  66. t.Fatalf("valid token status = %d, want %d", validRec.Code, http.StatusOK)
  67. }
  68. }
  69. func TestSecurityHeadersMiddleware(t *testing.T) {
  70. gin.SetMode(gin.TestMode)
  71. router := gin.New()
  72. router.Use(SecurityHeadersMiddleware(true))
  73. var capturedNonce string
  74. router.GET("/", func(c *gin.Context) {
  75. capturedNonce = c.GetString("csp_nonce")
  76. c.String(http.StatusOK, "ok")
  77. })
  78. rec := httptest.NewRecorder()
  79. req := httptest.NewRequest(http.MethodGet, "/", nil)
  80. router.ServeHTTP(rec, req)
  81. headers := rec.Result().Header
  82. if got := headers.Get("X-Content-Type-Options"); got != "nosniff" {
  83. t.Fatalf("X-Content-Type-Options = %q", got)
  84. }
  85. if got := headers.Get("X-Frame-Options"); got != "DENY" {
  86. t.Fatalf("X-Frame-Options = %q", got)
  87. }
  88. if got := headers.Get("Referrer-Policy"); got != "no-referrer" {
  89. t.Fatalf("Referrer-Policy = %q", got)
  90. }
  91. if got := headers.Get("Strict-Transport-Security"); got == "" {
  92. t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
  93. }
  94. // CSP is the highest-value header here: assert it stays nonce-bound with its hardening
  95. // directives, so weakening it (unsafe-inline, dropped frame-ancestors, broken nonce) fails.
  96. csp := headers.Get("Content-Security-Policy")
  97. if csp == "" {
  98. t.Fatal("Content-Security-Policy header must be set")
  99. }
  100. if capturedNonce == "" {
  101. t.Fatal("csp_nonce context value must be set (the injected inline script reads it)")
  102. }
  103. if want := "script-src 'self' 'nonce-" + capturedNonce + "'"; !strings.Contains(csp, want) {
  104. t.Fatalf("CSP script-src must be bound to the per-request nonce %q; got %q", want, csp)
  105. }
  106. for _, directive := range []string{"object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"} {
  107. if !strings.Contains(csp, directive) {
  108. t.Errorf("CSP missing hardening directive %q; got %q", directive, csp)
  109. }
  110. }
  111. // script-src must NOT allow 'unsafe-inline' (it would defeat the nonce). Check the
  112. // script-src directive in isolation, since style-src legitimately uses unsafe-inline.
  113. scriptDir := csp[strings.Index(csp, "script-src"):]
  114. if i := strings.Index(scriptDir, ";"); i >= 0 {
  115. scriptDir = scriptDir[:i]
  116. }
  117. if strings.Contains(scriptDir, "unsafe-inline") {
  118. t.Errorf("CSP script-src must not allow 'unsafe-inline': %q", scriptDir)
  119. }
  120. }
  121. func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
  122. gin.SetMode(gin.TestMode)
  123. router := gin.New()
  124. router.Use(SecurityHeadersMiddleware(false))
  125. router.GET("/", func(c *gin.Context) {
  126. c.String(http.StatusOK, "ok")
  127. })
  128. rec := httptest.NewRecorder()
  129. req := httptest.NewRequest(http.MethodGet, "/", nil)
  130. router.ServeHTTP(rec, req)
  131. if got := rec.Result().Header.Get("Strict-Transport-Security"); got != "" {
  132. t.Fatalf("Strict-Transport-Security = %q, want empty", got)
  133. }
  134. }