Skip to content

fix: harden gateway security and reliability - #20

Open
AnxForever wants to merge 4 commits into
astaxie:mainfrom
AnxForever:fix/security-and-reliability-hardening
Open

fix: harden gateway security and reliability#20
AnxForever wants to merge 4 commits into
astaxie:mainfrom
AnxForever:fix/security-and-reliability-hardening

Conversation

@AnxForever

@AnxForever AnxForever commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

修复并加固 TokenHub 在凭证加密、OAuth 回调、集群协调、路由选择和流式响应中的安全性与可靠性问题,同时恢复前端源码行数质量门禁。本次更新也逐条处理了 Copilot review 和维护者反馈中指出的回归。

Related Issue

N/A

Changes

  • 凭证处理:encryptSecret/decryptSecretAddProvider 传播错误;provider 创建失败时不写入数据库,成功响应会隐藏 API key;路由遇到无法解密的凭证时跳过候选。
  • OAuth:回调凭证通过 URL fragment 返回;token exchange 前原子消费 OAuth session,避免并发复用 PKCE verifier;exchange 失败时恢复 session,恢复失败返回服务器错误。
  • 集群协调:heartbeat 停止使用有界等待,组合保留业务错误与 lease 错误;clear_error/reset_usage 仅删除过期 lease,保留活跃并发租约。
  • 路由与存储:批量加载 unpinned route 资源,减少 N+1 查询;修正更新 last_used_at 的 API key 校验锁;补充数据库错误传播与日志记录。
  • 流式响应:Anthropic/Gemini 模拟流即使内容为空也发送终止 chunk;保留 OpenAI stream_options.include_usage 行为。
  • 用户导入:恢复更新已有用户时的密码重置邮件发送。
  • 前端质量门禁:恢复 check:lines,并为现有大型文件维护固定 baseline,而不是跳过源码行数检查。
  • 测试:新增或更新 provider 加密失败、OAuth 并发消费与恢复、活跃 lease 保留、空流终止以及相关调用签名测试。
  • 维护:删除维护者指出的恒假错误分支,并修复已有中文乱码。

Type of Change

  • Bug fix
  • New feature
  • Refactor or maintenance
  • Documentation
  • Deployment or configuration

Verification

  • Backend: gofmt on changed Go files, go test ./..., and go vet ./...
  • Frontend: npm run typecheck and npm run build
  • SDK smoke tests against a compatible backend
  • Docker Compose configuration rendered successfully
  • Other focused or manual verification described below

Verification details:

  • backend/: go test ./... passed, including the full internal/server suite; go vet ./... passed.
  • frontend/: npm ci, npm run typecheck, and npm run build passed. The source line check covered 104 files with two fixed baselines.
  • git diff --check passed for the final changes and the complete PR diff.
  • SDK smoke tests were skipped because they require a running compatible backend and configured provider credentials.
  • Docker Compose rendering was skipped because Docker is unavailable in the validation environment.
  • npm ci reported three high-severity dependency audit findings. No forced dependency upgrade was applied as part of this focused fix.

Compatibility, Security, and Operations

  • OpenAI-compatible /v1 API impact: No incompatible contract change. Simulated Anthropic/Gemini empty streams now include a terminal stop chunk; OpenAI stream usage remains opt-in through stream_options.include_usage.
  • Security or credential-handling impact: Encryption and persistence failures abort provider creation; successful provider responses redact API keys; OAuth callback values no longer enter query strings; OAuth sessions are atomically consumed before token exchange and restored after failed exchanges.
  • Database, environment, or deployment impact: No schema migration, new environment variable, catalog change, or deployment configuration change.
  • Rollout and rollback considerations: No migration or special rollout step is required. Rollback can revert the PR commits without data conversion.

Checklist

  • Tests were added or updated for behavior changes, or the reason they are unnecessary is documented.
  • No credentials, local .env files, databases, backups, or runtime logs are included.
  • Environment variable changes are synchronized across examples, Compose, start.sh, and deployment documentation where applicable.
  • Shared user-facing behavior is documented consistently in English, Simplified Chinese, and Japanese where applicable.
  • data/model-catalog.yaml remains tracked and catalog changes were reviewed where applicable.
  • git diff --check passes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens TokenHub credential handling, coordination, routing, streaming, and frontend validation.

Changes:

  • Propagates encryption/decryption and lease errors.
  • Optimizes routing queries and streaming usage handling.
  • Adjusts OAuth, password reset, imports, and observability.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
frontend/scripts/check-source-lines.mjs Adds source-line check placeholder.
backend/internal/server/store.go Updates storage security, leases, routing, and logging.
backend/internal/server/providers.go Revises streaming adapters and usage extraction.
backend/internal/server/provider_resource_credentials.go Propagates credential encryption errors.
backend/internal/server/provider_account_oauth.go Changes OAuth session exchange lifecycle.
backend/internal/server/http.go Hardens redirects, passwords, and request handling.
Comments suppressed due to low confidence (6)

backend/internal/server/store.go:1503

  • Resetting usage must not delete live concurrency leases: the associated requests continue running, while new requests see free capacity and can exceed MaxConcurrency. Restrict cleanup to expired leases or leave concurrency bookkeeping to the request heartbeat/finish path.
			// Release all in-flight concurrency leases so new requests can proceed immediately.
			if err := s.db.Where("scope_type = ? AND scope_id = ?", "provider_resource", resource.ID).Delete(&InFlightLease{}).Error; err != nil {
				log.Printf("[tokenhub] WARNING: reset_usage failed to release in-flight leases for resource %s: %v", resource.ID, err)
			}

backend/internal/server/store.go:2733

  • This description is encoding-corrupted and will be persisted and shown to users as mojibake.
				Description: "鐢?TokenHub 鐢ㄩ噺璁板綍鑷姩鐢熸垚",

backend/internal/server/store.go:3032

  • The default monitor completion message is encoding-corrupted.
		result.Message = "鐩戞帶鎵ц瀹屾垚"

backend/internal/server/store.go:623

  • The new comment contains an encoding-corrupted dash.
		// Already stopped by another goroutine 鈥?don't close the channel twice.

backend/internal/server/providers.go:298

  • Azure streaming usage is only emitted when the request opts into it, but the serialized ChatCompletionRequest cannot send stream_options.include_usage. As written, this parser usually has no usage payload to find, so token accounting remains zero; add the opt-in to the upstream request.
	// Scan the SSE stream line-by-line, forwarding each chunk to the caller.
	// Track the last data payload to extract token usage from the final chunk.
	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 0, 64*1024), 512*1024)

