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.
This commit is contained in:
@@ -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
|
|||||||
|
|
||||||
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() {
|
|||||||
} 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
|||||||
"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) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
|||||||
- 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:
|
|||||||
# - 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() {
|
|||||||
// 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() {
|
|||||||
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() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user