security_headers.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package middleware
  2. import (
  3. "crypto/rand"
  4. "encoding/base64"
  5. "fmt"
  6. "log"
  7. "strings"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/gin-gonic/gin"
  10. )
  11. const (
  12. // CSPNonceKey is the context key for storing the CSP nonce
  13. CSPNonceKey = "csp_nonce"
  14. // NonceTemplate is the placeholder in CSP policy for nonce
  15. NonceTemplate = "__CSP_NONCE__"
  16. // CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
  17. CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
  18. // TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain (Chinese mainland site).
  19. TencentCaptchaDomain = "https://turing.captcha.qcloud.com"
  20. // TencentCaptchaStaticDomain is the Tencent Captcha static asset domain.
  21. TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com"
  22. // TencentCaptchaCDNDomain 是天御国内站的核心 JS CDN 主机:
  23. // 入口脚本 TJCaptcha.js 会再从这里加载 /1/tgJCap.*.js,缺失时会被 script-src 拦截。
  24. TencentCaptchaCDNDomain = "https://turing.captcha.gtimg.com"
  25. // TencentCaptchaGlobalDomain 是天御国际站的 Web SDK 与验证弹窗 iframe 主机。
  26. TencentCaptchaGlobalDomain = "https://ca.turing.captcha.qcloud.com"
  27. // TencentCaptchaGlobalCDNDomain 是天御国际站的核心 JS CDN 主机。
  28. TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com"
  29. // TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。
  30. TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com"
  31. // TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。
  32. TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com"
  33. // TencentCaptchaRceDomain 是国际站风控校验接口主机。
  34. TencentCaptchaRceDomain = "https://rce.tencentrio.com"
  35. // TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。
  36. TencentCaptchaWorkerSource = "blob:"
  37. // StripeDomain is the domain for Stripe.js SDK
  38. StripeDomain = "https://*.stripe.com"
  39. // AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。
  40. AirwallexStaticDomain = "https://static.airwallex.com"
  41. // AirwallexCheckoutDomain 是 Airwallex 生产环境收银台元素和 iframe 域名。
  42. AirwallexCheckoutDomain = "https://checkout.airwallex.com"
  43. // AirwallexDemoStaticDomain 是 Airwallex 沙箱环境 SDK 脚本域名。
  44. AirwallexDemoStaticDomain = "https://static-demo.airwallex.com"
  45. // AirwallexDemoCheckoutDomain 是 Airwallex 沙箱环境收银台元素和 iframe 域名。
  46. AirwallexDemoCheckoutDomain = "https://checkout-demo.airwallex.com"
  47. )
  48. var requiredCSPDirectiveValues = []struct {
  49. directive string
  50. value string
  51. }{
  52. // 插件配置 UI 使用同源 iframe;目标响应仍必须显式放开 X-Frame-Options,
  53. // 因此这里只允许 'self' 不会使其他默认 DENY 的管理/API 页面可被嵌入。
  54. {"frame-src", "'self'"},
  55. {"script-src", CloudflareInsightsDomain},
  56. {"script-src", TencentCaptchaDomain},
  57. {"frame-src", TencentCaptchaDomain},
  58. {"style-src", TencentCaptchaStaticDomain},
  59. {"script-src", TencentCaptchaCDNDomain},
  60. {"script-src", TencentCaptchaGlobalDomain},
  61. {"script-src", TencentCaptchaGlobalCDNDomain},
  62. {"script-src", TencentCaptchaPrehandleDomain},
  63. {"script-src", TencentCaptchaJQueryDomain},
  64. {"connect-src", TencentCaptchaDomain},
  65. {"connect-src", TencentCaptchaPrehandleDomain},
  66. {"connect-src", TencentCaptchaRceDomain},
  67. {"frame-src", TencentCaptchaGlobalDomain},
  68. {"frame-src", TencentCaptchaPrehandleDomain},
  69. {"worker-src", TencentCaptchaWorkerSource},
  70. {"script-src", StripeDomain},
  71. {"frame-src", StripeDomain},
  72. {"script-src", AirwallexStaticDomain},
  73. {"script-src", AirwallexCheckoutDomain},
  74. {"style-src", AirwallexStaticDomain},
  75. {"style-src", AirwallexCheckoutDomain},
  76. {"frame-src", AirwallexCheckoutDomain},
  77. {"script-src", AirwallexDemoStaticDomain},
  78. {"script-src", AirwallexDemoCheckoutDomain},
  79. {"style-src", AirwallexDemoStaticDomain},
  80. {"style-src", AirwallexDemoCheckoutDomain},
  81. {"frame-src", AirwallexDemoCheckoutDomain},
  82. }
  83. // GenerateNonce generates a cryptographically secure random nonce.
  84. // 返回 error 以确保调用方在 crypto/rand 失败时能正确降级。
  85. func GenerateNonce() (string, error) {
  86. b := make([]byte, 16)
  87. if _, err := rand.Read(b); err != nil {
  88. return "", fmt.Errorf("generate CSP nonce: %w", err)
  89. }
  90. return base64.StdEncoding.EncodeToString(b), nil
  91. }
  92. // GetNonceFromContext retrieves the CSP nonce from gin context
  93. func GetNonceFromContext(c *gin.Context) string {
  94. if nonce, exists := c.Get(CSPNonceKey); exists {
  95. if s, ok := nonce.(string); ok {
  96. return s
  97. }
  98. }
  99. return ""
  100. }
  101. // SecurityHeaders sets baseline security headers for all responses.
  102. // getFrameSrcOrigins is an optional function that returns extra origins to inject into frame-src;
  103. // pass nil to disable dynamic frame-src injection.
  104. func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) gin.HandlerFunc {
  105. policy := strings.TrimSpace(cfg.Policy)
  106. if policy == "" {
  107. policy = config.DefaultCSPPolicy
  108. }
  109. // Enhance policy with required directives (nonce placeholder and Cloudflare Insights)
  110. policy = enhanceCSPPolicy(policy)
  111. return func(c *gin.Context) {
  112. finalPolicy := policy
  113. if getFrameSrcOrigins != nil {
  114. for _, origin := range getFrameSrcOrigins() {
  115. if origin != "" {
  116. finalPolicy = addToDirective(finalPolicy, "frame-src", origin)
  117. }
  118. }
  119. }
  120. c.Header("X-Content-Type-Options", "nosniff")
  121. c.Header("X-Frame-Options", "DENY")
  122. c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
  123. if isAPIRoutePath(c) {
  124. c.Next()
  125. return
  126. }
  127. if cfg.Enabled {
  128. // Generate nonce for this request
  129. nonce, err := GenerateNonce()
  130. if err != nil {
  131. // crypto/rand 失败时降级为无 nonce 的 CSP 策略
  132. log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err)
  133. c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'"))
  134. } else {
  135. c.Set(CSPNonceKey, nonce)
  136. c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'"))
  137. }
  138. }
  139. c.Next()
  140. }
  141. }
  142. func isAPIRoutePath(c *gin.Context) bool {
  143. if c == nil || c.Request == nil || c.Request.URL == nil {
  144. return false
  145. }
  146. path := c.Request.URL.Path
  147. return strings.HasPrefix(path, "/v1/") ||
  148. strings.HasPrefix(path, "/v1beta/") ||
  149. strings.HasPrefix(path, "/antigravity/") ||
  150. strings.HasPrefix(path, "/responses") ||
  151. strings.HasPrefix(path, "/images")
  152. }
  153. // enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和运行时组件必需域名。
  154. // 这样旧配置文件没有及时补域名时,验证码和支付组件仍能正常加载。
  155. func enhanceCSPPolicy(policy string) string {
  156. // Add nonce placeholder to script-src if not present
  157. if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") {
  158. policy = addToDirective(policy, "script-src", NonceTemplate)
  159. }
  160. for _, required := range requiredCSPDirectiveValues {
  161. if !directiveHasValue(policy, required.directive, required.value) {
  162. policy = addToDirective(policy, required.directive, required.value)
  163. }
  164. }
  165. return policy
  166. }
  167. func directiveHasValue(policy, directive, value string) bool {
  168. for _, rawDirective := range strings.Split(policy, ";") {
  169. fields := strings.Fields(strings.TrimSpace(rawDirective))
  170. if len(fields) == 0 || fields[0] != directive {
  171. continue
  172. }
  173. for _, field := range fields[1:] {
  174. if field == value {
  175. return true
  176. }
  177. }
  178. return false
  179. }
  180. return false
  181. }
  182. // addToDirective adds a value to a specific CSP directive.
  183. // If the directive doesn't exist, it will be added after default-src.
  184. func addToDirective(policy, directive, value string) string {
  185. if end, ok := cspDirectiveEnd(policy, directive); ok {
  186. return policy[:end] + " " + value + policy[end:]
  187. }
  188. trimmed := strings.TrimSpace(policy)
  189. if trimmed == "" {
  190. return newCSPDirective(directive, value)
  191. }
  192. if !strings.HasSuffix(trimmed, ";") {
  193. trimmed += ";"
  194. }
  195. return trimmed + " " + newCSPDirective(directive, value)
  196. }
  197. func cspDirectiveEnd(policy, directive string) (int, bool) {
  198. start := 0
  199. for start <= len(policy) {
  200. end := len(policy)
  201. if relativeEnd := strings.IndexByte(policy[start:], ';'); relativeEnd >= 0 {
  202. end = start + relativeEnd
  203. }
  204. fields := strings.Fields(policy[start:end])
  205. if len(fields) > 0 && fields[0] == directive {
  206. return end, true
  207. }
  208. if end == len(policy) {
  209. break
  210. }
  211. start = end + 1
  212. }
  213. return 0, false
  214. }
  215. func newCSPDirective(directive, value string) string {
  216. if value == "'self'" {
  217. return directive + " 'self';"
  218. }
  219. return directive + " 'self' " + value + ";"
  220. }