-
Notifications
You must be signed in to change notification settings - Fork 252
[security] /v1/* 与 /api/admin/* 默认 fail-closed;新增首启自助初始化与全局未配置 Key 警告 #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
james-6-23
merged 2 commits into
james-6-23:main
from
ImogeneOctaviap794:security/fail-closed-and-bootstrap
Apr 30, 2026
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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) { | ||
|
Comment on lines
+98
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment says 12 runes but constant is 8. The doc comment on line 104 states "校验最小长度(12 个 rune)" but 📝 Suggested fix // 安全约束:
// 1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409;
// 2. 通过互斥锁 + 双重检查避免并发写入;
// 3. 简单全局限频,防止扫描器穷举;
-// 4. 校验最小长度(12 个 rune),避免过弱密钥;
+// 4. 校验最小长度(8 个 rune),避免过弱密钥;
// 5. 全程审计日志。🤖 Prompt for AI Agents |
||
| 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: "[]", | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.