From 56dbaab1d47974016289646a59b3a34276935ff5 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 5 Jul 2026 13:53:16 +0800 Subject: [PATCH] feat(session): support opt-in Secure session cookies - add SESSION_COOKIE_SECURE / SESSION_COOKIE_TRUSTED_URL env vars with startup validation: enabling Secure requires at least one trusted HTTPS entry URL - wire common.SessionCookieSecure into the session cookie store instead of a hardcoded Secure=false - print a startup warning when Secure session cookies are disabled - document the new settings in .env.example and docker-compose files Secure stays off by default because many deployments front new-api with plain-HTTP reverse proxies, where a hardcoded Secure default would break logins entirely; enabling it safely depends on the deployment's TLS setup, so it ships as an opt-in deployment-hardening flag. --- .env.example | 3 ++ common/constants.go | 2 + common/init.go | 3 ++ common/session_cookie.go | 50 ++++++++++++++++++++++++ common/sys_log.go | 7 ++++ common/url_validator_test.go | 74 ++++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 3 ++ docker-compose.yml | 2 + main.go | 6 ++- middleware/cors.go | 2 +- 10 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 common/session_cookie.go diff --git a/.env.example b/.env.example index e02b1f53..a63ed766 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,9 @@ # 会话密钥 # SESSION_SECRET=random_string +# 启用 Secure session cookie,必须同时配置可信 HTTPS 入口地址;多个地址用英文逗号分隔 +# SESSION_COOKIE_SECURE=false +# SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # 其他配置 # 生成默认token diff --git a/common/constants.go b/common/constants.go index 5a15f730..87d212f9 100644 --- a/common/constants.go +++ b/common/constants.go @@ -74,6 +74,8 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar var SessionSecret = uuid.New().String() var CryptoSecret = uuid.New().String() +var SessionCookieSecure = false +var SessionCookieTrustedURLs []string var OptionMap map[string]string var OptionMapRWMutex sync.RWMutex diff --git a/common/init.go b/common/init.go index 6c9e2ad4..88b2dc3e 100644 --- a/common/init.go +++ b/common/init.go @@ -61,6 +61,9 @@ func InitEnv() { } else { CryptoSecret = SessionSecret } + if err := InitSessionCookieSettings(); err != nil { + log.Fatal(err) + } if os.Getenv("SQLITE_PATH") != "" { SQLitePath = os.Getenv("SQLITE_PATH") } diff --git a/common/session_cookie.go b/common/session_cookie.go new file mode 100644 index 00000000..74e3e25c --- /dev/null +++ b/common/session_cookie.go @@ -0,0 +1,50 @@ +package common + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +func InitSessionCookieSettings() error { + secureRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_SECURE")) + trustedURLsRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_TRUSTED_URL")) + + SessionCookieSecure = false + SessionCookieTrustedURLs = nil + + if secureRaw == "" || strings.EqualFold(secureRaw, "false") { + if trustedURLsRaw != "" { + return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL requires SESSION_COOKIE_SECURE=true") + } + return nil + } + + if !strings.EqualFold(secureRaw, "true") { + return fmt.Errorf("SESSION_COOKIE_SECURE must be true or false") + } + + if trustedURLsRaw == "" { + return fmt.Errorf("SESSION_COOKIE_SECURE=true requires SESSION_COOKIE_TRUSTED_URL") + } + + trustedURLs := strings.Split(trustedURLsRaw, ",") + for _, trustedURL := range trustedURLs { + trustedURL = strings.TrimSpace(trustedURL) + if trustedURL == "" { + return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL") + } + parsedURL, err := url.Parse(trustedURL) + if err != nil { + return fmt.Errorf("invalid SESSION_COOKIE_TRUSTED_URL: %w", err) + } + if parsedURL.Scheme != "https" || parsedURL.Host == "" { + return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL must contain only https URLs with hosts") + } + SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, trustedURL) + } + + SessionCookieSecure = true + return nil +} diff --git a/common/sys_log.go b/common/sys_log.go index 6e5b3622..1fa4ebb5 100644 --- a/common/sys_log.go +++ b/common/sys_log.go @@ -46,6 +46,13 @@ func LogStartupSuccess(startTime time.Time, port string) { LogWriterMu.RLock() defer LogWriterMu.RUnlock() + if SessionCookieSecure == false { + // log warning if session cookie is not secure + fmt.Fprintf(gin.DefaultWriter, "\n") + fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Session cookie is not secure. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n") + fmt.Fprintf(gin.DefaultWriter, "\n") + } + fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) fmt.Fprintf(gin.DefaultWriter, "\n") diff --git a/common/url_validator_test.go b/common/url_validator_test.go index 5d12be3a..478832a3 100644 --- a/common/url_validator_test.go +++ b/common/url_validator_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidateRedirectURL(t *testing.T) { @@ -119,3 +121,75 @@ func TestValidateRedirectURL(t *testing.T) { }) } } + +func resetSessionCookieSettingsAfterTest(t *testing.T) { + t.Helper() + t.Cleanup(func() { + SessionCookieSecure = false + SessionCookieTrustedURLs = nil + }) +} + +func TestInitSessionCookieSettingsDefaultsToInsecure(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + require.NoError(t, InitSessionCookieSettings()) + assert.False(t, SessionCookieSecure) + assert.Empty(t, SessionCookieTrustedURLs) +} + +func TestInitSessionCookieSettingsRequiresBothEnvVars(t *testing.T) { + t.Run("secure without trusted url", func(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "") + + require.Error(t, InitSessionCookieSettings()) + }) + + t.Run("trusted url without secure", func(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com") + + require.Error(t, InitSessionCookieSettings()) + }) +} + +func TestInitSessionCookieSettingsRequiresHTTPSURL(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "http://example.com") + + require.Error(t, InitSessionCookieSettings()) +} + +func TestInitSessionCookieSettingsEnablesSecureCookie(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com") + + require.NoError(t, InitSessionCookieSettings()) + assert.True(t, SessionCookieSecure) + assert.Equal(t, []string{"https://example.com"}, SessionCookieTrustedURLs) +} + +func TestInitSessionCookieSettingsAllowsMultipleTrustedURLs(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com, https://admin.example.com") + + require.NoError(t, InitSessionCookieSettings()) + assert.True(t, SessionCookieSecure) + assert.Equal(t, []string{"https://example.com", "https://admin.example.com"}, SessionCookieTrustedURLs) +} + +func TestInitSessionCookieSettingsRejectsEmptyTrustedURLInList(t *testing.T) { + resetSessionCookieSettingsAfterTest(t) + t.Setenv("SESSION_COOKIE_SECURE", "true") + t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com,") + + require.Error(t, InitSessionCookieSettings()) +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e75befae..f98f4b0d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -31,6 +31,9 @@ services: - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - BATCH_UPDATE_ENABLED=true + # Enable only when accessing the dev backend through HTTPS. SESSION_COOKIE_TRUSTED_URL is required when true. + # - SESSION_COOKIE_SECURE=true + # - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com depends_on: redis: condition: service_started diff --git a/docker-compose.yml b/docker-compose.yml index afaf82d7..f5881f4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) # - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable) # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) +# - SESSION_COOKIE_SECURE=true # 启用 Secure session cookie,必须同时配置 SESSION_COOKIE_TRUSTED_URL (Enable Secure session cookies; requires SESSION_COOKIE_TRUSTED_URL) +# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # 可信 HTTPS 入口地址,多个用英文逗号分隔 (Trusted HTTPS entry URLs, comma-separated) # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed # - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) # - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) diff --git a/main.go b/main.go index 3a2fb694..888484a4 100644 --- a/main.go +++ b/main.go @@ -181,7 +181,7 @@ func main() { // This will cause SSE not to work!!! //server.Use(gzip.Gzip(gzip.DefaultCompression)) server.Use(middleware.RequestId()) - server.Use(middleware.PoweredBy()) + server.Use(middleware.Version()) server.Use(middleware.I18n()) middleware.SetUpLogger(server) // Initialize session store @@ -190,7 +190,7 @@ func main() { Path: "/", MaxAge: 2592000, // 30 days HttpOnly: true, - Secure: false, + Secure: common.SessionCookieSecure, SameSite: http.SameSiteStrictMode, }) server.Use(sessions.Sessions("session", store)) @@ -221,6 +221,8 @@ func main() { } }() + time.Sleep(100 * time.Millisecond) + common.LogStartupSuccess(startTime, port) quit := make(chan os.Signal, 1) diff --git a/middleware/cors.go b/middleware/cors.go index 6aaa15d7..e90d77bd 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -15,7 +15,7 @@ func CORS() gin.HandlerFunc { return cors.New(config) } -func PoweredBy() gin.HandlerFunc { +func Version() gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-New-Api-Version", common.Version) c.Next()