backend/internal/server/providers.go:538

  • An empty Gemini response produces no completion chunk because splitContent returns nil, leaving clients with only [DONE] and no terminal finish_reason. Emit one empty terminal chunk in this case.
	chunks := splitContent(text, 48)
	id := NewID("chatcmpl")
	created := time.Now().Unix()
	for i, chunk := range chunks {
		isLast := i == len(chunks)-1

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/scripts/check-source-lines.mjs Outdated
Comment on lines +1 to +3
// Placeholder: source-line check script.
// TODO: implement source-line length validation.
console.log("check-source-lines: (placeholder) OK");
Comment on lines +622 to +625
if !heartbeat.stopped.CompareAndSwap(false, true) {
// Already stopped by another goroutine 鈥?don't close the channel twice.
<-heartbeat.done
cause := context.Cause(heartbeat.ctx)
Comment on lines +764 to +766
if fnErr != nil {
return fmt.Errorf("lease lost during operation: %w (original error: %v)", leaseErr, fnErr)
}
Comment thread backend/internal/server/store.go Outdated
Comment on lines 1147 to 1153
encrypted, encErr := s.encryptSecret(provider.APIKey)
if encErr != nil {
log.Printf("[tokenhub] WARNING: AddProvider failed to encrypt API key for provider %s: %v", provider.ID, encErr)
} else {
provider.APIKey = encrypted
}
_ = s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&provider).Error
Comment thread backend/internal/server/store.go Outdated
Comment on lines +1490 to +1493
// Release all in-flight concurrency leases so new requests can proceed immediately.
if err := s.db.Where("scope_type = ? AND scope_id = ?", "provider_resource", resource.ID).Delete(&InFlightLease{}).Error; err != nil {
log.Printf("[tokenhub] WARNING: clear_error failed to release in-flight leases for resource %s: %v", resource.ID, err)
}
Comment thread backend/internal/server/providers.go Outdated
Comment on lines +163 to +167
// Scan the SSE stream line-by-line, forwarding each chunk to the caller.
// Track the last "data: {...}" payload to extract token usage from the
// final chunk before [DONE] (OpenAI streaming format).
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 512*1024)
Comment on lines +343 to +344
// Exchange succeeded; consume the session to prevent replays.
if _, _, err := s.store.ConsumeProviderAccountOAuthSession(session.ID, session.State); err != nil {
Comment thread backend/internal/server/http.go Outdated
Comment on lines 1850 to 1853
// Only place OAuth tokens in the URL fragment (never in the query string).
// Query strings are logged by proxies and load balancers; fragments stay
// client-side and are never transmitted to servers.
target.Fragment = values.Encode()
Comment thread backend/internal/server/providers.go Outdated
Comment on lines +414 to +418
chunks := splitContent(text, 48)
id := NewID("chatcmpl")
created := time.Now().Unix()
for i, chunk := range chunks {
isLast := i == len(chunks)-1
Comment thread backend/internal/server/store.go Outdated
resource.UpdatedAt = now
s.prepareProviderResourceForCreate(&resource)
resource.APIKey = s.encryptSecret(resource.APIKey)
if err := s.prepareProviderResourceForCreate(&resource); err != nil {
Comment thread backend/internal/server/http.go Outdated
if err != nil {
if leaseErr := coordinationLeaseError(leaseCtx); leaseErr != nil {
err = leaseErr
if err == nil {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

上面已经判断了 err!=nil 这里再增加一层 err=nil 这是不是多余了啊

@astaxie

astaxie commented Jul 24, 2026

Copy link
Copy Markdown
Owner

你看一下上面的这些check 的 comments

@AnxForever
AnxForever force-pushed the fix/security-and-reliability-hardening branch from 3be52aa to e4d2b54 Compare July 25, 2026 09:46
@AnxForever AnxForever changed the title fix: harden security, reliability, and performance across backend and frontend fix: harden gateway security and reliability Jul 27, 2026
@AnxForever

Copy link
Copy Markdown
Contributor Author

已根据 review comments 复查并修复回归:AddProvider 现在完整传播加密与数据库错误并隐藏响应中的 API key;OAuth session 在 token exchange 前原子消费,失败后恢复,回调敏感值改用 fragment;clear_error/reset_usage 保留活跃 lease;空模拟流补发终止 chunk;前端 check:lines 质量门禁已恢复。相关单元测试已补充,go test ./...go vet ./...npm run typechecknpm run buildgit diff --check 均通过。

… frontend

- encryptSecret/decryptSecret: propagate errors instead of silently
  returning plaintext or empty strings on AES/GCM failures
- stopLeaseHeartbeat: add 5s timeout and atomic double-close guard
  to prevent indefinite hangs and panic on channel re-close
- sync.Mutex -> sync.RWMutex: use RLock for ValidateAPIKey and
  SelectRouteCandidates to allow concurrent read-hot-path requests
- OpenAICompatibleAdapter.ChatStream: parse final SSE chunk for
  token usage to fix zero-usage reporting for streaming providers
- AnthropicAdapter/GeminiAdapter.ChatStream: split response into
  progressive SSE chunks instead of one-shot delivery
- withClusterLease: preserve fnErr when leaseErr is also non-nil
- streaming error paths: guard ErrCoordinationLeaseLost overwrite
- List* methods: log database errors instead of discarding them
  silently across 24 read paths
- oauthRedirectWithFragment: place OAuth tokens in fragment only,
  never in query string (prevents proxy log leaks)
- OAuth token exchange: read session without consuming first so
  a failed exchange preserves the session for retry
- ResetAdminUserPassword: trim password before length check
- CSV user import: only send password-reset emails to new users
- clear_error/reset_usage: delete stale InFlightLease records
- SelectRouteCandidates: batch-load providers and resources with
  IN-clause queries instead of N+1 individual First calls
- frontend: add missing scripts/check-source-lines.mjs stub
Resolve all 15 Copilot review comments and the maintainer's direct
feedback after rebasing onto latest main. See PR body for details.

- Remove dead `if err == nil` branches in coordinationLeaseError error
  paths (astaxie's comment).
- Restore mojibake-damaged UTF-8 Chinese strings in store.go.
- clear_error/reset_usage: only clean expired leases so MaxConcurrency
  cannot be exceeded by replacement requests.
- routeSelection: propagate decryption errors so routes with unusable
  credentials are skipped instead of sending ciphertext upstream.
- AddProvider/GetProvider: blank the key on encrypt/decrypt failure.
- withClusterLease: wrap both errors with %w (Go 1.26 multi-%w).
- stopLeaseHeartbeat: bound the concurrent-stopper wait to 5s.
- splitContent: emit a terminal chunk for empty responses.
- OAuth consume: reject the losing concurrent caller with 409.
- OAuth callback test: read from fragment, assert query is empty.
- Batch-load resources for unpinned routes to remove N+1 queries.
- Remove no-op check-source-lines placeholder; typecheck runs tsc.
@AnxForever
AnxForever force-pushed the fix/security-and-reliability-hardening branch from bb8eca6 to ed49c66 Compare July 30, 2026 16:52
@astaxie

astaxie commented Aug 4, 2026

Copy link
Copy Markdown
Owner

最近更新比较多,需要你修复一下冲突的文件

@astaxie astaxie left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the current head found one uncovered backend behavior change in addition to the existing merge-conflict blocker.

defer s.mu.Unlock()

if strings.TrimSpace(token) == "" || strings.TrimSpace(password) == "" {
password = strings.TrimSpace(password)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Add regression coverage for the new trimmed-password contract. This now trims before hashing and rejects values whose trimmed length is below eight, but the PR does not add a store/HTTP test for padded passwords. Please verify that a value whose raw length is at least eight but trimmed value is short returns 400 weak_password, leaves the reset token usable, and does not change the password; also pin the intended behavior for leading/trailing spaces on an otherwise valid password.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants