From e67b0fc33e4ac1461bfb41b8d7a2978ca5279095 Mon Sep 17 00:00:00 2001 From: ImogeneOctaviap794 Date: Thu, 30 Apr 2026 15:24:01 +0800 Subject: [PATCH 1/2] security: enforce fail-closed on /v1/* and /api/admin/*, add first-run bootstrap Previously the proxy fell back to fail-open behavior when no API key / ADMIN_SECRET was configured: any unauthenticated client could hit /v1/* and consume the entire account pool quota, or call /api/admin/* and read / mutate sensitive data. At least 36 publicly deployed instances have been observed to be vulnerable in their default configuration. This commit changes the defaults: - /v1/*: refuse with 503 when no public API key has been created. An explicit opt-out (CODEX_ALLOW_ANONYMOUS=true) is provided for internal / testing scenarios; the startup banner keeps warning while it is active. - /api/admin/*: refuse with 503 + code "bootstrap_required" when ADMIN_SECRET is unset. - New first-run bootstrap (admin/bootstrap.go): GET /api/admin/bootstrap-status POST /api/admin/bootstrap (only when uninitialized) Hardening: sync.Mutex + double-check, fixed-window rate limit (60s / 20 req), minimum 8-char secret, full SecurityAuditLog. - Drop the previous "auto-generate ADMIN_SECRET" shortcut so users always know which secret is in use. - Default bind stays 0.0.0.0 to keep Docker / reverse-proxy deployments working; CODEX_BIND lets users restrict to loopback. - Frontend AuthGate gains a "First-run setup" screen with a confirm field; a global SecurityBanner warns logged-in admins when no public API key has been created. - .env.example / .env.sqlite.example document the new switches. Behavior change: existing deployments must (a) set an ADMIN_SECRET via the new browser bootstrap page on first restart and (b) create at least one public API key before /v1/* will start serving traffic again. Users who consciously want the legacy unauthenticated behavior must opt in with CODEX_ALLOW_ANONYMOUS=true. --- .env.example | 13 +- .env.sqlite.example | 12 +- admin/bootstrap.go | 190 +++++++++++++++++++++ admin/handler.go | 19 ++- config/config.go | 10 ++ frontend/src/components/AuthGate.tsx | 176 +++++++++++++++++-- frontend/src/components/Layout.tsx | 2 + frontend/src/components/SecurityBanner.tsx | 91 ++++++++++ main.go | 84 ++++++++- proxy/handler.go | 21 ++- 10 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 admin/bootstrap.go create mode 100644 frontend/src/components/SecurityBanner.tsx diff --git a/.env.example b/.env.example index a121b2dc..155dff25 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,18 @@ # HTTP 服务端口 CODEX_PORT=8080 -# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框) +# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署) +# 如希望仅本机访问,可设置为 127.0.0.1 +# CODEX_BIND=0.0.0.0 + +# 管理后台登录密钥 +# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次) +# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成 # ADMIN_SECRET=your-admin-password -# 下游 API 密钥鉴权(逗号分隔,留空则不鉴权) -# CODEX_API_KEYS=sk-your-key-1,sk-your-key-2 +# /v1/* 匿名访问开关(默认 false,强烈建议保持默认) +# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景! +# CODEX_ALLOW_ANONYMOUS=false # 全局代理(可选) # CODEX_PROXY_URL=http://host.docker.internal:7890 diff --git a/.env.sqlite.example b/.env.sqlite.example index 0ade873f..56707047 100644 --- a/.env.sqlite.example +++ b/.env.sqlite.example @@ -6,9 +6,19 @@ # HTTP 服务端口 CODEX_PORT=8080 -# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框) +# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署) +# 如希望仅本机访问,可设置为 127.0.0.1 +# CODEX_BIND=0.0.0.0 + +# 管理后台登录密钥 +# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次) +# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成 # ADMIN_SECRET=your-admin-password +# /v1/* 匿名访问开关(默认 false,强烈建议保持默认) +# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景! +# CODEX_ALLOW_ANONYMOUS=false + # ---- 数据库 / 缓存驱动 ---- DATABASE_DRIVER=sqlite CACHE_DRIVER=memory diff --git a/admin/bootstrap.go b/admin/bootstrap.go new file mode 100644 index 00000000..643a6dba --- /dev/null +++ b/admin/bootstrap.go @@ -0,0 +1,190 @@ +package admin + +import ( + "context" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" +) + +// bootstrapState 跟踪初始化端点的运行状态,主要用于: +// 1. 防止并发条件下重复写入; +// 2. 简单的全局限频,避免被扫描器穷举攻击。 +var bootstrapState struct { + mu sync.Mutex + + // rateBucket: 简单的固定窗口限频,单位 = 每 windowSec 内最多 maxPerWindow 次 + windowStart atomic.Int64 // unix seconds + count atomic.Int64 +} + +const ( + bootstrapWindowSec = 60 + bootstrapMaxPerWin = 20 + bootstrapMinSecret = 8 + bootstrapMaxSecret = 256 +) + +func bootstrapAllowRate() bool { + now := time.Now().Unix() + winStart := bootstrapState.windowStart.Load() + if now-winStart >= bootstrapWindowSec { + bootstrapState.windowStart.Store(now) + bootstrapState.count.Store(0) + } + return bootstrapState.count.Add(1) <= bootstrapMaxPerWin +} + +// GetBootstrapStatus 返回当前是否需要执行初始化(GET /api/admin/bootstrap-status)。 +// +// 该端点不要求鉴权,前端 AuthGate 在拿到登录界面前会先轮询此端点: +// - 已通过 .env 设置 ADMIN_SECRET => needs_bootstrap=false, source="env" +// - 已写入数据库 => needs_bootstrap=false, source="database" +// - 两端均空 => needs_bootstrap=true, source="empty" +func (h *Handler) GetBootstrapStatus(c *gin.Context) { + envSecret := strings.TrimSpace(h.adminSecretEnv) + if envSecret != "" { + c.JSON(http.StatusOK, gin.H{ + "needs_bootstrap": false, + "source": "env", + }) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + settings, err := h.db.GetSystemSettings(ctx) + if err != nil { + // 数据库异常时倾向 fail-closed:不允许 bootstrap,让运维先排查 DB + c.JSON(http.StatusServiceUnavailable, gin.H{ + "needs_bootstrap": false, + "source": "error", + "error": "读取系统设置失败,请检查数据库连接", + }) + return + } + if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" { + c.JSON(http.StatusOK, gin.H{ + "needs_bootstrap": false, + "source": "database", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "needs_bootstrap": true, + "source": "empty", + }) +} + +// PostBootstrap 接收用户在浏览器中输入的初始管理密钥并写入数据库。 +// +// 安全约束: +// 1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409; +// 2. 通过互斥锁 + 双重检查避免并发写入; +// 3. 简单全局限频,防止扫描器穷举; +// 4. 校验最小长度(12 个 rune),避免过弱密钥; +// 5. 全程审计日志。 +func (h *Handler) PostBootstrap(c *gin.Context) { + if !bootstrapAllowRate() { + security.SecurityAuditLog("BOOTSTRAP_RATE_LIMITED", "ip="+c.ClientIP()) + c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"}) + return + } + + envSecret := strings.TrimSpace(h.adminSecretEnv) + if envSecret != "" { + security.SecurityAuditLog("BOOTSTRAP_REJECTED_ENV", "ip="+c.ClientIP()) + c.JSON(http.StatusConflict, gin.H{ + "error": "ADMIN_SECRET 已通过环境变量配置,无需在页面初始化", + }) + return + } + + var body struct { + AdminSecret string `json:"admin_secret"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) + return + } + secret := strings.TrimSpace(body.AdminSecret) + if secret == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥不能为空"}) + return + } + if utf8.RuneCountInString(secret) < bootstrapMinSecret { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "管理密钥至少 8 位", + }) + return + } + if len(secret) > bootstrapMaxSecret { + c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥过长"}) + return + } + + bootstrapState.mu.Lock() + defer bootstrapState.mu.Unlock() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + // 双重检查:进入临界区后再读一次,避免并发写入 + settings, err := h.db.GetSystemSettings(ctx) + if err != nil { + security.SecurityAuditLog("BOOTSTRAP_DB_READ_ERROR", "ip="+c.ClientIP()+" err="+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取系统设置失败"}) + return + } + if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" { + security.SecurityAuditLog("BOOTSTRAP_REJECTED_ALREADY_INITIALIZED", "ip="+c.ClientIP()) + c.JSON(http.StatusConflict, gin.H{ + "error": "ADMIN_SECRET 已配置,无法重复初始化。如需重置,请进入「设置」页面使用现有密钥登录后修改。", + }) + return + } + if settings == nil { + settings = defaultBootstrapSettings() + } + settings.AdminSecret = secret + + if err := h.db.UpdateSystemSettings(ctx, settings); err != nil { + security.SecurityAuditLog("BOOTSTRAP_DB_WRITE_ERROR", "ip="+c.ClientIP()+" err="+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "写入系统设置失败"}) + return + } + + security.SecurityAuditLog("BOOTSTRAP_SUCCESS", "ip="+c.ClientIP()) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// defaultBootstrapSettings 返回 settings 表初次记录的安全默认值。 +// 与 main.go 中 step 3 保持一致,避免 PostBootstrap 在数据库尚无任何记录时 +// 写入空值导致后续业务设置缺失。 +func defaultBootstrapSettings() *database.SystemSettings { + return &database.SystemSettings{ + MaxConcurrency: 2, + GlobalRPM: 0, + TestModel: "gpt-5.4", + TestConcurrency: 50, + BackgroundRefreshIntervalMinutes: 2, + UsageProbeMaxAgeMinutes: 10, + RecoveryProbeIntervalMinutes: 30, + PgMaxConns: 50, + RedisPoolSize: 30, + PromptFilterMode: "monitor", + PromptFilterThreshold: 50, + PromptFilterStrictThreshold: 90, + PromptFilterLogMatches: true, + PromptFilterMaxTextLength: 81920, + PromptFilterCustomPatterns: "[]", + PromptFilterDisabledPatterns: "[]", + } +} diff --git a/admin/handler.go b/admin/handler.go index a33479e9..b83eb197 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -100,6 +100,11 @@ func (h *Handler) SetPoolSizes(pgMaxConns, redisPoolSize int) { func (h *Handler) RegisterRoutes(r *gin.Engine) { r.GET("/p/img/:id", h.GetSignedImageAssetFile) + // 首次初始化端点(无需鉴权,仅在系统未配置 ADMIN_SECRET 时可用) + // 这两个端点必须注册在 adminAuthMiddleware 之外,否则会被 fail-closed 拦截。 + r.GET("/api/admin/bootstrap-status", h.GetBootstrapStatus) + r.POST("/api/admin/bootstrap", h.PostBootstrap) + api := r.Group("/api/admin") api.Use(h.adminAuthMiddleware()) api.GET("/stats", h.GetStats) @@ -167,12 +172,22 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { } // adminAuthMiddleware 管理接口鉴权中间件(增强版,增加安全审计日志) +// +// 安全策略(fail-closed): +// - 未配置 ADMIN_SECRET 时一律拒绝(503),防止 /api/admin/* 裸奔。 +// - 用户应通过前端「首次初始化」页面(无鉴权的 /api/admin/bootstrap 端点) +// 设置初始密钥,或者在 .env 中显式设置 ADMIN_SECRET 后重启。 func (h *Handler) adminAuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { adminSecret, source := h.resolveAdminSecret(c.Request.Context()) if adminSecret == "" { - // 未配置管理密钥,跳过鉴权 - c.Next() + // fail-closed:拒绝并提示用户配置 ADMIN_SECRET + security.SecurityAuditLog("ADMIN_BLOCKED_NO_SECRET", fmt.Sprintf("path=%s ip=%s", c.Request.URL.Path, c.ClientIP())) + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "管理接口未初始化:ADMIN_SECRET 尚未配置。请在浏览器访问 /admin/ 完成首次初始化,或在 .env 中设置 ADMIN_SECRET 后重启。", + "code": "bootstrap_required", + }) + c.Abort() return } diff --git a/config/config.go b/config/config.go index e0ef0de3..17a55ac0 100644 --- a/config/config.go +++ b/config/config.go @@ -70,7 +70,9 @@ func (c *CacheConfig) Label() string { // 业务逻辑参数(如 ProxyURL,APIKeys,MaxConcurrency)已全部移至数据库 SystemSettings 进行化 type Config struct { Port int + BindAddress string // 监听地址,默认 127.0.0.1,公网监听需显式设置为 0.0.0.0 AdminSecret string + AllowAnonymousV1 bool // 显式允许 /v1/* 在未配置 API Key 时无鉴权放行(默认禁止) MaxRequestBodySize int Database DatabaseConfig Cache CacheConfig @@ -98,6 +100,14 @@ func Load(envPath string) (*Config, error) { fmt.Sscanf(port, "%d", &cfg.Port) } cfg.AdminSecret = strings.TrimSpace(os.Getenv("ADMIN_SECRET")) + cfg.AllowAnonymousV1 = parseBoolEnv(os.Getenv("CODEX_ALLOW_ANONYMOUS")) + // 默认绑 0.0.0.0 以兼容 Docker 端口映射、反向代理、生产服务器等常规部署。 + // 安全防护由 fail-closed 中间件 + 自动 ADMIN_SECRET + 启动 banner 共同保证; + // 想要严格仅本机访问的用户可设 CODEX_BIND=127.0.0.1。 + cfg.BindAddress = strings.TrimSpace(os.Getenv("CODEX_BIND")) + if cfg.BindAddress == "" { + cfg.BindAddress = "0.0.0.0" + } if v := strings.TrimSpace(os.Getenv("CODEX_MAX_REQUEST_BODY_SIZE_MB")); v != "" { if mb, err := strconv.Atoi(v); err == nil && mb > 0 { cfg.MaxRequestBodySize = mb * 1024 * 1024 diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 3128d4dd..299d4ee3 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -4,17 +4,75 @@ import { useTranslation } from 'react-i18next' import { ADMIN_AUTH_REQUIRED_EVENT, getAdminKey, setAdminKey } from '../api' import logoImg from '../assets/logo.png' -type AuthStatus = 'checking' | 'authenticated' | 'need_login' +type AuthStatus = 'checking' | 'need_bootstrap' | 'need_login' | 'authenticated' + +const MIN_SECRET_LEN = 8 + +const COPY = { + zh: { + bootstrapTitle: '首次使用:设置管理密钥', + bootstrapSubtitle: '该密钥用于登录管理后台与调用 /api/admin/* 接口,请妥善保管。', + bootstrapHint: `至少 ${MIN_SECRET_LEN} 位`, + secretLabel: '管理密钥', + confirmLabel: '再次输入确认', + submit: '完成初始化并登录', + submitting: '正在保存…', + errEmpty: '管理密钥不能为空', + errTooShort: `管理密钥至少 ${MIN_SECRET_LEN} 位`, + errMismatch: '两次输入不一致', + errServer: '初始化失败,请稍后再试', + loginSubtitle: '请输入管理密钥登录', + loginPlaceholder: '请输入 ADMIN_SECRET', + loginError: '密钥错误,请重新输入', + loginButton: '登录', + loadingText: '加载中…', + }, + en: { + bootstrapTitle: 'First-run setup: choose an admin secret', + bootstrapSubtitle: 'This secret is required for both web login and /api/admin/* API calls. Store it safely.', + bootstrapHint: `At least ${MIN_SECRET_LEN} characters.`, + secretLabel: 'Admin secret', + confirmLabel: 'Confirm secret', + submit: 'Initialize and sign in', + submitting: 'Saving…', + errEmpty: 'Admin secret cannot be empty', + errTooShort: `Admin secret must be at least ${MIN_SECRET_LEN} characters`, + errMismatch: 'The two entries do not match', + errServer: 'Initialization failed, please retry', + loginSubtitle: 'Enter your admin secret to sign in', + loginPlaceholder: 'Enter ADMIN_SECRET', + loginError: 'Invalid secret, please try again', + loginButton: 'Sign in', + loadingText: 'Loading…', + }, +} as const export default function AuthGate({ children }: PropsWithChildren) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const lang = (i18n.language || 'zh').startsWith('zh') ? 'zh' : 'en' + const copy = COPY[lang] + const [status, setStatus] = useState('checking') const [inputKey, setInputKey] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) + const [bsSecret, setBsSecret] = useState('') + const [bsConfirm, setBsConfirm] = useState('') + const [bsError, setBsError] = useState('') + const [bsSubmitting, setBsSubmitting] = useState(false) + const checkAuth = useCallback(async () => { try { + const bsRes = await fetch('/api/admin/bootstrap-status') + if (bsRes.ok) { + const bs = (await bsRes.json()) as { needs_bootstrap?: boolean } + if (bs.needs_bootstrap) { + setStatus('need_bootstrap') + return + } + } + const headers: Record = {} const key = getAdminKey() if (key) headers['X-Admin-Key'] = key @@ -22,6 +80,8 @@ export default function AuthGate({ children }: PropsWithChildren) { if (res.status === 401) { setAdminKey('') setStatus('need_login') + } else if (res.status === 503) { + setStatus('need_bootstrap') } else { setStatus('authenticated') } @@ -42,7 +102,7 @@ export default function AuthGate({ children }: PropsWithChildren) { const handleAuthRequired = () => { setError('') setInputKey('') - setStatus('need_login') + void checkAuth() } const handleStorage = (event: StorageEvent) => { @@ -72,24 +132,118 @@ export default function AuthGate({ children }: PropsWithChildren) { headers: { 'X-Admin-Key': inputKey.trim() }, }) if (res.status === 401) { - setError(t('auth.error')) + setError(copy.loginError) } else { setAdminKey(inputKey.trim()) setStatus('authenticated') } } catch { - setError(t('auth.error')) + setError(copy.loginError) } finally { setSubmitting(false) } } + const handleBootstrap = async () => { + setBsError('') + const secret = bsSecret.trim() + const confirm = bsConfirm.trim() + if (!secret) { + setBsError(copy.errEmpty) + return + } + if (secret.length < MIN_SECRET_LEN) { + setBsError(copy.errTooShort) + return + } + if (secret !== confirm) { + setBsError(copy.errMismatch) + return + } + setBsSubmitting(true) + try { + const res = await fetch('/api/admin/bootstrap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ admin_secret: secret }), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + setBsError(body.error || copy.errServer) + return + } + setAdminKey(secret) + setBsSecret('') + setBsConfirm('') + setStatus('authenticated') + } catch { + setBsError(copy.errServer) + } finally { + setBsSubmitting(false) + } + } + if (status === 'checking') { return (
-

{t('common.loading')}

+

{copy.loadingText}

+
+
+ ) + } + + if (status === 'need_bootstrap') { + return ( +
+
+
+ Codex2API +

Codex2API

+

{copy.bootstrapSubtitle}

+
+ +
+

{copy.bootstrapTitle}

+

{copy.bootstrapHint}

+ +
+
+ + { setBsSecret(e.target.value); setBsError('') }} + className="w-full h-10 px-3.5 rounded-md border border-input bg-background text-[15px] outline-none transition-all focus:border-primary/40 focus:ring-2 focus:ring-primary/10" + autoFocus + /> +
+ +
+ + { setBsConfirm(e.target.value); setBsError('') }} + onKeyDown={(e) => { if (e.key === 'Enter') void handleBootstrap() }} + className="w-full h-10 px-3.5 rounded-md border border-input bg-background text-[15px] outline-none transition-all focus:border-primary/40 focus:ring-2 focus:ring-primary/10" + /> +
+ + {bsError && ( +
{bsError}
+ )} + + +
+
) @@ -101,10 +255,8 @@ export default function AuthGate({ children }: PropsWithChildren) {
Codex2API -

- Codex2API -

-

{t('auth.subtitle')}

+

Codex2API

+

{copy.loginSubtitle}

@@ -116,7 +268,7 @@ export default function AuthGate({ children }: PropsWithChildren) { value={inputKey} onChange={(e) => { setInputKey(e.target.value); setError('') }} onKeyDown={(e) => { if (e.key === 'Enter') void handleLogin() }} - placeholder={t('auth.placeholder')} + placeholder={copy.loginPlaceholder} autoFocus className="w-full h-10 px-3.5 rounded-md border border-input bg-background text-[15px] outline-none transition-all focus:border-primary/40 focus:ring-2 focus:ring-primary/10" /> @@ -131,7 +283,7 @@ export default function AuthGate({ children }: PropsWithChildren) { disabled={submitting} className="w-full h-10 rounded-md bg-primary text-primary-foreground font-semibold text-[15px] shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50" > - {submitting ? t('common.loading') : t('auth.login')} + {submitting ? copy.loadingText : copy.loginButton}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 413ce4ad..86edfa20 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import logoImg from '../assets/logo.png' import { useTheme } from '../hooks/useTheme' import { useVersionCheck } from '../hooks/useVersionCheck' +import SecurityBanner from './SecurityBanner' type NavDef = { to: string @@ -178,6 +179,7 @@ export default function Layout({ children }: PropsWithChildren) {
+
{children}
diff --git a/frontend/src/components/SecurityBanner.tsx b/frontend/src/components/SecurityBanner.tsx new file mode 100644 index 00000000..90e20e2d --- /dev/null +++ b/frontend/src/components/SecurityBanner.tsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ShieldAlert, X } from 'lucide-react' +import { api } from '../api' + +const DISMISS_STORAGE_KEY = 'codex2api_security_banner_dismissed_at' +const DISMISS_TTL_MS = 24 * 60 * 60 * 1000 // 用户手动关闭后 24h 内不再骚扰 + +const COPY = { + zh: { + title: '安全提示:尚未配置对外 API Key', + body: '/v1/* 接口在创建第一把 API Key 之前会拒绝所有请求(503)。请进入「API 密钥」页面创建至少一把 Key,再向客户端分发。', + cta: '前往创建', + dismiss: '我知道了', + }, + en: { + title: 'Security notice: no public API key has been configured', + body: 'All /v1/* requests will be refused (503) until you create at least one API key. Open the API Keys page to create one before exposing this service.', + cta: 'Create API key', + dismiss: 'Dismiss', + }, +} as const + +export default function SecurityBanner() { + const { i18n } = useTranslation() + const [keyCount, setKeyCount] = useState(null) + const [dismissed, setDismissed] = useState(() => { + const ts = Number(localStorage.getItem(DISMISS_STORAGE_KEY) ?? '0') + return ts > 0 && Date.now() - ts < DISMISS_TTL_MS + }) + + const refresh = useCallback(async () => { + try { + const res = await api.getAPIKeys() + setKeyCount((res.keys ?? []).length) + } catch { + setKeyCount(null) // 401/网络异常时不显示,避免登录前打扰 + } + }, []) + + useEffect(() => { + void refresh() + const timer = window.setInterval(() => { + void refresh() + }, 60_000) + return () => window.clearInterval(timer) + }, [refresh]) + + if (dismissed) return null + if (keyCount === null) return null + if (keyCount > 0) return null + + const lang = (i18n.language || 'zh').startsWith('zh') ? 'zh' : 'en' + const copy = COPY[lang] + + const handleDismiss = () => { + localStorage.setItem(DISMISS_STORAGE_KEY, String(Date.now())) + setDismissed(true) + } + + return ( +
+ +
+

{copy.title}

+

{copy.body}

+
+ + {copy.cta} + + +
+
+ +
+ ) +} diff --git a/main.go b/main.go index b76b1ede..adc3a814 100644 --- a/main.go +++ b/main.go @@ -261,11 +261,19 @@ func main() { }) }) - addr := fmt.Sprintf(":%d", cfg.Port) + // 6.5 启动安全状态自检 banner + printSecurityBanner(db, cfg, settings) + + addr := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port) + displayHost := cfg.BindAddress + if displayHost == "0.0.0.0" || displayHost == "::" { + displayHost = "localhost" + } log.Println("==========================================") log.Printf(" Codex2API v2 已启动") - log.Printf(" HTTP: http://0.0.0.0%s", addr) - log.Printf(" 管理台: http://0.0.0.0%s/admin/", addr) + log.Printf(" Listen: %s", addr) + log.Printf(" HTTP: http://%s:%d", displayHost, cfg.Port) + log.Printf(" 管理台: http://%s:%d/admin/", displayHost, cfg.Port) log.Printf(" API: POST /v1/chat/completions") log.Printf(" API: POST /v1/responses") log.Printf(" API: POST /v1/images/generations") @@ -342,6 +350,76 @@ func loggerMiddleware() gin.HandlerFunc { } } +// printSecurityBanner 启动时打印安全状态自检 banner: +// - 不再自动生成 ADMIN_SECRET。若两端都空,则提示用户首次访问页面进行初始化。 +// - 检查 API Key 数量、监听地址、匿名开关,命中风险时给出对应提示。 +func printSecurityBanner(db *database.DB, cfg *config.Config, settings *database.SystemSettings) { + if db == nil || cfg == nil || settings == nil { + return + } + + envSecret := strings.TrimSpace(cfg.AdminSecret) + dbSecret := strings.TrimSpace(settings.AdminSecret) + needsBootstrap := envSecret == "" && dbSecret == "" + + apiKeyCount := 0 + { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if rows, err := db.ListAPIKeys(ctx); err == nil { + apiKeyCount = len(rows) + } + cancel() + } + + bind := strings.TrimSpace(cfg.BindAddress) + publicBind := bind == "" || bind == "0.0.0.0" || bind == "::" + const sep = "==========================================================" + log.Println(sep) + log.Println("[SECURITY] Codex2API 安全状态自检") + log.Println(sep) + + switch { + case needsBootstrap: + log.Println("⚠ 尚未配置 ADMIN_SECRET(环境变量与数据库均为空)。") + log.Printf(" 请使用浏览器访问管理台 http://%s:%d/admin/ 完成首次初始化,", bannerDisplayHost(bind), cfg.Port) + log.Println(" 设置一个强随机的管理密钥;该密钥也将作为登录密钥。") + log.Println(" 在初始化完成之前,所有 /api/admin/* 接口(除初始化端点外)均返回 503。") + case envSecret != "": + log.Println("✓ ADMIN_SECRET 来源:环境变量 (.env)") + default: + log.Println("✓ ADMIN_SECRET 来源:数据库(如需修改请进入「设置」页面)") + } + + if apiKeyCount == 0 { + if cfg.AllowAnonymousV1 { + log.Println("⚠ /v1/* 当前处于【匿名访问】模式(CODEX_ALLOW_ANONYMOUS=true)。") + log.Println(" 任何能访问端口的人均可调用 /v1/* 消耗你的账号池配额,请仅在内网/测试环境使用!") + } else { + log.Println("⚠ 尚未创建任何对外 API Key。/v1/* 接口在创建第一把 Key 之前会返回 503。") + log.Println(" 请进入管理台「API 密钥」页面创建至少一把 Key 后再对外提供服务。") + } + } else { + log.Printf("✓ 已配置 %d 个对外 API Key,/v1/* 强制鉴权已生效。", apiKeyCount) + } + + if publicBind { + log.Printf("ℹ 监听地址 = %s (所有网卡,兼容 Docker / 反代 / 公网)。", bind) + log.Println(" 生产环境请确认已部署反向代理 + HTTPS、配置防火墙白名单,并使用强 ADMIN_SECRET 与 API Key。") + log.Println(" 如希望服务只在本机回环可达,可设置 CODEX_BIND=127.0.0.1。") + } else { + log.Printf("✓ 监听地址 = %s (受限访问)。", bind) + } + + log.Println(sep) +} + +func bannerDisplayHost(bind string) string { + if bind == "" || bind == "0.0.0.0" || bind == "::" { + return "" + } + return bind +} + func shouldSkipAccessLog(method string, path string, status int) bool { if status >= http.StatusBadRequest { return false diff --git a/proxy/handler.go b/proxy/handler.go index 92a7997b..fbc5653d 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -551,11 +551,28 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { } // authMiddleware API Key 鉴权中间件(增强版,带安全日志) +// +// 安全策略(fail-closed): +// - 默认情况下,未配置任何 API Key 时直接拒绝请求(503),避免裸奔账号池。 +// - 仅当显式设置 CODEX_ALLOW_ANONYMOUS=true 时才在无密钥情况下放行(兼容内网/测试)。 func (h *Handler) authMiddleware() gin.HandlerFunc { + allowAnonymous := h.cfg != nil && h.cfg.AllowAnonymousV1 return func(c *gin.Context) { - // 如果没有配置任何密钥,跳过鉴权 + // 如果没有配置任何密钥 if !h.hasAnyKeys() { - c.Next() + if allowAnonymous { + // 显式允许匿名访问(旧行为,仅在 CODEX_ALLOW_ANONYMOUS=true 时启用) + c.Next() + return + } + // fail-closed:未配置 API Key 即拒绝,避免账号池被未授权调用 + security.SecurityAuditLog("V1_BLOCKED_NO_KEYS", fmt.Sprintf("path=%s ip=%s", c.Request.URL.Path, c.ClientIP())) + api.SendError(c, api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service is not configured: no API key has been created yet. Please add at least one API key in the admin dashboard, or set CODEX_ALLOW_ANONYMOUS=true to disable this check.", + api.ErrorTypeServer, + )) + c.Abort() return } From 4d0b51f29cce0393fe776c8f8ca76cc790889082 Mon Sep 17 00:00:00 2001 From: ImogeneOctaviap794 Date: Thu, 30 Apr 2026 15:39:41 +0800 Subject: [PATCH 2/2] review: address CodeRabbit feedback - config/config.go: align comments with current behaviour (default bind 0.0.0.0, security guarded by fail-closed + browser bootstrap, no auto-generated secret) - .env.example, .env.sqlite.example: rewrite ADMIN_SECRET notes to reflect the browser-based first-run bootstrap flow - admin/bootstrap.go: harden bootstrapAllowRate() with CAS-based window rollover so concurrent goroutines cannot reset the counter twice and exceed bootstrapMaxPerWin - frontend AuthGate: mirror backend max-length check (256 chars) to fail fast in the UI before sending the request - frontend SecurityBanner: soften absolute wording, mention CODEX_ALLOW_ANONYMOUS exception so the copy is consistent with the actual server behaviour --- .env.example | 5 +++-- .env.sqlite.example | 5 +++-- admin/bootstrap.go | 20 ++++++++++++++++---- config/config.go | 4 ++-- frontend/src/components/AuthGate.tsx | 7 +++++++ frontend/src/components/SecurityBanner.tsx | 4 ++-- 6 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 155dff25..a288328c 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,9 @@ CODEX_PORT=8080 # CODEX_BIND=0.0.0.0 # 管理后台登录密钥 -# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次) -# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成 +# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存 +# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503 +# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化 # ADMIN_SECRET=your-admin-password # /v1/* 匿名访问开关(默认 false,强烈建议保持默认) diff --git a/.env.sqlite.example b/.env.sqlite.example index 56707047..1276fc7e 100644 --- a/.env.sqlite.example +++ b/.env.sqlite.example @@ -11,8 +11,9 @@ CODEX_PORT=8080 # CODEX_BIND=0.0.0.0 # 管理后台登录密钥 -# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次) -# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成 +# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存 +# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503 +# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化 # ADMIN_SECRET=your-admin-password # /v1/* 匿名访问开关(默认 false,强烈建议保持默认) diff --git a/admin/bootstrap.go b/admin/bootstrap.go index 643a6dba..7390f38a 100644 --- a/admin/bootstrap.go +++ b/admin/bootstrap.go @@ -32,12 +32,24 @@ const ( bootstrapMaxSecret = 256 ) +// bootstrapAllowRate 使用 CAS 实现固定窗口限频: +// - 任意时刻只有一个 goroutine 能成功翻新窗口起点,其它失败者读到的就是 +// 翻新后的最新值,避免多个 goroutine 同时把 count 重置为 0。 +// - 在并发下,最坏情况只是有一个 reset 与若干 Add 交错,但所有"翻窗" +// 操作都是原子的,不会出现窗口被重复清零导致超额放行的情况。 func bootstrapAllowRate() bool { now := time.Now().Unix() - winStart := bootstrapState.windowStart.Load() - if now-winStart >= bootstrapWindowSec { - bootstrapState.windowStart.Store(now) - bootstrapState.count.Store(0) + for { + winStart := bootstrapState.windowStart.Load() + if now-winStart < bootstrapWindowSec { + break + } + // 仅当 windowStart 仍是我们读到的旧值时才推进;其它 goroutine 已经 + // 推进过的话直接退出循环,复用最新窗口。 + if bootstrapState.windowStart.CompareAndSwap(winStart, now) { + bootstrapState.count.Store(0) + break + } } return bootstrapState.count.Add(1) <= bootstrapMaxPerWin } diff --git a/config/config.go b/config/config.go index 17a55ac0..6c0733e7 100644 --- a/config/config.go +++ b/config/config.go @@ -70,7 +70,7 @@ func (c *CacheConfig) Label() string { // 业务逻辑参数(如 ProxyURL,APIKeys,MaxConcurrency)已全部移至数据库 SystemSettings 进行化 type Config struct { Port int - BindAddress string // 监听地址,默认 127.0.0.1,公网监听需显式设置为 0.0.0.0 + BindAddress string // 监听地址,默认 0.0.0.0(兼容 Docker / 反代 / 公网);如需仅本机访问可设为 127.0.0.1 AdminSecret string AllowAnonymousV1 bool // 显式允许 /v1/* 在未配置 API Key 时无鉴权放行(默认禁止) MaxRequestBodySize int @@ -102,7 +102,7 @@ func Load(envPath string) (*Config, error) { cfg.AdminSecret = strings.TrimSpace(os.Getenv("ADMIN_SECRET")) cfg.AllowAnonymousV1 = parseBoolEnv(os.Getenv("CODEX_ALLOW_ANONYMOUS")) // 默认绑 0.0.0.0 以兼容 Docker 端口映射、反向代理、生产服务器等常规部署。 - // 安全防护由 fail-closed 中间件 + 自动 ADMIN_SECRET + 启动 banner 共同保证; + // 安全防护由 fail-closed 中间件 + 首启自助初始化 (/api/admin/bootstrap) + 启动 banner 共同保证; // 想要严格仅本机访问的用户可设 CODEX_BIND=127.0.0.1。 cfg.BindAddress = strings.TrimSpace(os.Getenv("CODEX_BIND")) if cfg.BindAddress == "" { diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 299d4ee3..291c7c39 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -7,6 +7,7 @@ import logoImg from '../assets/logo.png' type AuthStatus = 'checking' | 'need_bootstrap' | 'need_login' | 'authenticated' const MIN_SECRET_LEN = 8 +const MAX_SECRET_LEN = 256 const COPY = { zh: { @@ -19,6 +20,7 @@ const COPY = { submitting: '正在保存…', errEmpty: '管理密钥不能为空', errTooShort: `管理密钥至少 ${MIN_SECRET_LEN} 位`, + errTooLong: `管理密钥不可超过 ${MAX_SECRET_LEN} 个字符`, errMismatch: '两次输入不一致', errServer: '初始化失败,请稍后再试', loginSubtitle: '请输入管理密钥登录', @@ -37,6 +39,7 @@ const COPY = { submitting: 'Saving…', errEmpty: 'Admin secret cannot be empty', errTooShort: `Admin secret must be at least ${MIN_SECRET_LEN} characters`, + errTooLong: `Admin secret must not exceed ${MAX_SECRET_LEN} characters`, errMismatch: 'The two entries do not match', errServer: 'Initialization failed, please retry', loginSubtitle: 'Enter your admin secret to sign in', @@ -156,6 +159,10 @@ export default function AuthGate({ children }: PropsWithChildren) { setBsError(copy.errTooShort) return } + if (secret.length > MAX_SECRET_LEN) { + setBsError(copy.errTooLong) + return + } if (secret !== confirm) { setBsError(copy.errMismatch) return diff --git a/frontend/src/components/SecurityBanner.tsx b/frontend/src/components/SecurityBanner.tsx index 90e20e2d..fab1ab57 100644 --- a/frontend/src/components/SecurityBanner.tsx +++ b/frontend/src/components/SecurityBanner.tsx @@ -9,13 +9,13 @@ const DISMISS_TTL_MS = 24 * 60 * 60 * 1000 // 用户手动关闭后 24h 内不 const COPY = { zh: { title: '安全提示:尚未配置对外 API Key', - body: '/v1/* 接口在创建第一把 API Key 之前会拒绝所有请求(503)。请进入「API 密钥」页面创建至少一把 Key,再向客户端分发。', + body: '默认情况下,/v1/* 接口在创建第一把 API Key 之前会拒绝所有请求(503)。请进入「API 密钥」页面创建至少一把 Key,再向客户端分发。(注意:如果服务端启用了 CODEX_ALLOW_ANONYMOUS=true,匿名调用将被放行,仅建议在内网/测试场景使用。)', cta: '前往创建', dismiss: '我知道了', }, en: { title: 'Security notice: no public API key has been configured', - body: 'All /v1/* requests will be refused (503) until you create at least one API key. Open the API Keys page to create one before exposing this service.', + body: 'By default, /v1/* requests are refused (503) until at least one API key is created. Open the API Keys page to create one before exposing this service. (Note: when the server is started with CODEX_ALLOW_ANONYMOUS=true, anonymous traffic is allowed — recommended for internal/testing scenarios only.)', cta: 'Create API key', dismiss: 'Dismiss', },