security_headers_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. package middleware
  2. import (
  3. "encoding/base64"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/assert"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func init() {
  14. gin.SetMode(gin.TestMode)
  15. }
  16. func TestGenerateNonce(t *testing.T) {
  17. t.Run("generates_valid_base64_string", func(t *testing.T) {
  18. nonce, err := GenerateNonce()
  19. require.NoError(t, err)
  20. // Should be valid base64
  21. decoded, err := base64.StdEncoding.DecodeString(nonce)
  22. require.NoError(t, err)
  23. // Should decode to 16 bytes
  24. assert.Len(t, decoded, 16)
  25. })
  26. t.Run("generates_unique_nonces", func(t *testing.T) {
  27. nonces := make(map[string]bool)
  28. for i := 0; i < 100; i++ {
  29. nonce, err := GenerateNonce()
  30. require.NoError(t, err)
  31. assert.False(t, nonces[nonce], "nonce should be unique")
  32. nonces[nonce] = true
  33. }
  34. })
  35. t.Run("nonce_has_expected_length", func(t *testing.T) {
  36. nonce, err := GenerateNonce()
  37. require.NoError(t, err)
  38. // 16 bytes -> 24 chars in base64 (with padding)
  39. assert.Len(t, nonce, 24)
  40. })
  41. }
  42. func TestGetNonceFromContext(t *testing.T) {
  43. t.Run("returns_nonce_when_present", func(t *testing.T) {
  44. w := httptest.NewRecorder()
  45. c, _ := gin.CreateTestContext(w)
  46. expectedNonce := "test-nonce-123"
  47. c.Set(CSPNonceKey, expectedNonce)
  48. nonce := GetNonceFromContext(c)
  49. assert.Equal(t, expectedNonce, nonce)
  50. })
  51. t.Run("returns_empty_string_when_not_present", func(t *testing.T) {
  52. w := httptest.NewRecorder()
  53. c, _ := gin.CreateTestContext(w)
  54. nonce := GetNonceFromContext(c)
  55. assert.Empty(t, nonce)
  56. })
  57. t.Run("returns_empty_for_wrong_type", func(t *testing.T) {
  58. w := httptest.NewRecorder()
  59. c, _ := gin.CreateTestContext(w)
  60. // Set a non-string value
  61. c.Set(CSPNonceKey, 12345)
  62. // Should return empty string for wrong type (safe type assertion)
  63. nonce := GetNonceFromContext(c)
  64. assert.Empty(t, nonce)
  65. })
  66. }
  67. func TestSecurityHeaders(t *testing.T) {
  68. t.Run("sets_basic_security_headers", func(t *testing.T) {
  69. cfg := config.CSPConfig{Enabled: false}
  70. middleware := SecurityHeaders(cfg, nil)
  71. w := httptest.NewRecorder()
  72. c, _ := gin.CreateTestContext(w)
  73. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  74. middleware(c)
  75. assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
  76. assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
  77. assert.Equal(t, "strict-origin-when-cross-origin", w.Header().Get("Referrer-Policy"))
  78. })
  79. t.Run("csp_disabled_no_csp_header", func(t *testing.T) {
  80. cfg := config.CSPConfig{Enabled: false}
  81. middleware := SecurityHeaders(cfg, nil)
  82. w := httptest.NewRecorder()
  83. c, _ := gin.CreateTestContext(w)
  84. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  85. middleware(c)
  86. assert.Empty(t, w.Header().Get("Content-Security-Policy"))
  87. })
  88. t.Run("csp_enabled_sets_csp_header", func(t *testing.T) {
  89. cfg := config.CSPConfig{
  90. Enabled: true,
  91. Policy: "default-src 'self'",
  92. }
  93. middleware := SecurityHeaders(cfg, nil)
  94. w := httptest.NewRecorder()
  95. c, _ := gin.CreateTestContext(w)
  96. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  97. middleware(c)
  98. csp := w.Header().Get("Content-Security-Policy")
  99. assert.NotEmpty(t, csp)
  100. // Policy is auto-enhanced with nonce and Cloudflare Insights domain
  101. assert.Contains(t, csp, "default-src 'self'")
  102. assert.Contains(t, csp, "'nonce-")
  103. assert.Contains(t, csp, CloudflareInsightsDomain)
  104. assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
  105. })
  106. t.Run("old_custom_policy_dynamically_allows_same_origin_frames", func(t *testing.T) {
  107. cfg := config.CSPConfig{
  108. Enabled: true,
  109. Policy: "default-src 'self'; frame-src https://checkout.example.com",
  110. }
  111. middleware := SecurityHeaders(cfg, nil)
  112. w := httptest.NewRecorder()
  113. c, _ := gin.CreateTestContext(w)
  114. c.Request = httptest.NewRequest(http.MethodGet, "/admin/plugins", nil)
  115. middleware(c)
  116. csp := w.Header().Get("Content-Security-Policy")
  117. assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "'self'"))
  118. assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "https://checkout.example.com"))
  119. assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
  120. })
  121. t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
  122. cfg := config.CSPConfig{
  123. Enabled: true,
  124. Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__",
  125. }
  126. middleware := SecurityHeaders(cfg, nil)
  127. w := httptest.NewRecorder()
  128. c, _ := gin.CreateTestContext(w)
  129. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  130. middleware(c)
  131. assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
  132. assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
  133. assert.Equal(t, "strict-origin-when-cross-origin", w.Header().Get("Referrer-Policy"))
  134. assert.Empty(t, w.Header().Get("Content-Security-Policy"))
  135. assert.Empty(t, GetNonceFromContext(c))
  136. })
  137. t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) {
  138. cfg := config.CSPConfig{
  139. Enabled: true,
  140. Policy: "script-src 'self' __CSP_NONCE__",
  141. }
  142. middleware := SecurityHeaders(cfg, nil)
  143. w := httptest.NewRecorder()
  144. c, _ := gin.CreateTestContext(w)
  145. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  146. middleware(c)
  147. csp := w.Header().Get("Content-Security-Policy")
  148. assert.NotEmpty(t, csp)
  149. assert.NotContains(t, csp, "__CSP_NONCE__", "placeholder should be replaced")
  150. assert.Contains(t, csp, "'nonce-", "should contain nonce directive")
  151. // Verify nonce is stored in context
  152. nonce := GetNonceFromContext(c)
  153. assert.NotEmpty(t, nonce)
  154. assert.Contains(t, csp, "'nonce-"+nonce+"'")
  155. })
  156. t.Run("uses_default_policy_when_empty", func(t *testing.T) {
  157. cfg := config.CSPConfig{
  158. Enabled: true,
  159. Policy: "",
  160. }
  161. middleware := SecurityHeaders(cfg, nil)
  162. w := httptest.NewRecorder()
  163. c, _ := gin.CreateTestContext(w)
  164. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  165. middleware(c)
  166. csp := w.Header().Get("Content-Security-Policy")
  167. assert.NotEmpty(t, csp)
  168. // Default policy should contain these elements
  169. assert.Contains(t, csp, "default-src 'self'")
  170. assert.Contains(t, csp, TencentCaptchaDomain)
  171. })
  172. t.Run("uses_default_policy_when_whitespace_only", func(t *testing.T) {
  173. cfg := config.CSPConfig{
  174. Enabled: true,
  175. Policy: " \t\n ",
  176. }
  177. middleware := SecurityHeaders(cfg, nil)
  178. w := httptest.NewRecorder()
  179. c, _ := gin.CreateTestContext(w)
  180. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  181. middleware(c)
  182. csp := w.Header().Get("Content-Security-Policy")
  183. assert.NotEmpty(t, csp)
  184. assert.Contains(t, csp, "default-src 'self'")
  185. })
  186. t.Run("multiple_nonce_placeholders_replaced", func(t *testing.T) {
  187. cfg := config.CSPConfig{
  188. Enabled: true,
  189. Policy: "script-src __CSP_NONCE__; style-src __CSP_NONCE__",
  190. }
  191. middleware := SecurityHeaders(cfg, nil)
  192. w := httptest.NewRecorder()
  193. c, _ := gin.CreateTestContext(w)
  194. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  195. middleware(c)
  196. csp := w.Header().Get("Content-Security-Policy")
  197. nonce := GetNonceFromContext(c)
  198. // Count occurrences of the nonce
  199. count := strings.Count(csp, "'nonce-"+nonce+"'")
  200. assert.Equal(t, 2, count, "both placeholders should be replaced with same nonce")
  201. })
  202. t.Run("calls_next_handler", func(t *testing.T) {
  203. cfg := config.CSPConfig{Enabled: true, Policy: "default-src 'self'"}
  204. middleware := SecurityHeaders(cfg, nil)
  205. nextCalled := false
  206. router := gin.New()
  207. router.Use(middleware)
  208. router.GET("/test", func(c *gin.Context) {
  209. nextCalled = true
  210. c.Status(http.StatusOK)
  211. })
  212. w := httptest.NewRecorder()
  213. req := httptest.NewRequest(http.MethodGet, "/test", nil)
  214. router.ServeHTTP(w, req)
  215. assert.True(t, nextCalled, "next handler should be called")
  216. assert.Equal(t, http.StatusOK, w.Code)
  217. })
  218. t.Run("nonce_unique_per_request", func(t *testing.T) {
  219. cfg := config.CSPConfig{
  220. Enabled: true,
  221. Policy: "script-src __CSP_NONCE__",
  222. }
  223. middleware := SecurityHeaders(cfg, nil)
  224. nonces := make(map[string]bool)
  225. for i := 0; i < 10; i++ {
  226. w := httptest.NewRecorder()
  227. c, _ := gin.CreateTestContext(w)
  228. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  229. middleware(c)
  230. nonce := GetNonceFromContext(c)
  231. assert.False(t, nonces[nonce], "nonce should be unique per request")
  232. nonces[nonce] = true
  233. }
  234. })
  235. }
  236. func TestCSPNonceKey(t *testing.T) {
  237. t.Run("constant_value", func(t *testing.T) {
  238. assert.Equal(t, "csp_nonce", CSPNonceKey)
  239. })
  240. }
  241. func TestNonceTemplate(t *testing.T) {
  242. t.Run("constant_value", func(t *testing.T) {
  243. assert.Equal(t, "__CSP_NONCE__", NonceTemplate)
  244. })
  245. }
  246. func TestEnhanceCSPPolicy(t *testing.T) {
  247. t.Run("adds_nonce_placeholder_if_missing", func(t *testing.T) {
  248. policy := "default-src 'self'; script-src 'self'"
  249. enhanced := enhanceCSPPolicy(policy)
  250. assert.Contains(t, enhanced, NonceTemplate)
  251. assert.Contains(t, enhanced, CloudflareInsightsDomain)
  252. })
  253. t.Run("allows_only_same_origin_plugin_frames", func(t *testing.T) {
  254. policy := "default-src 'self'; frame-src https://checkout.example.com"
  255. enhanced := enhanceCSPPolicy(policy)
  256. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
  257. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "https://checkout.example.com"))
  258. assert.NotContains(t, enhanced, "frame-src *")
  259. })
  260. t.Run("adds_same_origin_frame_source_once_when_directive_is_missing", func(t *testing.T) {
  261. enhanced := enhanceCSPPolicy("default-src 'self'; script-src 'self'")
  262. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'"))
  263. })
  264. t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) {
  265. policy := "default-src 'self'; script-src 'self' __CSP_NONCE__"
  266. enhanced := enhanceCSPPolicy(policy)
  267. // Should not duplicate
  268. count := strings.Count(enhanced, NonceTemplate)
  269. assert.Equal(t, 1, count)
  270. })
  271. t.Run("does_not_duplicate_cloudflare_domain", func(t *testing.T) {
  272. policy := "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com"
  273. enhanced := enhanceCSPPolicy(policy)
  274. count := strings.Count(enhanced, CloudflareInsightsDomain)
  275. assert.Equal(t, 1, count)
  276. })
  277. t.Run("adds_tencent_captcha_domain_for_web_sdk", func(t *testing.T) {
  278. policy := "default-src 'self'; script-src 'self' __CSP_NONCE__"
  279. enhanced := enhanceCSPPolicy(policy)
  280. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaDomain))
  281. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain))
  282. assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain))
  283. assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com")
  284. // 入口脚本会再从 CDN 拉核心 JS,国际站还会换用 ca./global. 两个主机;
  285. // 缺任意一个都会让天御 SDK 触发 script-src 拦截。
  286. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaCDNDomain))
  287. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain))
  288. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain))
  289. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain))
  290. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain))
  291. assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain))
  292. assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain))
  293. assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain))
  294. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain))
  295. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain))
  296. assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
  297. })
  298. t.Run("does_not_duplicate_tencent_captcha_worker_source", func(t *testing.T) {
  299. policy := "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__"
  300. enhanced := enhanceCSPPolicy(policy)
  301. assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
  302. })
  303. t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) {
  304. // 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户
  305. for _, required := range requiredCSPDirectiveValues {
  306. assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value),
  307. "DefaultCSPPolicy 缺少 %s %s", required.directive, required.value)
  308. }
  309. })
  310. t.Run("handles_policy_without_script_src", func(t *testing.T) {
  311. policy := "default-src 'self'"
  312. enhanced := enhanceCSPPolicy(policy)
  313. assert.Contains(t, enhanced, "script-src")
  314. assert.Contains(t, enhanced, NonceTemplate)
  315. assert.Contains(t, enhanced, CloudflareInsightsDomain)
  316. })
  317. t.Run("preserves_existing_nonce", func(t *testing.T) {
  318. policy := "script-src 'self' 'nonce-existing'"
  319. enhanced := enhanceCSPPolicy(policy)
  320. // Should not add placeholder if nonce already exists
  321. assert.NotContains(t, enhanced, NonceTemplate)
  322. assert.Contains(t, enhanced, "'nonce-existing'")
  323. })
  324. t.Run("adds_airwallex_domains_for_payment_sdk", func(t *testing.T) {
  325. policy := "default-src 'self'; script-src 'self' __CSP_NONCE__; style-src 'self'; frame-src 'self'"
  326. enhanced := enhanceCSPPolicy(policy)
  327. assert.Contains(t, enhanced, "script-src 'self' __CSP_NONCE__")
  328. assert.Contains(t, enhanced, AirwallexStaticDomain)
  329. assert.Contains(t, enhanced, AirwallexCheckoutDomain)
  330. assert.Contains(t, enhanced, AirwallexDemoStaticDomain)
  331. assert.Contains(t, enhanced, AirwallexDemoCheckoutDomain)
  332. assert.Contains(t, enhanced, "style-src 'self'")
  333. assert.Contains(t, enhanced, "frame-src 'self'")
  334. })
  335. t.Run("does_not_duplicate_airwallex_domains", func(t *testing.T) {
  336. policy := "default-src 'self'; script-src 'self' https://static.airwallex.com https://static-demo.airwallex.com; frame-src https://checkout.airwallex.com https://checkout-demo.airwallex.com"
  337. enhanced := enhanceCSPPolicy(policy)
  338. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexStaticDomain))
  339. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexCheckoutDomain))
  340. assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexStaticDomain))
  341. assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexCheckoutDomain))
  342. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", AirwallexCheckoutDomain))
  343. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexDemoStaticDomain))
  344. assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", AirwallexDemoCheckoutDomain))
  345. assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexDemoStaticDomain))
  346. assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", AirwallexDemoCheckoutDomain))
  347. assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", AirwallexDemoCheckoutDomain))
  348. })
  349. }
  350. func countDirectiveValue(policy, directive, value string) int {
  351. for _, rawDirective := range strings.Split(policy, ";") {
  352. fields := strings.Fields(strings.TrimSpace(rawDirective))
  353. if len(fields) == 0 || fields[0] != directive {
  354. continue
  355. }
  356. count := 0
  357. for _, field := range fields[1:] {
  358. if field == value {
  359. count++
  360. }
  361. }
  362. return count
  363. }
  364. return 0
  365. }
  366. func TestAddToDirective(t *testing.T) {
  367. t.Run("adds_to_existing_directive", func(t *testing.T) {
  368. policy := "script-src 'self'; style-src 'self'"
  369. result := addToDirective(policy, "script-src", "https://example.com")
  370. assert.Contains(t, result, "script-src 'self' https://example.com")
  371. })
  372. t.Run("creates_directive_if_not_exists", func(t *testing.T) {
  373. policy := "default-src 'self'"
  374. result := addToDirective(policy, "script-src", "https://example.com")
  375. assert.Contains(t, result, "script-src")
  376. assert.Contains(t, result, "https://example.com")
  377. })
  378. t.Run("handles_directive_at_end_without_semicolon", func(t *testing.T) {
  379. policy := "default-src 'self'; script-src 'self'"
  380. result := addToDirective(policy, "script-src", "https://example.com")
  381. assert.Contains(t, result, "https://example.com")
  382. })
  383. t.Run("handles_empty_policy", func(t *testing.T) {
  384. policy := ""
  385. result := addToDirective(policy, "script-src", "https://example.com")
  386. assert.Contains(t, result, "script-src")
  387. assert.Contains(t, result, "https://example.com")
  388. })
  389. t.Run("does_not_match_a_directive_name_suffix", func(t *testing.T) {
  390. policy := "default-src 'none'; child-frame-src https://legacy.example.com"
  391. result := addToDirective(policy, "frame-src", "'self'")
  392. assert.Equal(t, 1, countDirectiveValue(result, "frame-src", "'self'"))
  393. assert.Equal(t, 0, countDirectiveValue(result, "child-frame-src", "'self'"))
  394. assert.Equal(t, 1, countDirectiveValue(result, "child-frame-src", "https://legacy.example.com"))
  395. })
  396. }
  397. // Benchmark tests
  398. func BenchmarkGenerateNonce(b *testing.B) {
  399. for i := 0; i < b.N; i++ {
  400. _, _ = GenerateNonce()
  401. }
  402. }
  403. func BenchmarkSecurityHeadersMiddleware(b *testing.B) {
  404. cfg := config.CSPConfig{
  405. Enabled: true,
  406. Policy: "script-src 'self' __CSP_NONCE__",
  407. }
  408. middleware := SecurityHeaders(cfg, nil)
  409. b.ResetTimer()
  410. for i := 0; i < b.N; i++ {
  411. w := httptest.NewRecorder()
  412. c, _ := gin.CreateTestContext(w)
  413. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  414. middleware(c)
  415. }
  416. }