Skip to content

Commit 95ef15b

Browse files
[security] /v1/* 与 /api/admin/* 默认 fail-closed;新增首启自助初始化
修复严重安全漏洞:默认配置下 /v1/* 和 /api/admin/* 端点完全无鉴权。已知至少 36 个公网站点受影响。 核心变更: - /v1/* 和 /api/admin/* 默认 fail-closed - 新增首次启动浏览器初始化流程 - 添加安全警告横幅 - 所有 CI 检查通过
1 parent ed6d300 commit 95ef15b

10 files changed

Lines changed: 616 additions & 23 deletions

File tree

.env.example

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
# HTTP 服务端口
77
CODEX_PORT=8080
88

9-
# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框)
9+
# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署)
10+
# 如希望仅本机访问,可设置为 127.0.0.1
11+
# CODEX_BIND=0.0.0.0
12+
13+
# 管理后台登录密钥
14+
# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存
15+
# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503
16+
# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化
1017
# ADMIN_SECRET=your-admin-password
1118

12-
# 下游 API 密钥鉴权(逗号分隔,留空则不鉴权)
13-
# CODEX_API_KEYS=sk-your-key-1,sk-your-key-2
19+
# /v1/* 匿名访问开关(默认 false,强烈建议保持默认)
20+
# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景!
21+
# CODEX_ALLOW_ANONYMOUS=false
1422

1523
# 全局代理(可选)
1624
# CODEX_PROXY_URL=http://host.docker.internal:7890

.env.sqlite.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@
66
# HTTP 服务端口
77
CODEX_PORT=8080
88

9-
# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框)
9+
# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署)
10+
# 如希望仅本机访问,可设置为 127.0.0.1
11+
# CODEX_BIND=0.0.0.0
12+
13+
# 管理后台登录密钥
14+
# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存
15+
# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503
16+
# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化
1017
# ADMIN_SECRET=your-admin-password
1118

19+
# /v1/* 匿名访问开关(默认 false,强烈建议保持默认)
20+
# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景!
21+
# CODEX_ALLOW_ANONYMOUS=false
22+
1223
# ---- 数据库 / 缓存驱动 ----
1324
DATABASE_DRIVER=sqlite
1425
CACHE_DRIVER=memory

admin/bootstrap.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"strings"
7+
"sync"
8+
"sync/atomic"
9+
"time"
10+
"unicode/utf8"
11+
12+
"github.com/codex2api/database"
13+
"github.com/codex2api/security"
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
// bootstrapState 跟踪初始化端点的运行状态,主要用于:
18+
// 1. 防止并发条件下重复写入;
19+
// 2. 简单的全局限频,避免被扫描器穷举攻击。
20+
var bootstrapState struct {
21+
mu sync.Mutex
22+
23+
// rateBucket: 简单的固定窗口限频,单位 = 每 windowSec 内最多 maxPerWindow 次
24+
windowStart atomic.Int64 // unix seconds
25+
count atomic.Int64
26+
}
27+
28+
const (
29+
bootstrapWindowSec = 60
30+
bootstrapMaxPerWin = 20
31+
bootstrapMinSecret = 8
32+
bootstrapMaxSecret = 256
33+
)
34+
35+
// bootstrapAllowRate 使用 CAS 实现固定窗口限频:
36+
// - 任意时刻只有一个 goroutine 能成功翻新窗口起点,其它失败者读到的就是
37+
// 翻新后的最新值,避免多个 goroutine 同时把 count 重置为 0。
38+
// - 在并发下,最坏情况只是有一个 reset 与若干 Add 交错,但所有"翻窗"
39+
// 操作都是原子的,不会出现窗口被重复清零导致超额放行的情况。
40+
func bootstrapAllowRate() bool {
41+
now := time.Now().Unix()
42+
for {
43+
winStart := bootstrapState.windowStart.Load()
44+
if now-winStart < bootstrapWindowSec {
45+
break
46+
}
47+
// 仅当 windowStart 仍是我们读到的旧值时才推进;其它 goroutine 已经
48+
// 推进过的话直接退出循环,复用最新窗口。
49+
if bootstrapState.windowStart.CompareAndSwap(winStart, now) {
50+
bootstrapState.count.Store(0)
51+
break
52+
}
53+
}
54+
return bootstrapState.count.Add(1) <= bootstrapMaxPerWin
55+
}
56+
57+
// GetBootstrapStatus 返回当前是否需要执行初始化(GET /api/admin/bootstrap-status)。
58+
//
59+
// 该端点不要求鉴权,前端 AuthGate 在拿到登录界面前会先轮询此端点:
60+
// - 已通过 .env 设置 ADMIN_SECRET => needs_bootstrap=false, source="env"
61+
// - 已写入数据库 => needs_bootstrap=false, source="database"
62+
// - 两端均空 => needs_bootstrap=true, source="empty"
63+
func (h *Handler) GetBootstrapStatus(c *gin.Context) {
64+
envSecret := strings.TrimSpace(h.adminSecretEnv)
65+
if envSecret != "" {
66+
c.JSON(http.StatusOK, gin.H{
67+
"needs_bootstrap": false,
68+
"source": "env",
69+
})
70+
return
71+
}
72+
73+
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
74+
defer cancel()
75+
settings, err := h.db.GetSystemSettings(ctx)
76+
if err != nil {
77+
// 数据库异常时倾向 fail-closed:不允许 bootstrap,让运维先排查 DB
78+
c.JSON(http.StatusServiceUnavailable, gin.H{
79+
"needs_bootstrap": false,
80+
"source": "error",
81+
"error": "读取系统设置失败,请检查数据库连接",
82+
})
83+
return
84+
}
85+
if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" {
86+
c.JSON(http.StatusOK, gin.H{
87+
"needs_bootstrap": false,
88+
"source": "database",
89+
})
90+
return
91+
}
92+
c.JSON(http.StatusOK, gin.H{
93+
"needs_bootstrap": true,
94+
"source": "empty",
95+
})
96+
}
97+
98+
// PostBootstrap 接收用户在浏览器中输入的初始管理密钥并写入数据库。
99+
//
100+
// 安全约束:
101+
// 1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409;
102+
// 2. 通过互斥锁 + 双重检查避免并发写入;
103+
// 3. 简单全局限频,防止扫描器穷举;
104+
// 4. 校验最小长度(12 个 rune),避免过弱密钥;
105+
// 5. 全程审计日志。
106+
func (h *Handler) PostBootstrap(c *gin.Context) {
107+
if !bootstrapAllowRate() {
108+
security.SecurityAuditLog("BOOTSTRAP_RATE_LIMITED", "ip="+c.ClientIP())
109+
c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
110+
return
111+
}
112+
113+
envSecret := strings.TrimSpace(h.adminSecretEnv)
114+
if envSecret != "" {
115+
security.SecurityAuditLog("BOOTSTRAP_REJECTED_ENV", "ip="+c.ClientIP())
116+
c.JSON(http.StatusConflict, gin.H{
117+
"error": "ADMIN_SECRET 已通过环境变量配置,无需在页面初始化",
118+
})
119+
return
120+
}
121+
122+
var body struct {
123+
AdminSecret string `json:"admin_secret"`
124+
}
125+
if err := c.ShouldBindJSON(&body); err != nil {
126+
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"})
127+
return
128+
}
129+
secret := strings.TrimSpace(body.AdminSecret)
130+
if secret == "" {
131+
c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥不能为空"})
132+
return
133+
}
134+
if utf8.RuneCountInString(secret) < bootstrapMinSecret {
135+
c.JSON(http.StatusBadRequest, gin.H{
136+
"error": "管理密钥至少 8 位",
137+
})
138+
return
139+
}
140+
if len(secret) > bootstrapMaxSecret {
141+
c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥过长"})
142+
return
143+
}
144+
145+
bootstrapState.mu.Lock()
146+
defer bootstrapState.mu.Unlock()
147+
148+
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
149+
defer cancel()
150+
151+
// 双重检查:进入临界区后再读一次,避免并发写入
152+
settings, err := h.db.GetSystemSettings(ctx)
153+
if err != nil {
154+
security.SecurityAuditLog("BOOTSTRAP_DB_READ_ERROR", "ip="+c.ClientIP()+" err="+err.Error())
155+
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取系统设置失败"})
156+
return
157+
}
158+
if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" {
159+
security.SecurityAuditLog("BOOTSTRAP_REJECTED_ALREADY_INITIALIZED", "ip="+c.ClientIP())
160+
c.JSON(http.StatusConflict, gin.H{
161+
"error": "ADMIN_SECRET 已配置,无法重复初始化。如需重置,请进入「设置」页面使用现有密钥登录后修改。",
162+
})
163+
return
164+
}
165+
if settings == nil {
166+
settings = defaultBootstrapSettings()
167+
}
168+
settings.AdminSecret = secret
169+
170+
if err := h.db.UpdateSystemSettings(ctx, settings); err != nil {
171+
security.SecurityAuditLog("BOOTSTRAP_DB_WRITE_ERROR", "ip="+c.ClientIP()+" err="+err.Error())
172+
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入系统设置失败"})
173+
return
174+
}
175+
176+
security.SecurityAuditLog("BOOTSTRAP_SUCCESS", "ip="+c.ClientIP())
177+
c.JSON(http.StatusOK, gin.H{"ok": true})
178+
}
179+
180+
// defaultBootstrapSettings 返回 settings 表初次记录的安全默认值。
181+
// 与 main.go 中 step 3 保持一致,避免 PostBootstrap 在数据库尚无任何记录时
182+
// 写入空值导致后续业务设置缺失。
183+
func defaultBootstrapSettings() *database.SystemSettings {
184+
return &database.SystemSettings{
185+
MaxConcurrency: 2,
186+
GlobalRPM: 0,
187+
TestModel: "gpt-5.4",
188+
TestConcurrency: 50,
189+
BackgroundRefreshIntervalMinutes: 2,
190+
UsageProbeMaxAgeMinutes: 10,
191+
RecoveryProbeIntervalMinutes: 30,
192+
PgMaxConns: 50,
193+
RedisPoolSize: 30,
194+
PromptFilterMode: "monitor",
195+
PromptFilterThreshold: 50,
196+
PromptFilterStrictThreshold: 90,
197+
PromptFilterLogMatches: true,
198+
PromptFilterMaxTextLength: 81920,
199+
PromptFilterCustomPatterns: "[]",
200+
PromptFilterDisabledPatterns: "[]",
201+
}
202+
}

admin/handler.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ func (h *Handler) SetPoolSizes(pgMaxConns, redisPoolSize int) {
100100
func (h *Handler) RegisterRoutes(r *gin.Engine) {
101101
r.GET("/p/img/:id", h.GetSignedImageAssetFile)
102102

103+
// 首次初始化端点(无需鉴权,仅在系统未配置 ADMIN_SECRET 时可用)
104+
// 这两个端点必须注册在 adminAuthMiddleware 之外,否则会被 fail-closed 拦截。
105+
r.GET("/api/admin/bootstrap-status", h.GetBootstrapStatus)
106+
r.POST("/api/admin/bootstrap", h.PostBootstrap)
107+
103108
api := r.Group("/api/admin")
104109
api.Use(h.adminAuthMiddleware())
105110
api.GET("/stats", h.GetStats)
@@ -167,12 +172,22 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
167172
}
168173

169174
// adminAuthMiddleware 管理接口鉴权中间件(增强版,增加安全审计日志)
175+
//
176+
// 安全策略(fail-closed):
177+
// - 未配置 ADMIN_SECRET 时一律拒绝(503),防止 /api/admin/* 裸奔。
178+
// - 用户应通过前端「首次初始化」页面(无鉴权的 /api/admin/bootstrap 端点)
179+
// 设置初始密钥,或者在 .env 中显式设置 ADMIN_SECRET 后重启。
170180
func (h *Handler) adminAuthMiddleware() gin.HandlerFunc {
171181
return func(c *gin.Context) {
172182
adminSecret, source := h.resolveAdminSecret(c.Request.Context())
173183
if adminSecret == "" {
174-
// 未配置管理密钥,跳过鉴权
175-
c.Next()
184+
// fail-closed:拒绝并提示用户配置 ADMIN_SECRET
185+
security.SecurityAuditLog("ADMIN_BLOCKED_NO_SECRET", fmt.Sprintf("path=%s ip=%s", c.Request.URL.Path, c.ClientIP()))
186+
c.JSON(http.StatusServiceUnavailable, gin.H{
187+
"error": "管理接口未初始化:ADMIN_SECRET 尚未配置。请在浏览器访问 /admin/ 完成首次初始化,或在 .env 中设置 ADMIN_SECRET 后重启。",
188+
"code": "bootstrap_required",
189+
})
190+
c.Abort()
176191
return
177192
}
178193

config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ func (c *CacheConfig) Label() string {
7070
// 业务逻辑参数(如 ProxyURL,APIKeys,MaxConcurrency)已全部移至数据库 SystemSettings 进行化
7171
type Config struct {
7272
Port int
73+
BindAddress string // 监听地址,默认 0.0.0.0(兼容 Docker / 反代 / 公网);如需仅本机访问可设为 127.0.0.1
7374
AdminSecret string
75+
AllowAnonymousV1 bool // 显式允许 /v1/* 在未配置 API Key 时无鉴权放行(默认禁止)
7476
MaxRequestBodySize int
7577
Database DatabaseConfig
7678
Cache CacheConfig
@@ -98,6 +100,14 @@ func Load(envPath string) (*Config, error) {
98100
fmt.Sscanf(port, "%d", &cfg.Port)
99101
}
100102
cfg.AdminSecret = strings.TrimSpace(os.Getenv("ADMIN_SECRET"))
103+
cfg.AllowAnonymousV1 = parseBoolEnv(os.Getenv("CODEX_ALLOW_ANONYMOUS"))
104+
// 默认绑 0.0.0.0 以兼容 Docker 端口映射、反向代理、生产服务器等常规部署。
105+
// 安全防护由 fail-closed 中间件 + 首启自助初始化 (/api/admin/bootstrap) + 启动 banner 共同保证;
106+
// 想要严格仅本机访问的用户可设 CODEX_BIND=127.0.0.1。
107+
cfg.BindAddress = strings.TrimSpace(os.Getenv("CODEX_BIND"))
108+
if cfg.BindAddress == "" {
109+
cfg.BindAddress = "0.0.0.0"
110+
}
101111
if v := strings.TrimSpace(os.Getenv("CODEX_MAX_REQUEST_BODY_SIZE_MB")); v != "" {
102112
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
103113
cfg.MaxRequestBodySize = mb * 1024 * 1024

0 commit comments

Comments
 (0)