feat(安全): 实现 CSP nonce 支持解决内联脚本安全问题

- 添加 GenerateNonce() 生成加密安全的随机 nonce
- SecurityHeaders 中间件为每个请求生成唯一 nonce
- CSP 策略支持 __CSP_NONCE__ 占位符动态替换
- embed_on.go 注入的内联脚本添加 nonce 属性
- 添加 Cloudflare Insights 域名到 CSP 允许列表
- 添加完整单元测试,覆盖率达到 89.8%

解决的问题:
- 内联脚本违反 CSP script-src 指令
- Cloudflare Insights beacon.min.js 加载被阻止

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-16 17:05:49 +08:00
parent c659788022
commit c9f79dee66
6 changed files with 1010 additions and 7 deletions

View File

@@ -13,9 +13,15 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
)
const (
// NonceHTMLPlaceholder is the placeholder for nonce in HTML script tags
NonceHTMLPlaceholder = "__CSP_NONCE_VALUE__"
)
//go:embed all:dist
var frontendFS embed.FS
@@ -115,6 +121,9 @@ func (s *FrontendServer) fileExists(path string) bool {
}
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
// Get nonce from context (generated by SecurityHeaders middleware)
nonce := middleware.GetNonceFromContext(c)
// Check cache first
cached := s.cache.Get()
if cached != nil {
@@ -125,9 +134,12 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
return
}
// Replace nonce placeholder with actual nonce before serving
content := replaceNoncePlaceholder(cached.Content, nonce)
c.Header("ETag", cached.ETag)
c.Header("Cache-Control", "no-cache") // Must revalidate
c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content)
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
c.Abort()
return
}
@@ -155,24 +167,33 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
rendered := s.injectSettings(settingsJSON)
s.cache.Set(rendered, settingsJSON)
// Replace nonce placeholder with actual nonce before serving
content := replaceNoncePlaceholder(rendered, nonce)
cached = s.cache.Get()
if cached != nil {
c.Header("ETag", cached.ETag)
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
c.Abort()
}
func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
// Create the script tag to inject
script := []byte(`<script>window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
// Create the script tag to inject with nonce placeholder
// The placeholder will be replaced with actual nonce at request time
script := []byte(`<script nonce="` + NonceHTMLPlaceholder + `">window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
// Inject before </head>
headClose := []byte("</head>")
return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
}
// replaceNoncePlaceholder replaces the nonce placeholder with actual nonce value
func replaceNoncePlaceholder(html []byte, nonce string) []byte {
return bytes.ReplaceAll(html, []byte(NonceHTMLPlaceholder), []byte(nonce))
}
// ServeEmbeddedFrontend returns a middleware for serving embedded frontend
// This is the legacy function for backward compatibility when no settings provider is available
func ServeEmbeddedFrontend() gin.HandlerFunc {