From e3f812c2feaaf70bad997e61cf3842fd2cfd2648 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Fri, 16 Jan 2026 17:20:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8):=20CSP=20=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=87=AA=E5=8A=A8=E5=A2=9E=E5=BC=BA=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E9=9C=80=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 enhanceCSPPolicy() 自动增强任何 CSP 策略 - 自动添加 nonce 占位符(如果策略中没有) - 自动添加 Cloudflare Insights 域名 - 即使配置文件使用旧策略也能正常工作 - 添加 enhanceCSPPolicy 和 addToDirective 单元测试 Co-Authored-By: Claude Opus 4.5 --- .../server/middleware/security_headers.go | 57 +++++++++++++ .../middleware/security_headers_test.go | 82 ++++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 92ecc150..9ce7f449 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -14,6 +14,8 @@ const ( CSPNonceKey = "csp_nonce" // NonceTemplate is the placeholder in CSP policy for nonce NonceTemplate = "__CSP_NONCE__" + // CloudflareInsightsDomain is the domain for Cloudflare Web Analytics + CloudflareInsightsDomain = "https://static.cloudflareinsights.com" ) // GenerateNonce generates a cryptographically secure random nonce @@ -40,6 +42,9 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { policy = config.DefaultCSPPolicy } + // Enhance policy with required directives (nonce placeholder and Cloudflare Insights) + policy = enhanceCSPPolicy(policy) + return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") @@ -57,3 +62,55 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { c.Next() } } + +// enhanceCSPPolicy ensures the CSP policy includes nonce support and Cloudflare Insights domain. +// This allows the application to work correctly even if the config file has an older CSP policy. +func enhanceCSPPolicy(policy string) string { + // Add nonce placeholder to script-src if not present + if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") { + policy = addToDirective(policy, "script-src", NonceTemplate) + } + + // Add Cloudflare Insights domain to script-src if not present + if !strings.Contains(policy, CloudflareInsightsDomain) { + policy = addToDirective(policy, "script-src", CloudflareInsightsDomain) + } + + return policy +} + +// addToDirective adds a value to a specific CSP directive. +// If the directive doesn't exist, it will be added after default-src. +func addToDirective(policy, directive, value string) string { + // Find the directive in the policy + directivePrefix := directive + " " + idx := strings.Index(policy, directivePrefix) + + if idx == -1 { + // Directive not found, add it after default-src or at the beginning + defaultSrcIdx := strings.Index(policy, "default-src ") + if defaultSrcIdx != -1 { + // Find the end of default-src directive (next semicolon) + endIdx := strings.Index(policy[defaultSrcIdx:], ";") + if endIdx != -1 { + insertPos := defaultSrcIdx + endIdx + 1 + // Insert new directive after default-src + return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:] + } + } + // Fallback: prepend the directive + return directive + " 'self' " + value + "; " + policy + } + + // Find the end of this directive (next semicolon or end of string) + endIdx := strings.Index(policy[idx:], ";") + + if endIdx == -1 { + // No semicolon found, directive goes to end of string + return policy + " " + value + } + + // Insert value before the semicolon + insertPos := idx + endIdx + return policy[:insertPos] + " " + value + policy[insertPos:] +} diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index c84c9b25..dc7a87d8 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -122,7 +122,10 @@ func TestSecurityHeaders(t *testing.T) { csp := w.Header().Get("Content-Security-Policy") assert.NotEmpty(t, csp) - assert.Equal(t, "default-src 'self'", csp) + // Policy is auto-enhanced with nonce and Cloudflare Insights domain + assert.Contains(t, csp, "default-src 'self'") + assert.Contains(t, csp, "'nonce-") + assert.Contains(t, csp, CloudflareInsightsDomain) }) t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) { @@ -261,6 +264,83 @@ func TestNonceTemplate(t *testing.T) { }) } +func TestEnhanceCSPPolicy(t *testing.T) { + t.Run("adds_nonce_placeholder_if_missing", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self'" + enhanced := enhanceCSPPolicy(policy) + + assert.Contains(t, enhanced, NonceTemplate) + assert.Contains(t, enhanced, CloudflareInsightsDomain) + }) + + t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' __CSP_NONCE__" + enhanced := enhanceCSPPolicy(policy) + + // Should not duplicate + count := strings.Count(enhanced, NonceTemplate) + assert.Equal(t, 1, count) + }) + + t.Run("does_not_duplicate_cloudflare_domain", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com" + enhanced := enhanceCSPPolicy(policy) + + count := strings.Count(enhanced, CloudflareInsightsDomain) + assert.Equal(t, 1, count) + }) + + t.Run("handles_policy_without_script_src", func(t *testing.T) { + policy := "default-src 'self'" + enhanced := enhanceCSPPolicy(policy) + + assert.Contains(t, enhanced, "script-src") + assert.Contains(t, enhanced, NonceTemplate) + assert.Contains(t, enhanced, CloudflareInsightsDomain) + }) + + t.Run("preserves_existing_nonce", func(t *testing.T) { + policy := "script-src 'self' 'nonce-existing'" + enhanced := enhanceCSPPolicy(policy) + + // Should not add placeholder if nonce already exists + assert.NotContains(t, enhanced, NonceTemplate) + assert.Contains(t, enhanced, "'nonce-existing'") + }) +} + +func TestAddToDirective(t *testing.T) { + t.Run("adds_to_existing_directive", func(t *testing.T) { + policy := "script-src 'self'; style-src 'self'" + result := addToDirective(policy, "script-src", "https://example.com") + + assert.Contains(t, result, "script-src 'self' https://example.com") + }) + + t.Run("creates_directive_if_not_exists", func(t *testing.T) { + policy := "default-src 'self'" + result := addToDirective(policy, "script-src", "https://example.com") + + assert.Contains(t, result, "script-src") + assert.Contains(t, result, "https://example.com") + }) + + t.Run("handles_directive_at_end_without_semicolon", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self'" + result := addToDirective(policy, "script-src", "https://example.com") + + assert.Contains(t, result, "https://example.com") + }) + + t.Run("handles_empty_policy", func(t *testing.T) { + policy := "" + result := addToDirective(policy, "script-src", "https://example.com") + + assert.Contains(t, result, "script-src") + assert.Contains(t, result, "https://example.com") + }) +} + // Benchmark tests func BenchmarkGenerateNonce(b *testing.B) { for i := 0; i < b.N; i++ {