feat: implement CORS support with configurable allowed origins
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
|
cors:
|
||||||
|
# 浏览器跨域白名单。生产环境必须改为实际前端/嵌入站点域名,例如 https://kefu.example.com。
|
||||||
|
# 留空表示不允许跨域请求,只支持同源或非浏览器调用。
|
||||||
|
allowedOrigins:
|
||||||
|
- http://127.0.0.1:8083
|
||||||
|
- http://localhost:8083
|
||||||
|
|
||||||
db:
|
db:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
server:
|
server:
|
||||||
port: 8083
|
port: 8083
|
||||||
|
cors:
|
||||||
|
# 浏览器跨域白名单。生产部署时改为实际前端/嵌入站点域名。
|
||||||
|
allowedOrigins:
|
||||||
|
- http://127.0.0.1:8083
|
||||||
|
- http://localhost:8083
|
||||||
|
|
||||||
db:
|
db:
|
||||||
type: mysql
|
type: mysql
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func NewServer() (*gin.Engine, error) {
|
|||||||
printBanner()
|
printBanner()
|
||||||
|
|
||||||
app := gin.New()
|
app := gin.New()
|
||||||
app.Use(corsMiddleware())
|
app.Use(corsMiddleware(cfg.Server.CORS.AllowedOrigins))
|
||||||
app.Use(gin.Recovery())
|
app.Use(gin.Recovery())
|
||||||
app.Use(requestLogMiddleware())
|
app.Use(requestLogMiddleware())
|
||||||
app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes()))
|
app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes()))
|
||||||
@@ -60,18 +60,40 @@ func NewServer() (*gin.Engine, error) {
|
|||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func corsMiddleware() gin.HandlerFunc {
|
func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
|
||||||
allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
||||||
exposeHeaders := "Content-Length, Content-Type, Authorization, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
exposeHeaders := "Content-Length, Content-Type, Authorization, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
|
||||||
|
allowMethods := "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||||
|
allowedOriginSet := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||||
|
if origin == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowedOriginSet[origin] = struct{}{}
|
||||||
|
}
|
||||||
return func(ctx *gin.Context) {
|
return func(ctx *gin.Context) {
|
||||||
if isWebsocketUpgrade(ctx) {
|
if isWebsocketUpgrade(ctx) {
|
||||||
ctx.Next()
|
ctx.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Header("Access-Control-Allow-Origin", "*")
|
origin := strings.TrimRight(strings.TrimSpace(ctx.GetHeader("Origin")), "/")
|
||||||
ctx.Header("Access-Control-Allow-Headers", allowHeaders)
|
if origin != "" {
|
||||||
ctx.Header("Access-Control-Expose-Headers", exposeHeaders)
|
ctx.Header("Vary", "Origin")
|
||||||
ctx.Header("Access-Control-Max-Age", "600")
|
if _, ok := allowedOriginSet[origin]; !ok {
|
||||||
|
if ctx.Request.Method == http.MethodOptions {
|
||||||
|
ctx.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
ctx.Header("Access-Control-Allow-Methods", allowMethods)
|
||||||
|
ctx.Header("Access-Control-Allow-Headers", allowHeaders)
|
||||||
|
ctx.Header("Access-Control-Expose-Headers", exposeHeaders)
|
||||||
|
ctx.Header("Access-Control-Max-Age", "600")
|
||||||
|
}
|
||||||
if ctx.Request.Method == http.MethodOptions {
|
if ctx.Request.Method == http.MethodOptions {
|
||||||
ctx.AbortWithStatus(http.StatusNoContent)
|
ctx.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -85,3 +85,77 @@ func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) {
|
||||||
|
config.SetCurrent(&config.Config{
|
||||||
|
Server: config.ServerConfig{
|
||||||
|
CORS: config.CORSConfig{
|
||||||
|
AllowedOrigins: []string{"https://console.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Storage: config.StorageConfig{
|
||||||
|
Local: config.LocalStorageConfig{
|
||||||
|
Root: "storage",
|
||||||
|
BaseURL: "/storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app, err := NewServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||||
|
req.Header.Set("Origin", "https://console.example.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://console.example.com" {
|
||||||
|
t.Fatalf("Access-Control-Allow-Origin=%q want %q", got, "https://console.example.com")
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) {
|
||||||
|
t.Fatalf("Access-Control-Allow-Methods=%q should contain %q", got, http.MethodPost)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Vary"); got != "Origin" {
|
||||||
|
t.Fatalf("Vary=%q want %q", got, "Origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServerRejectsUnconfiguredCORSOrigin(t *testing.T) {
|
||||||
|
config.SetCurrent(&config.Config{
|
||||||
|
Server: config.ServerConfig{
|
||||||
|
CORS: config.CORSConfig{
|
||||||
|
AllowedOrigins: []string{"https://console.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Storage: config.StorageConfig{
|
||||||
|
Local: config.LocalStorageConfig{
|
||||||
|
Root: "storage",
|
||||||
|
BaseURL: "/storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app, err := NewServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status=%d want %d", rec.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("Access-Control-Allow-Origin=%q want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ type WxWorkNotifyConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
|
CORS CORSConfig `yaml:"cors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s ServerConfig) Address() string {
|
func (s ServerConfig) Address() string {
|
||||||
@@ -40,6 +41,12 @@ func (s ServerConfig) Address() string {
|
|||||||
return fmt.Sprintf(":%d", s.Port)
|
return fmt.Sprintf(":%d", s.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CORSConfig struct {
|
||||||
|
// AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。
|
||||||
|
// 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。
|
||||||
|
AllowedOrigins []string `yaml:"allowedOrigins"`
|
||||||
|
}
|
||||||
|
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
DSN string `yaml:"dsn"`
|
DSN string `yaml:"dsn"`
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadReadsCORSAllowedOrigins(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
content := []byte(`server:
|
||||||
|
port: 8083
|
||||||
|
cors:
|
||||||
|
allowedOrigins:
|
||||||
|
- https://console.example.com
|
||||||
|
- http://localhost:3000
|
||||||
|
`)
|
||||||
|
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := cfg.Server.CORS.AllowedOrigins
|
||||||
|
want := []string{"https://console.example.com", "http://localhost:3000"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("len(AllowedOrigins)=%d want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("AllowedOrigins[%d]=%q want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user