diff --git a/config/config.example.yaml b/config/config.example.yaml index 4ebc8d1..352d540 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,5 +1,11 @@ server: port: 8083 + cors: + # 浏览器跨域白名单。生产环境必须改为实际前端/嵌入站点域名,例如 https://kefu.example.com。 + # 留空表示不允许跨域请求,只支持同源或非浏览器调用。 + allowedOrigins: + - http://127.0.0.1:8083 + - http://localhost:8083 db: type: sqlite diff --git a/docker/cs-agent.yaml b/docker/cs-agent.yaml index 9d8b1c2..60708ed 100644 --- a/docker/cs-agent.yaml +++ b/docker/cs-agent.yaml @@ -1,5 +1,10 @@ server: port: 8083 + cors: + # 浏览器跨域白名单。生产部署时改为实际前端/嵌入站点域名。 + allowedOrigins: + - http://127.0.0.1:8083 + - http://localhost:8083 db: type: mysql diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 16ebfa8..adbbda4 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -29,7 +29,7 @@ func NewServer() (*gin.Engine, error) { printBanner() app := gin.New() - app.Use(corsMiddleware()) + app.Use(corsMiddleware(cfg.Server.CORS.AllowedOrigins)) app.Use(gin.Recovery()) app.Use(requestLogMiddleware()) app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes())) @@ -60,18 +60,40 @@ func NewServer() (*gin.Engine, error) { 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" 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) { if isWebsocketUpgrade(ctx) { ctx.Next() return } - ctx.Header("Access-Control-Allow-Origin", "*") - ctx.Header("Access-Control-Allow-Headers", allowHeaders) - ctx.Header("Access-Control-Expose-Headers", exposeHeaders) - ctx.Header("Access-Control-Max-Age", "600") + origin := strings.TrimRight(strings.TrimSpace(ctx.GetHeader("Origin")), "/") + if origin != "" { + ctx.Header("Vary", "Origin") + 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 { ctx.AbortWithStatus(http.StatusNoContent) return diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 474724b..1eb2b42 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -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) + } +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 2755c2f..3c5e4db 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -30,7 +30,8 @@ type WxWorkNotifyConfig struct { } type ServerConfig struct { - Port int `yaml:"port"` + Port int `yaml:"port"` + CORS CORSConfig `yaml:"cors"` } func (s ServerConfig) Address() string { @@ -40,6 +41,12 @@ func (s ServerConfig) Address() string { return fmt.Sprintf(":%d", s.Port) } +type CORSConfig struct { + // AllowedOrigins 是允许浏览器跨域访问的 Origin 白名单,必须包含协议和域名。 + // 留空表示不允许跨域请求;同源请求通常不会携带 Origin,不受影响。 + AllowedOrigins []string `yaml:"allowedOrigins"` +} + type DBConfig struct { Type string `yaml:"type"` DSN string `yaml:"dsn"` diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go new file mode 100644 index 0000000..509fcb2 --- /dev/null +++ b/internal/pkg/config/config_test.go @@ -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]) + } + } +}