Commit 56dbaab1 by CaIon

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.
parent 0d5995eb
...@@ -69,6 +69,9 @@ ...@@ -69,6 +69,9 @@
# 会话密钥 # 会话密钥
# SESSION_SECRET=random_string # SESSION_SECRET=random_string
# 启用 Secure session cookie,必须同时配置可信 HTTPS 入口地址;多个地址用英文逗号分隔
# SESSION_COOKIE_SECURE=false
# SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com
# 其他配置 # 其他配置
# 生成默认token # 生成默认token
......
...@@ -74,6 +74,8 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar ...@@ -74,6 +74,8 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar
var SessionSecret = uuid.New().String() var SessionSecret = uuid.New().String()
var CryptoSecret = uuid.New().String() var CryptoSecret = uuid.New().String()
var SessionCookieSecure = false
var SessionCookieTrustedURLs []string
var OptionMap map[string]string var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex var OptionMapRWMutex sync.RWMutex
......
...@@ -61,6 +61,9 @@ func InitEnv() { ...@@ -61,6 +61,9 @@ func InitEnv() {
} else { } else {
CryptoSecret = SessionSecret CryptoSecret = SessionSecret
} }
if err := InitSessionCookieSettings(); err != nil {
log.Fatal(err)
}
if os.Getenv("SQLITE_PATH") != "" { if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH") SQLitePath = os.Getenv("SQLITE_PATH")
} }
......
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
}
...@@ -46,6 +46,13 @@ func LogStartupSuccess(startTime time.Time, port string) { ...@@ -46,6 +46,13 @@ func LogStartupSuccess(startTime time.Time, port string) {
LogWriterMu.RLock() LogWriterMu.RLock()
defer LogWriterMu.RUnlock() 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, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
......
...@@ -5,6 +5,8 @@ import ( ...@@ -5,6 +5,8 @@ import (
"testing" "testing"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestValidateRedirectURL(t *testing.T) { func TestValidateRedirectURL(t *testing.T) {
...@@ -119,3 +121,75 @@ 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())
}
...@@ -31,6 +31,9 @@ services: ...@@ -31,6 +31,9 @@ services:
- REDIS_CONN_STRING=redis://redis - REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- BATCH_UPDATE_ENABLED=true - 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: depends_on:
redis: redis:
condition: service_started condition: service_started
......
...@@ -39,6 +39,8 @@ services: ...@@ -39,6 +39,8 @@ services:
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) # - 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) # - 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_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 # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) # - 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) # - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
......
...@@ -181,7 +181,7 @@ func main() { ...@@ -181,7 +181,7 @@ func main() {
// This will cause SSE not to work!!! // This will cause SSE not to work!!!
//server.Use(gzip.Gzip(gzip.DefaultCompression)) //server.Use(gzip.Gzip(gzip.DefaultCompression))
server.Use(middleware.RequestId()) server.Use(middleware.RequestId())
server.Use(middleware.PoweredBy()) server.Use(middleware.Version())
server.Use(middleware.I18n()) server.Use(middleware.I18n())
middleware.SetUpLogger(server) middleware.SetUpLogger(server)
// Initialize session store // Initialize session store
...@@ -190,7 +190,7 @@ func main() { ...@@ -190,7 +190,7 @@ func main() {
Path: "/", Path: "/",
MaxAge: 2592000, // 30 days MaxAge: 2592000, // 30 days
HttpOnly: true, HttpOnly: true,
Secure: false, Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
server.Use(sessions.Sessions("session", store)) server.Use(sessions.Sessions("session", store))
...@@ -221,6 +221,8 @@ func main() { ...@@ -221,6 +221,8 @@ func main() {
} }
}() }()
time.Sleep(100 * time.Millisecond)
common.LogStartupSuccess(startTime, port) common.LogStartupSuccess(startTime, port)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
......
...@@ -15,7 +15,7 @@ func CORS() gin.HandlerFunc { ...@@ -15,7 +15,7 @@ func CORS() gin.HandlerFunc {
return cors.New(config) return cors.New(config)
} }
func PoweredBy() gin.HandlerFunc { func Version() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("X-New-Api-Version", common.Version) c.Header("X-New-Api-Version", common.Version)
c.Next() c.Next()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment