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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user