Files
gochat/backend/internal/middleware/cors.go
T
Rogeeandrogee eb83d241fe HH-547: allow cross-origin widget requests (#126)
* HH-547: allow cross-origin widget requests

* fix(HH-547): align production preflight with wildcard CORS

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-23 20:11:04 +08:00

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,
}
}