diff --git a/.dockerignore b/.dockerignore index 084c85721..7aa702f26 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,14 @@ Thumbs.db # Env .env .env.local +.env.* +!.env.example + +# Logs and local worktrees must not enter the build context. +logs/ +*.log +.worktrees/ +.local/ # Local credentials and account-test fixtures must never enter a build context. .private-account-fixtures/ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 9219d629d..b8584c791 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -30,6 +30,19 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Validate build version + env: + BUILD_VERSION: ${{ inputs.build_version || github.ref_name }} + run: | + if [[ "$BUILD_VERSION" == *$'\n'* || "$BUILD_VERSION" == *$'\r'* ]]; then + echo "Invalid BUILD_VERSION: newlines are not allowed" >&2 + exit 1 + fi + if ! [[ "$BUILD_VERSION" =~ ^[0-9A-Za-z._-]{1,64}$ ]]; then + echo "Invalid BUILD_VERSION: must match ^[0-9A-Za-z._-]{1,64}$" >&2 + exit 1 + fi + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c2d59c97a..a2c986070 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -126,10 +126,10 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: go.mod - - name: Test PostgreSQL trace, indexes, capabilities and intelligence checks + - name: Test PostgreSQL compatibility and pending-account transactions env: CODEX2API_TEST_POSTGRES_DSN: postgres://codex2api_test:test-only@localhost:5432/codex2api_test?sslmode=disable - run: go test -race -count=1 ./database -run '^(TestPostgresTraceAndCapabilities|TestIntelligenceCheckPostgres)$' + run: go test -race -count=1 ./database -run '^(TestPostgresTraceAndCapabilities|TestIntelligenceCheckPostgres|TestPendingAccountTransaction)$' test-race: name: test-race (${{ matrix.name }}) diff --git a/Dockerfile b/Dockerfile index bfe0fb9b2..bcee6122f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,10 @@ RUN apk --no-cache add ca-certificates tzdata COPY --from=go-builder /codex2api /usr/local/bin/codex2api +# 默认 LOG_DIR=logs 是相对路径;固定工作目录才能落到 compose 挂载的 /app/logs, +# 否则审计日志会写到容器根目录的 /logs,容器重建后丢失。 +WORKDIR /app + EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/codex2api"] diff --git a/admin/accounts_paged.go b/admin/accounts_paged.go index 5b6e91cf0..aed37e0c5 100644 --- a/admin/accounts_paged.go +++ b/admin/accounts_paged.go @@ -300,6 +300,9 @@ func parseAccountPageQuery(c *gin.Context) (accountPageQuery, error) { if err != nil || value < 1 { return query, fmt.Errorf("page must be a positive integer") } + if value > maxAdminPage { + return query, fmt.Errorf("page is too large") + } query.Page = value } if raw := strings.TrimSpace(c.Query("page_size")); raw != "" { diff --git a/admin/auth_throttle.go b/admin/auth_throttle.go new file mode 100644 index 000000000..174510d8a --- /dev/null +++ b/admin/auth_throttle.go @@ -0,0 +1,120 @@ +package admin + +import ( + "sync" + "time" +) + +const ( + adminAuthFailureWindow = 10 * time.Minute + adminAuthBlockDuration = 10 * time.Minute + adminAuthMaxFailures = 5 + adminAuthMaxEntries = 4096 +) + +// adminAuthThrottle 记录按 IP 的管理端鉴权失败次数。 +// 只在失败时计数;达到阈值后在密钥比较前阻断该 IP 的所有认证尝试。 +type adminAuthThrottle struct { + mu sync.Mutex + entries map[string]*adminAuthThrottleEntry +} + +type adminAuthThrottleEntry struct { + failures int + windowStart time.Time + blockedUntil time.Time +} + +func newAdminAuthThrottle() *adminAuthThrottle { + return &adminAuthThrottle{entries: make(map[string]*adminAuthThrottleEntry)} +} + +// retryAfter 返回该 IP 剩余的阻断时间;未阻断时返回 0。 +func (t *adminAuthThrottle) retryAfter(ip string, now time.Time) time.Duration { + if t == nil { + return 0 + } + t.mu.Lock() + defer t.mu.Unlock() + entry := t.entries[ip] + if entry == nil { + t.evictLocked(now) + return t.capacityRetryAfterLocked(now) + } + if !now.Before(entry.blockedUntil) { + return 0 + } + return entry.blockedUntil.Sub(now) +} + +// recordFailure 记录一次失败;达到阈值时开始阻断并返回阻断时长。 +func (t *adminAuthThrottle) recordFailure(ip string, now time.Time) time.Duration { + if t == nil { + return 0 + } + t.mu.Lock() + defer t.mu.Unlock() + entry := t.entries[ip] + if entry == nil { + t.evictLocked(now) + if retry := t.capacityRetryAfterLocked(now); retry > 0 { + return retry + } + entry = &adminAuthThrottleEntry{windowStart: now} + t.entries[ip] = entry + } + if now.Before(entry.blockedUntil) { + return entry.blockedUntil.Sub(now) + } + if !now.Before(entry.windowStart.Add(adminAuthFailureWindow)) { + entry.failures = 0 + entry.windowStart = now + } + entry.failures++ + if entry.failures < adminAuthMaxFailures { + return 0 + } + entry.failures = 0 + entry.windowStart = now + entry.blockedUntil = now.Add(adminAuthBlockDuration) + return adminAuthBlockDuration +} + +func (t *adminAuthThrottle) reset(ip string) { + if t == nil { + return + } + t.mu.Lock() + delete(t.entries, ip) + t.mu.Unlock() +} + +// evictLocked only removes expired entries; live failures and blocks survive saturation. +func (t *adminAuthThrottle) evictLocked(now time.Time) { + if len(t.entries) < adminAuthMaxEntries { + return + } + for ip, entry := range t.entries { + if !now.Before(entry.blockedUntil) && !now.Before(entry.windowStart.Add(adminAuthFailureWindow)) { + delete(t.entries, ip) + } + } +} + +// Untracked sources wait for the first slot to expire before key comparison. +func (t *adminAuthThrottle) capacityRetryAfterLocked(now time.Time) time.Duration { + if len(t.entries) < adminAuthMaxEntries { + return 0 + } + var earliest time.Time + for _, entry := range t.entries { + expires := entry.windowStart.Add(adminAuthFailureWindow) + if entry.blockedUntil.After(expires) { + expires = entry.blockedUntil + } + if earliest.IsZero() || expires.Before(earliest) { + earliest = expires + } + } + return earliest.Sub(now) +} diff --git a/admin/auth_throttle_test.go b/admin/auth_throttle_test.go new file mode 100644 index 000000000..12fb1064c --- /dev/null +++ b/admin/auth_throttle_test.go @@ -0,0 +1,168 @@ +package admin + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestAdminAuthThrottleBlocksAfterFailures(t *testing.T) { + throttle := newAdminAuthThrottle() + now := time.Unix(1_700_000_000, 0) + + for i := 0; i < adminAuthMaxFailures-1; i++ { + if blocked := throttle.recordFailure("203.0.113.9", now); blocked != 0 { + t.Fatalf("failure %d blocked early: %v", i+1, blocked) + } + } + if retry := throttle.retryAfter("203.0.113.9", now); retry != 0 { + t.Fatalf("retryAfter before threshold = %v, want 0", retry) + } + + if blocked := throttle.recordFailure("203.0.113.9", now); blocked != adminAuthBlockDuration { + t.Fatalf("threshold failure blocked for %v, want %v", blocked, adminAuthBlockDuration) + } + if retry := throttle.retryAfter("203.0.113.9", now.Add(time.Minute)); retry != adminAuthBlockDuration-time.Minute { + t.Fatalf("retryAfter = %v, want %v", retry, adminAuthBlockDuration-time.Minute) + } + if retry := throttle.retryAfter("203.0.113.10", now); retry != 0 { + t.Fatalf("other IP should not be blocked, got %v", retry) + } + if retry := throttle.retryAfter("203.0.113.9", now.Add(adminAuthBlockDuration)); retry != 0 { + t.Fatalf("block should expire, got %v", retry) + } +} + +func TestAdminAuthThrottleResetsOnSuccess(t *testing.T) { + throttle := newAdminAuthThrottle() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < adminAuthMaxFailures-1; i++ { + throttle.recordFailure("203.0.113.11", now) + } + throttle.reset("203.0.113.11") + if blocked := throttle.recordFailure("203.0.113.11", now); blocked != 0 { + t.Fatalf("reset should clear failure count, blocked for %v", blocked) + } +} + +func TestAdminAuthThrottleExpiresOldFailures(t *testing.T) { + throttle := newAdminAuthThrottle() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < adminAuthMaxFailures-1; i++ { + throttle.recordFailure("203.0.113.12", now) + } + later := now.Add(adminAuthFailureWindow + time.Second) + if blocked := throttle.recordFailure("203.0.113.12", later); blocked != 0 { + t.Fatalf("failures outside the window should not accumulate, blocked for %v", blocked) + } +} + +func TestAdminAuthMiddlewareThrottlesFailedAttempts(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newSelfServiceHandler(t, false) + router := gin.New() + handler.RegisterRoutes(router) + + request := func(key string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", nil) + req.Header.Set("X-Admin-Key", key) + router.ServeHTTP(rec, req) + return rec + } + + for i := 0; i < adminAuthMaxFailures-1; i++ { + if rec := request("wrong-key"); rec.Code != http.StatusUnauthorized { + t.Fatalf("failure %d returned %d, want 401", i+1, rec.Code) + } + } + if rec := request("wrong-key"); rec.Code != http.StatusTooManyRequests { + t.Fatalf("threshold failure returned %d, want 429", rec.Code) + } + if rec := request("wrong-key"); rec.Code != http.StatusTooManyRequests { + t.Fatalf("failure during block returned %d, want 429", rec.Code) + } else if rec.Header().Get("Retry-After") == "" { + t.Fatal("429 response missing Retry-After") + } + if rec := request("admin-secret"); rec.Code != http.StatusTooManyRequests { + t.Fatalf("correct key during block returned %d, want 429 (no password oracle)", rec.Code) + } + // Expiry must allow normal authentication again, including no-store headers. + handler.adminAuthThrottle.mu.Lock() + for _, entry := range handler.adminAuthThrottle.entries { + entry.blockedUntil = time.Now().Add(-time.Second) + } + handler.adminAuthThrottle.mu.Unlock() + if rec := request("admin-secret"); rec.Code != http.StatusOK { + t.Fatalf("correct key after expiry returned %d, want 200", rec.Code) + } else if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("admin response Cache-Control = %q, want no-store", got) + } + if rec := request("wrong-key"); rec.Code != http.StatusUnauthorized { + t.Fatalf("failure after successful login returned %d, want 401", rec.Code) + } +} + +func TestAdminAuthThrottleBoundsTrackedIPs(t *testing.T) { + throttle := newAdminAuthThrottle() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < adminAuthMaxEntries+200; i++ { + throttle.recordFailure(fmt.Sprintf("198.51.100.%d", i), now) + } + if got := len(throttle.entries); got > adminAuthMaxEntries { + t.Fatalf("tracked IPs = %d, want <= %d", got, adminAuthMaxEntries) + } +} + +func TestAdminAuthThrottleSaturationPreservesLiveEntries(t *testing.T) { + throttle := newAdminAuthThrottle() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < adminAuthMaxEntries; i++ { + ip := fmt.Sprintf("source-%d", i) + throttle.entries[ip] = &adminAuthThrottleEntry{windowStart: now, failures: adminAuthMaxFailures - 1} + } + throttle.recordFailure("source-0", now) + if retry := throttle.retryAfter("untracked", now.Add(time.Minute)); retry != 9*time.Minute { + t.Fatalf("saturated admission retry = %v, want 9m", retry) + } + if retry := throttle.recordFailure("untracked", now.Add(time.Minute)); retry == 0 { + t.Fatal("saturated failure tracking accepted a new source") + } + if retry := throttle.retryAfter("source-0", now.Add(time.Minute)); retry != 9*time.Minute { + t.Fatalf("live block was evicted: %v", retry) + } + if got := throttle.recordFailure("source-1", now.Add(time.Minute)); got != adminAuthBlockDuration { + t.Fatalf("live failure count was lost: %v", got) + } + if len(throttle.entries) != adminAuthMaxEntries { + t.Fatalf("table size = %d", len(throttle.entries)) + } + if retry := throttle.retryAfter("untracked", now.Add(adminAuthFailureWindow)); retry != 0 { + t.Fatalf("expired slots did not recover: %v", retry) + } + if retry := throttle.retryAfter("source-1", now.Add(adminAuthFailureWindow)); retry != time.Minute { + t.Fatalf("later block must survive cleanup: %v", retry) + } +} + +func TestAdminAuthMiddlewareRejectsUntrackedSourceWhenFull(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newSelfServiceHandler(t, false) + now := time.Now() + for i := 0; i < adminAuthMaxEntries; i++ { + handler.adminAuthThrottle.entries[fmt.Sprintf("source-%d", i)] = &adminAuthThrottleEntry{windowStart: now} + } + router := gin.New() + handler.RegisterRoutes(router) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", nil) + req.Header.Set("X-Admin-Key", "admin-secret") + router.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" { + t.Fatalf("untracked valid candidate at capacity = %d %s, want 429 with Retry-After", rec.Code, rec.Body.String()) + } +} diff --git a/admin/handler.go b/admin/handler.go index e6c5020a0..176cca4f3 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -110,6 +110,7 @@ type Handler struct { cacheDriver string cacheLabel string adminSecretEnv string + adminAuthThrottle *adminAuthThrottle imageProxy *proxy.Handler antigravitySyncAccount func(context.Context, int64) antigravityRefreshItem antigravityCapabilityProbe antigravityCapabilityExecutor @@ -428,6 +429,9 @@ const ( importDBWaitBackoff = 10 * time.Second ) +// maxAdminPage 限制分页页码,避免 (page-1)*page_size 溢出为负 OFFSET。 +const maxAdminPage = 100000 + type importRuntimeLoadSnapshot struct { RPM int64 Active int64 @@ -1042,6 +1046,7 @@ func NewHandler(store *auth.Store, db *database.DB, tc cache.TokenCache, rl *pro cacheDriver: tc.Driver(), cacheLabel: tc.Label(), adminSecretEnv: adminSecretEnv, + adminAuthThrottle: newAdminAuthThrottle(), imageProxy: proxy.NewHandler(store, db, nil, nil), chartCacheData: make(map[string]*chartCacheEntry), accountListCache: make(map[string]*accountListSnapshot), @@ -1436,19 +1441,43 @@ func (h *Handler) adminAuthMiddleware() gin.HandlerFunc { } } + clientIP := c.ClientIP() + // Check the block before comparing any candidate. Accepting a correct + // candidate during a block would leave an unlimited password oracle. + if retry := h.adminAuthThrottle.retryAfter(clientIP, time.Now()); retry > 0 { + c.Header("Retry-After", strconv.Itoa(int((retry+time.Second-1)/time.Second))) + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "认证失败次数过多,请稍后再试", + }) + return + } + // 清理输入 adminKey = security.SanitizeInput(adminKey) // 使用安全比较防止时序攻击 if !security.SecureCompare(adminKey, adminSecret) { // 记录安全审计日志 - security.SecurityAuditLog("ADMIN_AUTH_FAILED", fmt.Sprintf("path=%s ip=%s source=%s", c.Request.URL.Path, c.ClientIP(), source)) + security.SecurityAuditLog("ADMIN_AUTH_FAILED", fmt.Sprintf("path=%s ip=%s source=%s", c.Request.URL.Path, security.SanitizeLog(clientIP), source)) + if blocked := h.adminAuthThrottle.recordFailure(clientIP, time.Now()); blocked > 0 { + c.Header("Retry-After", strconv.Itoa(int((blocked+time.Second-1)/time.Second))) + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "认证失败次数过多,请稍后再试", + }) + c.Abort() + return + } c.JSON(http.StatusUnauthorized, gin.H{ "error": "管理密钥无效或缺失", }) c.Abort() return } + h.adminAuthThrottle.reset(clientIP) + + // 管理端响应包含 raw_key、auth.json、settings 等秘密,禁止中间缓存保存。 + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") // 成功认证,记录审计日志 if security.IsSensitiveEndpoint(c.Request.URL.Path) { @@ -8618,10 +8647,12 @@ type createKeyReq struct { } // generateKey 生成随机 API Key -func generateKey() string { +func generateKey() (string, error) { b := make([]byte, 24) - rand.Read(b) - return "sk-" + hex.EncodeToString(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate api key: %w", err) + } + return "sk-" + hex.EncodeToString(b), nil } // CreateAPIKey 创建新 API 密钥(增强版,带输入验证) @@ -8678,7 +8709,12 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { key := req.Key if key == "" { - key = generateKey() + var err error + key, err = generateKey() + if err != nil { + writeInternalError(c, err) + return + } } else { // 验证用户提供的key格式 key = security.SanitizeInput(key) @@ -10955,7 +10991,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { if req.ProxyURL != nil { h.store.SetProxyURL(*req.ProxyURL) - log.Printf("设置已更新: proxy_url = %s", *req.ProxyURL) + log.Printf("设置已更新: proxy_url = %s", security.MaskURLCredentials(*req.ProxyURL)) } if req.PgMaxConns != nil { @@ -11140,7 +11176,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { v := strings.TrimSpace(*req.GithubProxyURL) h.store.SetGithubProxyURL(v) runtimeCfg.GithubProxyURL = v - log.Printf("设置已更新: github_proxy_url = %s", v) + log.Printf("设置已更新: github_proxy_url = %s", security.MaskURLCredentials(v)) } // Codex 过载熔断(配置只存 RuntimeSettings,热更新生效) diff --git a/admin/image_mime_test.go b/admin/image_mime_test.go new file mode 100644 index 000000000..0a62e5193 --- /dev/null +++ b/admin/image_mime_test.go @@ -0,0 +1,26 @@ +package admin + +import "testing" + +func TestSafeInlineImageMimeType(t *testing.T) { + tests := []struct { + input string + wantType string + wantInline bool + }{ + {"image/png", "image/png", true}, + {"IMAGE/JPEG; charset=binary", "image/jpeg", true}, + {"image/webp", "image/webp", true}, + {"image/gif", "image/gif", true}, + {"text/html", "application/octet-stream", false}, + {"image/svg+xml", "application/octet-stream", false}, + {"", "application/octet-stream", false}, + } + for _, test := range tests { + gotType, gotInline := safeInlineImageMimeType(test.input) + if gotType != test.wantType || gotInline != test.wantInline { + t.Errorf("safeInlineImageMimeType(%q) = (%q, %v), want (%q, %v)", + test.input, gotType, gotInline, test.wantType, test.wantInline) + } + } +} diff --git a/admin/image_studio.go b/admin/image_studio.go index 5500cc30d..5073265c7 100644 --- a/admin/image_studio.go +++ b/admin/image_studio.go @@ -707,12 +707,23 @@ func (h *Handler) serveImageAssetFile(c *gin.Context, asset *database.ImageAsset if opts.download { disposition = "attachment" } + contentType, inlineSafe := safeInlineImageMimeType(asset.MimeType) + if !inlineSafe { + // 上游或客户端回显的 MIME 不可信;非白名单类型强制下载,避免 text/html、 + // SVG 等在同源页面里执行脚本。 + disposition = "attachment" + } filename := sanitizeDownloadFilename(asset.Filename) if opts.thumbKB > 0 && !opts.download { - data, contentType, err := cachedImageThumbnail(c.Request.Context(), asset, backend, opts.thumbKB) + data, thumbType, err := cachedImageThumbnail(c.Request.Context(), asset, backend, opts.thumbKB) if err == nil { - c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, thumbnailFilename(filename))) - c.Data(http.StatusOK, contentType, data) + thumbDisposition := "inline" + thumbContentType, thumbSafe := safeInlineImageMimeType(thumbType) + if !thumbSafe { + thumbDisposition = "attachment" + } + c.Header("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, thumbDisposition, thumbnailFilename(filename))) + c.Data(http.StatusOK, thumbContentType, data) return } // Preserve the original endpoint's full-image fallback for formats that @@ -722,9 +733,7 @@ func (h *Handler) serveImageAssetFile(c *gin.Context, asset *database.ImageAsset } } - if strings.TrimSpace(asset.MimeType) != "" { - c.Header("Content-Type", asset.MimeType) - } + c.Header("Content-Type", contentType) c.Header("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, filename)) rc, size, err := backend.Open(c.Request.Context(), asset.StoragePath) @@ -733,10 +742,6 @@ func (h *Handler) serveImageAssetFile(c *gin.Context, asset *database.ImageAsset return } defer rc.Close() - contentType := strings.TrimSpace(asset.MimeType) - if contentType == "" { - contentType = "application/octet-stream" - } if size < 0 { // S3 偶尔不带 Content-Length,退化为 chunked。 c.Status(http.StatusOK) @@ -747,6 +752,17 @@ func (h *Handler) serveImageAssetFile(c *gin.Context, asset *database.ImageAsset c.DataFromReader(http.StatusOK, size, contentType, rc, nil) } +// safeInlineImageMimeType 只放行浏览器按图片渲染的常见类型。 +// 其他值(包括 text/html、image/svg+xml 和空值)返回 application/octet-stream。 +func safeInlineImageMimeType(value string) (string, bool) { + mediaType := strings.ToLower(strings.TrimSpace(strings.SplitN(value, ";", 2)[0])) + switch mediaType { + case "image/png", "image/jpeg", "image/webp", "image/gif": + return mediaType, true + } + return "application/octet-stream", false +} + func imageAssetPathAllowed(storagePath string) bool { storagePath = strings.TrimSpace(storagePath) if imagestore.IsS3Ref(storagePath) { @@ -1638,6 +1654,13 @@ func normalizeImageStudioModel(model string) string { } func normalizeImageJobUpscale(model, size, upscale string) (string, error) { + // Validate explicit physical targets before any network or queue work. + // An oversized target must not silently disable the strict-size promise. + if strings.Contains(strings.ToLower(size), "x") { + if _, _, ok := imageupscale.ParseSize(size); !ok { + return "", fmt.Errorf("size must be positive WIDTHxHEIGHT, at most 16384 per side and 40000000 pixels") + } + } rawUpscale := strings.ToLower(strings.TrimSpace(upscale)) // An absent value means "decide from the model and size"; "none" is the // explicit opt-out that suppresses that inference, so a caller can take the @@ -1714,6 +1737,9 @@ func paginationParams(c *gin.Context, defaultPageSize int) (int, int) { page = parsed } } + if page > maxAdminPage { + page = maxAdminPage + } pageSize := defaultPageSize if raw := c.Query("page_size"); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { diff --git a/admin/image_studio_external.go b/admin/image_studio_external.go index d99c017bb..e80b95c9f 100644 --- a/admin/image_studio_external.go +++ b/admin/image_studio_external.go @@ -168,7 +168,11 @@ func (h *Handler) CreateExternalImageJob(c *gin.Context) { } h.runImageGenerationJob(jobID, req, apiKey, opts) }() - c.JSON(http.StatusAccepted, externalImageJobResponse{Job: job}) + // 202 只返回小回执;params_json 可能包含全部输入图(数十 MB), + // 不能随响应回显给客户端,否则单请求即可放大内存/带宽。 + responseJob := *job + responseJob.ParamsJSON = "" + c.JSON(http.StatusAccepted, externalImageJobResponse{Job: &responseJob}) } func (h *Handler) GetExternalImageJob(c *gin.Context) { @@ -258,15 +262,24 @@ func normalizeExternalImageJobFields(req *imageGenerationJobPayload) (bool, erro } func normalizeExternalImageJobInputs(ctx context.Context, req *imageGenerationJobPayload) error { + return normalizeExternalImageJobInputsWithLimit(ctx, req, queueInputBudget) +} + +func normalizeExternalImageJobInputsWithLimit(ctx context.Context, req *imageGenerationJobPayload, limit int64) error { if req == nil { return fmt.Errorf("body is required") } images := make([]string, 0, len(req.InputImages)) + total := 0 for _, imageURL := range req.InputImages { normalizedImage, err := normalizeExternalInputImage(ctx, imageURL) if err != nil { return err } + total += len(normalizedImage) + if int64(total) > limit { + return fmt.Errorf("input_images total size exceeds %d bytes", limit) + } images = append(images, normalizedImage) } req.InputImages = images diff --git a/admin/image_studio_external_budget_test.go b/admin/image_studio_external_budget_test.go new file mode 100644 index 000000000..714967a8e --- /dev/null +++ b/admin/image_studio_external_budget_test.go @@ -0,0 +1,37 @@ +package admin + +import ( + "bytes" + "context" + "encoding/base64" + "testing" +) + +func TestNormalizeExternalImageJobInputsTotalBudget(t *testing.T) { + img := "data:image/png;base64," + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("a"), 40)) + req := &imageGenerationJobPayload{InputImages: []string{img, img}} + if err := normalizeExternalImageJobInputsWithLimit(context.Background(), req, 150); err == nil { + t.Fatal("expected total input budget error") + } + req = &imageGenerationJobPayload{InputImages: []string{img, img}} + if err := normalizeExternalImageJobInputsWithLimit(context.Background(), req, 300); err != nil { + t.Fatalf("unexpected error under budget: %v", err) + } +} + +func TestNormalizeExternalImageJobFieldsRejectsOversizedSize(t *testing.T) { + for _, size := range []string{"20000x20000", "16385x1", "8000x8000", "999999999999999999999999x1"} { + for _, upscale := range []string{"", "none", "4k"} { + req := &imageGenerationJobPayload{Prompt: "test", Size: size, Upscale: upscale} + if _, err := normalizeExternalImageJobFields(req); err == nil { + t.Errorf("oversized size %q accepted with upscale %q", size, upscale) + } + } + } + for _, size := range []string{"1920x1080", "16384x1", "auto", "4k", ""} { + req := &imageGenerationJobPayload{Prompt: "test", Size: size} + if _, err := normalizeExternalImageJobFields(req); err != nil { + t.Errorf("valid size %q rejected: %v", size, err) + } + } +} diff --git a/admin/image_studio_test.go b/admin/image_studio_test.go index 5b3c10142..43fdeb715 100644 --- a/admin/image_studio_test.go +++ b/admin/image_studio_test.go @@ -999,8 +999,15 @@ func TestExternalImageJobRouteNormalizesEditPayload(t *testing.T) { if created.Job == nil || created.Job.APIKeyID != keyID { t.Fatalf("created job = %#v, want api key id %d", created.Job, keyID) } + if created.Job.ParamsJSON != "" { + t.Fatal("202 receipt must not echo params_json/input images") + } + storedJob, err := db.GetImageGenerationJob(context.Background(), created.Job.ID) + if err != nil { + t.Fatalf("load stored job: %v", err) + } var params imageGenerationJobPayload - if err := json.Unmarshal([]byte(created.Job.ParamsJSON), ¶ms); err != nil { + if err := json.Unmarshal([]byte(storedJob.ParamsJSON), ¶ms); err != nil { t.Fatalf("decode params_json: %v", err) } if params.Prompt != "edit this" || params.Model != "gpt-image-2" || params.OutputFormat != "png" { @@ -1092,8 +1099,15 @@ func TestExternalImageJobRouteFetchesPublicInputImageAsDataURL(t *testing.T) { if created.Job == nil { t.Fatalf("created job is nil") } + if created.Job.ParamsJSON != "" { + t.Fatal("202 receipt must not echo params_json/input images") + } + storedJob, err := db.GetImageGenerationJob(context.Background(), created.Job.ID) + if err != nil { + t.Fatalf("load stored job: %v", err) + } var params imageGenerationJobPayload - if err := json.Unmarshal([]byte(created.Job.ParamsJSON), ¶ms); err != nil { + if err := json.Unmarshal([]byte(storedJob.ParamsJSON), ¶ms); err != nil { t.Fatalf("decode params_json: %v", err) } want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(tiny) diff --git a/admin/key_usage_public.go b/admin/key_usage_public.go index d2403ded9..4a3b3a0eb 100644 --- a/admin/key_usage_public.go +++ b/admin/key_usage_public.go @@ -166,6 +166,9 @@ func parsePublicAPIKeyUsageLogPagination(pageRaw, pageSizeRaw string) (int, int, if err != nil || parsed < 1 { return 0, 0, errors.New("page 参数无效,需要正整数") } + if parsed > maxAdminPage { + return 0, 0, errors.New("page 参数过大") + } page = parsed } diff --git a/admin/oauth.go b/admin/oauth.go index 4b06e054a..661a87698 100644 --- a/admin/oauth.go +++ b/admin/oauth.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log" "net/http" @@ -22,6 +23,7 @@ import ( "github.com/codex2api/auth" "github.com/codex2api/database" "github.com/codex2api/proxy" + "github.com/codex2api/security" "github.com/gin-gonic/gin" ) @@ -39,6 +41,8 @@ const ( // ==================== 内存 Session 存储 ==================== type oauthSession struct { + mu sync.Mutex // protects callback progress; authorization fields are immutable + State string CodeVerifier string RedirectURI string @@ -54,6 +58,34 @@ type oauthSession struct { ExchangeResult *oauthExchangeResult } +// claimExchange consumes a session before network I/O. Failed exchanges require +// a new authorization session because the upstream may already have used the code. +func (s *oauthSession) claimExchange(code, state string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if !s.CallbackAt.IsZero() || s.ExchangeResult != nil { + return false + } + s.CallbackCode, s.CallbackState, s.CallbackAt = code, state, time.Now() + return true +} + +func (s *oauthSession) completeExchange(result *oauthExchangeResult) { + s.mu.Lock() + s.ExchangeResult = result + s.mu.Unlock() +} + +func (s *oauthSession) callbackSnapshot() (*oauthExchangeResult, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.ExchangeResult != nil { + result := *s.ExchangeResult + return &result, true + } + return nil, !s.CallbackAt.IsZero() +} + // oauthExchangeResult 自动回调完成后的兑换结果 type oauthExchangeResult struct { Success bool `json:"success"` @@ -97,16 +129,26 @@ func (s *oauthSessionStore) delete(id string) { s.mu.Unlock() } -// findByState 通过 state 查找 session(回调端点使用,返回 sessionID + session) +// findByState 通过 state 查找 session(回调端点使用,返回 sessionID + session)。 +// 遍历全部条目并使用常量时间比较,避免按匹配位置提前返回。 func (s *oauthSessionStore) findByState(state string) (string, *oauthSession, bool) { s.mu.Lock() defer s.mu.Unlock() + var foundID string + var foundSess *oauthSession for id, sess := range s.sessions { - if sess.State == state && time.Since(sess.CreatedAt) <= oauthSessionTTL { - return id, sess, true + if !security.SecureCompare(sess.State, state) { + continue + } + if time.Since(sess.CreatedAt) > oauthSessionTTL { + continue } + foundID, foundSess = id, sess } - return "", nil, false + if foundSess == nil { + return "", nil, false + } + return foundID, foundSess, true } func (s *oauthSessionStore) cleanupLoop() { @@ -229,6 +271,11 @@ func (h *Handler) ExchangeOAuthCode(c *gin.Context) { writeError(c, http.StatusBadRequest, "state 不匹配,请重新发起授权") return } + if !sess.claimExchange(req.Code, req.State) { + writeError(c, http.StatusBadRequest, "该授权会话已处理或正在处理,请重新发起授权") + return + } + defer sess.completeExchange(&oauthExchangeResult{Error: "请重新发起授权"}) proxyURL := sess.ProxyURL if trimmed := strings.TrimSpace(req.ProxyURL); trimmed != "" { @@ -569,6 +616,11 @@ func (h *Handler) UpdateOAuthAccountCode(c *gin.Context) { writeError(c, http.StatusBadRequest, "state 不匹配,请重新发起授权") return } + if !sess.claimExchange(req.Code, req.State) { + writeError(c, http.StatusBadRequest, "该授权会话已处理或正在处理,请重新发起授权") + return + } + defer sess.completeExchange(&oauthExchangeResult{Error: "请重新发起授权"}) proxyURL := sess.ProxyURL if req.ProxyURL != "" { @@ -740,11 +792,12 @@ func (h *Handler) OAuthCallback(c *gin.Context) { c.String(http.StatusBadRequest, oauthCallbackPage("授权失败", "OAuth 会话不存在或已过期,请重新发起授权", false)) return } - - // 记录回调信息 - sess.CallbackCode = code - sess.CallbackState = state - sess.CallbackAt = time.Now() + if !sess.claimExchange(code, state) { + c.String(http.StatusBadRequest, oauthCallbackPage("授权失败", "该授权回调已处理,请重新发起授权", false)) + return + } + result := &oauthExchangeResult{Error: "授权失败,请重新发起授权"} + defer func() { sess.completeExchange(result) }() // 执行 code exchange(Resin 临时身份) proxyURL := sess.ProxyURL @@ -754,16 +807,17 @@ func (h *Handler) OAuthCallback(c *gin.Context) { resinTempID := "oauth-" + sessionID tokenResp, accountInfo, err := doOAuthCodeExchange(c.Request.Context(), code, sess.CodeVerifier, sess.RedirectURI, proxyURL, resinTempID) if err != nil { - sess.ExchangeResult = &oauthExchangeResult{ + log.Printf("OAuth callback token exchange failed: %s", security.SanitizeLog(err.Error())) + result = &oauthExchangeResult{ Success: false, - Error: err.Error(), + Error: "兑换 token 失败,请重新发起授权", } - c.String(http.StatusOK, oauthCallbackPage("授权失败", "兑换 token 失败: "+err.Error(), false)) + c.String(http.StatusOK, oauthCallbackPage("授权失败", result.Error, false)) return } if tokenResp.RefreshToken == "" { - sess.ExchangeResult = &oauthExchangeResult{ + result = &oauthExchangeResult{ Success: false, Error: "授权服务器未返回 refresh_token", } @@ -777,6 +831,49 @@ func (h *Handler) OAuthCallback(c *gin.Context) { expiresIn: tokenResp.ExpiresIn, }) + // 公开自助门户发起的会话必须走「待审核」入库,不能因为 state 被带到公开 + // /auth/callback 就绕过 SubmitAccountPortalCode 的 enabled=false 分支。 + if sess.SelfService { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + name := seed.email + if name == "" { + name = "self-service-account" + } + id, err := h.upsertSelfServiceAccount(ctx, name, proxyURL, seed, sess.ContactEmail) + if err == errDuplicateOAuthIdentity { + result = &oauthExchangeResult{ + Success: true, + Message: "该账号已在系统中,无需重复提交", + } + c.String(http.StatusOK, oauthCallbackPage("提交成功", "该账号已在系统中,无需重复提交。", true)) + return + } + if err != nil { + log.Printf("OAuth callback account write failed: %s", security.SanitizeLog(err.Error())) + result = &oauthExchangeResult{ + Success: false, + Error: "账号保存失败,请联系管理员并重新发起授权", + } + c.String(http.StatusOK, oauthCallbackPage("授权失败", result.Error, false)) + return + } + email := seed.email + if accountInfo != nil && accountInfo.Email != "" { + email = accountInfo.Email + } + security.SecurityAuditLog("ACCOUNT_PORTAL_SUBMITTED", "ip="+security.SanitizeLog(c.ClientIP())+" contact="+security.SanitizeLog(sess.ContactEmail)+" account="+security.SanitizeLog(email)) + result = &oauthExchangeResult{ + Success: true, + Message: "账号已提交,等待管理员审核", + ID: id, + Email: email, + } + log.Printf("自助门户回调新增待审核账号: id=%d email=%s", id, email) + c.String(http.StatusOK, oauthCallbackPage("提交成功", "已提交,等待管理员审核后生效。感谢你的贡献!", true)) + return + } + // 自动添加账号 name := "" if seed.email != "" { @@ -791,11 +888,12 @@ func (h *Handler) OAuthCallback(c *gin.Context) { id, updated, err := h.upsertOAuthIdentityAccount(ctx, name, proxyURL, seed, "oauth_callback") if err != nil { - sess.ExchangeResult = &oauthExchangeResult{ + log.Printf("OAuth callback account write failed: %s", security.SanitizeLog(err.Error())) + result = &oauthExchangeResult{ Success: false, - Error: "账号写入数据库失败: " + err.Error(), + Error: "账号保存失败,请联系管理员并重新发起授权", } - c.String(http.StatusOK, oauthCallbackPage("授权失败", "写入数据库失败: "+err.Error(), false)) + c.String(http.StatusOK, oauthCallbackPage("授权失败", result.Error, false)) return } @@ -822,7 +920,7 @@ func (h *Handler) OAuthCallback(c *gin.Context) { message = fmt.Sprintf("账号已存在,已更新账号 %d", id) pageMessage = fmt.Sprintf("账号已存在,已更新账号 %d,可以关闭此页面。", id) } - sess.ExchangeResult = &oauthExchangeResult{ + result = &oauthExchangeResult{ Success: true, Message: message, ID: id, @@ -849,17 +947,18 @@ func (h *Handler) PollOAuthCallback(c *gin.Context) { return } - if sess.ExchangeResult != nil { + result, processing := sess.callbackSnapshot() + if result != nil { // 回调已完成,返回结果并清理 session c.JSON(http.StatusOK, gin.H{ "status": "completed", - "result": sess.ExchangeResult, + "result": result, }) globalOAuthStore.delete(sessionID) return } - if sess.CallbackCode != "" { + if processing { // 收到回调但尚未完成兑换(罕见竞态) c.JSON(http.StatusOK, gin.H{ "status": "processing", @@ -873,7 +972,8 @@ func (h *Handler) PollOAuthCallback(c *gin.Context) { }) } -// oauthCallbackPage 生成简单的 HTML 回调结果页面 +// oauthCallbackPage 生成简单的 HTML 回调结果页面。 +// title/message 可能来自数据库错误或 token claims,必须转义后再插入。 func oauthCallbackPage(title, message string, success bool) string { color := "#e53e3e" icon := "❌" @@ -881,6 +981,8 @@ func oauthCallbackPage(title, message string, success bool) string { color = "#38a169" icon = "✔" } + title = html.EscapeString(title) + message = html.EscapeString(message) return fmt.Sprintf(` %s