From 7d1e16b97b07e607154b8c6884ca8151b0f2d441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:52:27 +0800 Subject: [PATCH] Add files via upload --- internal/app/app.go | 60 +++++++++++++++++++++--- internal/app/cors_security_test.go | 73 +++++++++++++++++++++++++++++- internal/config/config.go | 3 ++ 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 3e3215f6..1b24e7df 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -84,7 +84,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error router := gin.Default() // CORS中间件 - router.Use(corsMiddleware()) + router.Use(corsMiddleware(cfg.Server.CORSAllowedOrigins)) // 初始化数据库 dbPath := cfg.Database.Path @@ -2069,22 +2069,36 @@ func initializeKnowledge( return knowledgeHandler, nil } -// corsMiddleware CORS中间件 -func corsMiddleware() gin.HandlerFunc { +// corsMiddleware allows same-origin requests, valid Chromium extension +// origins, and exact origins explicitly configured by the operator. CORS is +// not an authentication boundary; API access still requires a valid session. +func corsMiddleware(configuredOrigins []string) gin.HandlerFunc { + allowedOrigins := make(map[string]struct{}, len(configuredOrigins)) + for _, origin := range configuredOrigins { + if normalized, ok := normalizeCORSOrigin(origin); ok { + allowedOrigins[normalized] = struct{}{} + } + } + return func(c *gin.Context) { origin := strings.TrimSpace(c.GetHeader("Origin")) if origin != "" { - parsed, err := url.Parse(origin) - if err != nil || parsed.Host == "" || !strings.EqualFold(parsed.Host, c.Request.Host) { + c.Writer.Header().Add("Vary", "Origin") + normalized, valid := normalizeCORSOrigin(origin) + _, explicitlyAllowed := allowedOrigins[normalized] + parsed, _ := url.Parse(origin) + sameHost := valid && strings.EqualFold(parsed.Host, c.Request.Host) + browserExtension := valid && isChromiumExtensionOrigin(parsed) + if !sameHost && !browserExtension && !explicitlyAllowed { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "cross-origin request denied"}) return } c.Writer.Header().Set("Access-Control-Allow-Origin", origin) c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Add("Vary", "Origin") } c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + c.Writer.Header().Set("Access-Control-Max-Age", "600") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -2094,3 +2108,37 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +// isChromiumExtensionOrigin accepts only Chrome's canonical 32-character +// extension IDs (letters a-p). It does not allow arbitrary custom schemes or +// web origins, and the extension must separately obtain host permission. +func isChromiumExtensionOrigin(origin *url.URL) bool { + if origin == nil || !strings.EqualFold(origin.Scheme, "chrome-extension") || origin.Port() != "" { + return false + } + id := strings.ToLower(origin.Hostname()) + if len(id) != 32 { + return false + } + for _, ch := range id { + if ch < 'a' || ch > 'p' { + return false + } + } + return true +} + +// normalizeCORSOrigin validates and canonicalizes a serialized origin. CORS +// origins never contain credentials, paths, query strings, or fragments. +func normalizeCORSOrigin(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "*" || strings.EqualFold(raw, "null") { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || + (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", false + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), true +} diff --git a/internal/app/cors_security_test.go b/internal/app/cors_security_test.go index 1860dea3..d58eebf4 100644 --- a/internal/app/cors_security_test.go +++ b/internal/app/cors_security_test.go @@ -11,7 +11,7 @@ import ( func TestCORSMiddlewareAllowsSameOriginAndRejectsForeignOrigin(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() - router.Use(corsMiddleware()) + router.Use(corsMiddleware(nil)) router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) same := httptest.NewRequest(http.MethodGet, "http://app.example/test", nil) @@ -32,3 +32,74 @@ func TestCORSMiddlewareAllowsSameOriginAndRejectsForeignOrigin(t *testing.T) { t.Fatalf("foreign-origin response = %d, want %d", foreignW.Code, http.StatusForbidden) } } + +func TestCORSMiddlewareAllowsBrowserExtensionWithoutConfiguration(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware(nil)) + router.POST("/api/auth/login", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + req := httptest.NewRequest(http.MethodOptions, "https://server.example/api/auth/login", nil) + req.Host = "server.example" + req.Header.Set("Origin", "chrome-extension://abcdefghijklmnopabcdefghijklmnop") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("preflight response = %d, want %d", w.Code, http.StatusNoContent) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "chrome-extension://abcdefghijklmnopabcdefghijklmnop" { + t.Fatalf("allow-origin = %q", got) + } +} + +func TestCORSMiddlewareRejectsInvalidExtensionOrigins(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, origin := range []string{ + "chrome-extension://too-short", + "chrome-extension://qrstuvwxyzabcdefqrstuvwxyzabcdef", + "chrome-extension://abcdefghijklmnopabcdefghijklmnop:8443", + "moz-extension://abcdefghijklmnopabcdefghijklmnop", + } { + t.Run(origin, func(t *testing.T) { + router := gin.New() + router.Use(corsMiddleware(nil)) + router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + req := httptest.NewRequest(http.MethodGet, "https://server.example/test", nil) + req.Host = "server.example" + req.Header.Set("Origin", origin) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("response = %d, want %d", w.Code, http.StatusForbidden) + } + }) + } +} + +func TestCORSMiddlewareRejectsUnsafeConfiguredEntries(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, configured := range []string{ + "*", + "null", + "https://trusted.example/extra", + "https://trusted.example?trusted=true", + } { + t.Run(configured, func(t *testing.T) { + router := gin.New() + router.Use(corsMiddleware([]string{configured})) + router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + req := httptest.NewRequest(http.MethodGet, "https://server.example/test", nil) + req.Host = "server.example" + req.Header.Set("Origin", "https://trusted.example") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("response = %d, want %d", w.Code, http.StatusForbidden) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 06a253fa..e85b2d10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -767,6 +767,9 @@ func (c RobotsConfig) ServiceAccountUserIDs() map[string]string { type ServerConfig struct { Host string `yaml:"host" json:"host"` Port int `yaml:"port" json:"port"` + // CORSAllowedOrigins contains additional, exact origins that may call the API. + // Same-origin browser requests are always allowed. Wildcards are intentionally unsupported. + CORSAllowedOrigins []string `yaml:"cors_allowed_origins,omitempty" json:"cors_allowed_origins,omitempty"` // TLSEnabled 为 true 时主 Web UI 使用 HTTPS;现代浏览器在同源下会协商 HTTP/2,缓解 HTTP/1.1 每源并发连接数限制。 TLSEnabled bool `yaml:"tls_enabled,omitempty" json:"tls_enabled,omitempty"` // TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。