| package middleware |
|
|
| import ( |
| "crypto/rand" |
| "encoding/base64" |
| "fmt" |
| "log" |
| "strings" |
|
|
| "github.com/Wei-Shaw/sub2api/internal/config" |
| "github.com/gin-gonic/gin" |
| ) |
|
|
| const ( |
| |
| CSPNonceKey = "csp_nonce" |
| |
| NonceTemplate = "__CSP_NONCE__" |
| |
| CloudflareInsightsDomain = "https://static.cloudflareinsights.com" |
| ) |
|
|
| |
| |
| func GenerateNonce() (string, error) { |
| b := make([]byte, 16) |
| if _, err := rand.Read(b); err != nil { |
| return "", fmt.Errorf("generate CSP nonce: %w", err) |
| } |
| return base64.StdEncoding.EncodeToString(b), nil |
| } |
|
|
| |
| func GetNonceFromContext(c *gin.Context) string { |
| if nonce, exists := c.Get(CSPNonceKey); exists { |
| if s, ok := nonce.(string); ok { |
| return s |
| } |
| } |
| return "" |
| } |
|
|
| |
| |
| |
| func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) gin.HandlerFunc { |
| policy := strings.TrimSpace(cfg.Policy) |
| if policy == "" { |
| policy = config.DefaultCSPPolicy |
| } |
|
|
| |
| policy = enhanceCSPPolicy(policy) |
|
|
| return func(c *gin.Context) { |
| finalPolicy := policy |
| if getFrameSrcOrigins != nil { |
| for _, origin := range getFrameSrcOrigins() { |
| if origin != "" { |
| finalPolicy = addToDirective(finalPolicy, "frame-src", origin) |
| } |
| } |
| } |
|
|
| c.Header("X-Content-Type-Options", "nosniff") |
| c.Header("X-Frame-Options", "DENY") |
| c.Header("Referrer-Policy", "strict-origin-when-cross-origin") |
| if isAPIRoutePath(c) { |
| c.Next() |
| return |
| } |
|
|
| if cfg.Enabled { |
| |
| nonce, err := GenerateNonce() |
| if err != nil { |
| |
| log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err) |
| c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'")) |
| } else { |
| c.Set(CSPNonceKey, nonce) |
| c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'")) |
| } |
| } |
| c.Next() |
| } |
| } |
|
|
| func isAPIRoutePath(c *gin.Context) bool { |
| if c == nil || c.Request == nil || c.Request.URL == nil { |
| return false |
| } |
| path := c.Request.URL.Path |
| return strings.HasPrefix(path, "/v1/") || |
| strings.HasPrefix(path, "/v1beta/") || |
| strings.HasPrefix(path, "/antigravity/") || |
| strings.HasPrefix(path, "/sora/") || |
| strings.HasPrefix(path, "/responses") |
| } |
|
|
| |
| |
| func enhanceCSPPolicy(policy string) string { |
| |
| if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") { |
| policy = addToDirective(policy, "script-src", NonceTemplate) |
| } |
|
|
| |
| if !strings.Contains(policy, CloudflareInsightsDomain) { |
| policy = addToDirective(policy, "script-src", CloudflareInsightsDomain) |
| } |
|
|
| return policy |
| } |
|
|
| |
| |
| func addToDirective(policy, directive, value string) string { |
| |
| directivePrefix := directive + " " |
| idx := strings.Index(policy, directivePrefix) |
|
|
| if idx == -1 { |
| |
| defaultSrcIdx := strings.Index(policy, "default-src ") |
| if defaultSrcIdx != -1 { |
| |
| endIdx := strings.Index(policy[defaultSrcIdx:], ";") |
| if endIdx != -1 { |
| insertPos := defaultSrcIdx + endIdx + 1 |
| |
| return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:] |
| } |
| } |
| |
| return directive + " 'self' " + value + "; " + policy |
| } |
|
|
| |
| endIdx := strings.Index(policy[idx:], ";") |
|
|
| if endIdx == -1 { |
| |
| return policy + " " + value |
| } |
|
|
| |
| insertPos := idx + endIdx |
| return policy[:insertPos] + " " + value + policy[insertPos:] |
| } |
|
|