* HH-547: allow cross-origin widget requests * fix(HH-547): align production preflight with wildcard CORS --------- Co-authored-by: Rogee <rogee@ipao.vip>
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
package middleware
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
)
|
|
|
|
// CORSConfig holds CORS middleware configuration.
|
|
type CORSConfig struct {
|
|
AllowedOrigins []string
|
|
AllowedMethods []string
|
|
AllowedHeaders []string
|
|
ExposeHeaders []string
|
|
AllowCredentials bool
|
|
MaxAge int // seconds
|
|
DevMode bool // when true and AllowedOrigins is empty, fall back to Allow-Origin: *
|
|
}
|
|
|
|
// Default CORS values for production.
|
|
var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
|
|
var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"}
|
|
var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"}
|
|
var defaultCORSMaxAge = 86400 // 24 hours
|
|
|
|
// CORS adds Cross-Origin Resource Sharing headers.
|
|
// Corresponds to Chatwoot's rack-cors middleware configuration.
|
|
// When DevMode is true and no AllowedOrigins are configured, it falls back
|
|
// to Allow-Origin:* for development convenience.
|
|
// Otherwise, it checks the request Origin against the whitelist using
|
|
// exact match or wildcard subdomain matching (*.example.com).
|
|
func CORS(cfg CORSConfig) gin.HandlerFunc {
|
|
// Apply defaults for empty fields
|
|
methods := cfg.AllowedMethods
|
|
if len(methods) == 0 {
|
|
methods = defaultCORSMethods
|
|
}
|
|
headers := cfg.AllowedHeaders
|
|
if len(headers) == 0 {
|
|
headers = defaultCORSHeaders
|
|
}
|
|
exposeHeaders := cfg.ExposeHeaders
|
|
if len(exposeHeaders) == 0 {
|
|
exposeHeaders = defaultCORSExposeHeaders
|
|
}
|
|
maxAge := cfg.MaxAge
|
|
if maxAge == 0 {
|
|
maxAge = defaultCORSMaxAge
|
|
}
|
|
|
|
return func(c *gin.Context) {
|
|
origin := c.Request.Header.Get("Origin")
|
|
|
|
if cfg.DevMode && len(cfg.AllowedOrigins) == 0 {
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|
} else if origin != "" && isOriginAllowed(origin, cfg.AllowedOrigins) && productionOriginAllowed(origin, cfg) {
|
|
c.Header("Access-Control-Allow-Origin", origin)
|
|
c.Header("Vary", "Origin")
|
|
if cfg.AllowCredentials {
|
|
c.Header("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
}
|
|
|
|
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
|
|
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
|
|
c.Header("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
|
|
c.Header("Access-Control-Max-Age", fmt.Sprintf("%d", maxAge))
|
|
|
|
if c.Request.Method == http.MethodOptions {
|
|
c.AbortWithStatus(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func productionOriginAllowed(origin string, cfg CORSConfig) bool {
|
|
if cfg.DevMode {
|
|
return true
|
|
}
|
|
parsed, err := url.Parse(origin)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
|
return false
|
|
}
|
|
if cfg.AllowCredentials {
|
|
for _, allowed := range cfg.AllowedOrigins {
|
|
if allowed == "*" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isOriginAllowed checks whether the request origin matches any entry in the
|
|
// AllowedOrigins whitelist. Supports:
|
|
// - Exact match: "https://app.example.com" matches exactly
|
|
// - Wildcard subdomain: "*.example.com" matches any subdomain of example.com
|
|
// (e.g. https://foo.example.com, https://bar.example.com)
|
|
func isOriginAllowed(origin string, allowedOrigins []string) bool {
|
|
for _, pattern := range allowedOrigins {
|
|
if matchOrigin(origin, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchOrigin matches an origin against a pattern.
|
|
// pattern can be:
|
|
// - An exact URL: "https://app.example.com" → only matches that URL
|
|
// - A wildcard domain: "*.example.com" → matches any subdomain of example.com
|
|
// with any scheme (http or https)
|
|
func matchOrigin(origin, pattern string) bool {
|
|
// Exact match
|
|
if pattern == origin {
|
|
return true
|
|
}
|
|
|
|
// Wildcard subdomain matching: pattern like "*.example.com"
|
|
if strings.HasPrefix(pattern, "*.") {
|
|
// Strip the wildcard prefix to get the base domain: ".example.com"
|
|
baseDomain := pattern[1:] // ".example.com"
|
|
|
|
// Extract host from origin URL
|
|
host := extractHost(origin)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
|
|
// Host must end with the base domain and have at least one subdomain character
|
|
// e.g. "foo.example.com" ends with ".example.com" → match
|
|
// but "example.com" does not end with ".example.com" → no match
|
|
if strings.HasSuffix(host, baseDomain) && len(host) > len(baseDomain) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// extractHost parses a URL string and returns the host portion (without port).
|
|
func extractHost(origin string) string {
|
|
// Origin is always a full URL like "https://foo.example.com:8080"
|
|
// Use standard library parsing
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
// Fallback: try manual extraction for malformed origins
|
|
// Strip scheme
|
|
afterScheme := origin
|
|
if idx := strings.Index(origin, "://"); idx >= 0 {
|
|
afterScheme = origin[idx+3:]
|
|
}
|
|
// Strip path
|
|
if idx := strings.Index(afterScheme, "/"); idx >= 0 {
|
|
afterScheme = afterScheme[:idx]
|
|
}
|
|
// Strip port
|
|
if idx := strings.LastIndex(afterScheme, ":"); idx >= 0 {
|
|
afterScheme = afterScheme[:idx]
|
|
}
|
|
return afterScheme
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
// CORSConfigFromAppConfig keeps method/header tuning while allowing every embed origin.
|
|
func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig {
|
|
return CORSConfig{
|
|
AllowedMethods: cfg.Server.CORS.AllowedMethods,
|
|
AllowedHeaders: cfg.Server.CORS.AllowedHeaders,
|
|
ExposeHeaders: cfg.Server.CORS.ExposeHeaders,
|
|
MaxAge: cfg.Server.CORS.MaxAge,
|
|
DevMode: true,
|
|
}
|
|
}
|