From 371c1fd85b283d78c3154d775d5ec1e7063c5561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Sat, 12 Sep 2026 14:27:43 +0800 Subject: [PATCH 1/2] fix: answer CORS OPTIONS without an API key Browser extensions send a preflight OPTIONS request without Authorization. Serve 204 with CORS headers before auth so /v1 remains usable cross-origin, while GET/POST still require a key. --- CHANGELOG.md | 2 ++ README.md | 2 +- README_EN.md | 2 +- deploy/README.md | 2 +- internal/api/auth_test.go | 63 +++++++++++++++++++++++++++++++++++++++ internal/api/server.go | 36 ++++++++++++++++++++++ 6 files changed, 104 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4914f91..fc9a8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English +- Answer CORS preflight `OPTIONS` without an API key so browser extensions can call `/v1` - Send WorkBuddy Deepseek V4.1 Flash thinking as official top-level reasoning fields so streamed thinking comes back - Show WorkBuddy catalog context budgets (default and optional window) on the model list without inventing a Trae Max switch ### 中文 +- CORS 预检 `OPTIONS` 不再要求 API Key,浏览器扩展可以调用 `/v1` - WorkBuddy 的 Deepseek V4.1 Flash 改为发送官方顶层思考字段,流式思考内容可以返回 - 模型列表展示 WorkBuddy 目录里的默认和可选上下文窗口,不套用 Trae 的更大上下文开关 diff --git a/README.md b/README.md index cd5b7f7..a26756e 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ CLI2API 是本地网关:不提供账号、额度或官方 API 服务,不做 ## 安全 -默认只监听 `127.0.0.1:3010`;除 `/health` 和静态前端资源外,所有 API 与控制台数据接口均需要 API Key。不要提交 `.qoder`、Token、Cookie、登录 Blob 或原始抓包;凭证导出是显式敏感操作,请妥善保管导出文件。上游 API 或 CLI 更新可能导致兼容性变化,项目会固定并检查 qodercli 版本。发现安全问题请按 [SECURITY.md](SECURITY.md) 私下报告。 +默认只监听 `127.0.0.1:3010`;除 `/health`、静态前端资源和 CORS 预检 `OPTIONS` 外,所有 API 与控制台数据接口均需要 API Key。不要提交 `.qoder`、Token、Cookie、登录 Blob 或原始抓包;凭证导出是显式敏感操作,请妥善保管导出文件。上游 API 或 CLI 更新可能导致兼容性变化,项目会固定并检查 qodercli 版本。发现安全问题请按 [SECURITY.md](SECURITY.md) 私下报告。 ## 社区 diff --git a/README_EN.md b/README_EN.md index d08b4ff..617463f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -102,7 +102,7 @@ CLI2API is a local gateway: it does not provide accounts, quotas, or an official ## Security -The service binds `127.0.0.1:3010` by default; all APIs and console data endpoints require the API key except `/health` and static frontend assets. Never commit `.qoder`, tokens, cookies, auth blobs, or raw captures; credential export is an explicit sensitive operation — protect exported files. Upstream API or CLI changes may affect compatibility; qodercli is pinned and checked. Please report security issues privately according to [SECURITY.md](SECURITY.md). +The service binds `127.0.0.1:3010` by default; all APIs and console data endpoints require the API key except `/health`, static frontend assets, and CORS preflight `OPTIONS`. Never commit `.qoder`, tokens, cookies, auth blobs, or raw captures; credential export is an explicit sensitive operation — protect exported files. Upstream API or CLI changes may affect compatibility; qodercli is pinned and checked. Please report security issues privately according to [SECURITY.md](SECURITY.md). ## Community diff --git a/deploy/README.md b/deploy/README.md index 50966aa..af1f06a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -124,7 +124,7 @@ The API key is generated once and stored in SQLite. There is no environment-vari | `POST` | `/v1/responses` | OpenAI Responses-compatible API | | `GET/POST/PATCH/DELETE` | `/api/*` | Console management API | -All console and API routes except `/health` require the API key stored in SQLite. +All console and API routes except `/health` and CORS preflight `OPTIONS` require the API key stored in SQLite. ## 7. Managed next-version update diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 0af77b6..4c8a329 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -30,6 +30,69 @@ func TestClassifyCanceledErrorDoesNotBecomeAuth(t *testing.T) { } } +func TestCORSPreflightSkipsAPIKey(t *testing.T) { + srv := New(config.Config{ + Host: "127.0.0.1", + Port: 3010, + ProxyAPIKey: "secret", + QoderHome: t.TempDir(), + }) + defer srv.Close() + h := srv.Handler() + + for _, path := range []string{ + "/v1/models", + "/v1/chat/completions", + "/v1/messages", + "/v1/responses", + } { + req := httptest.NewRequest(http.MethodOptions, path, nil) + req.Header.Set("Origin", "chrome-extension://abc") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "authorization,content-type,x-api-key") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("OPTIONS %s: got %d want 204 body=%s", path, rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "chrome-extension://abc" { + t.Fatalf("OPTIONS %s allow-origin=%q", path, got) + } + allowHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers")) + if !strings.Contains(allowHeaders, "authorization") || !strings.Contains(allowHeaders, "content-type") { + t.Fatalf("OPTIONS %s allow-headers=%q", path, rec.Header().Get("Access-Control-Allow-Headers")) + } + if rec.Body.Len() != 0 { + t.Fatalf("OPTIONS %s body=%s", path, rec.Body.String()) + } + } +} + +func TestCORSHeadersOnUnauthorizedChat(t *testing.T) { + srv := New(config.Config{ + Host: "127.0.0.1", + Port: 3010, + ProxyAPIKey: "secret", + QoderHome: t.TempDir(), + }) + defer srv.Close() + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte("{}"))) + req.Header.Set("Origin", "chrome-extension://abc") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("POST without key: got %d want 401 body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "chrome-extension://abc" { + t.Fatalf("allow-origin=%q", got) + } + if got := rec.Header().Get("Access-Control-Expose-Headers"); !strings.Contains(got, "X-Request-Id") { + t.Fatalf("expose-headers=%q", got) + } +} + func TestManagementRoutesRequireAPIKey(t *testing.T) { srv := New(config.Config{ Host: "127.0.0.1", diff --git a/internal/api/server.go b/internal/api/server.go index faf4f46..b9adbb1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -172,6 +172,9 @@ func generateAPIKey() (string, error) { func (s *Server) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if applyCORS(w, r) { + return + } if s.maintenance.Load() && blocksDuringUpdate(r.URL.Path) { writeErr(w, http.StatusServiceUnavailable, "service_updating", "Service update in progress") return @@ -180,6 +183,39 @@ func (s *Server) Handler() http.Handler { }) } +const ( + corsAllowMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + corsAllowHeaders = "Authorization, Content-Type, x-api-key, X-CLI2API-Session, X-Qoder-Account" + corsExposeHeaders = "X-Request-Id, X-Qoder-Account, X-CLI2API-Account, X-CLI2API-Provider, Retry-After" +) + +func applyCORS(w http.ResponseWriter, r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" && r.Method != http.MethodOptions { + return false + } + allowOrigin := origin + if allowOrigin == "" { + allowOrigin = "*" + } + header := w.Header() + header.Set("Access-Control-Allow-Origin", allowOrigin) + header.Set("Access-Control-Allow-Methods", corsAllowMethods) + allowHeaders := strings.TrimSpace(r.Header.Get("Access-Control-Request-Headers")) + if allowHeaders == "" { + allowHeaders = corsAllowHeaders + } + header.Set("Access-Control-Allow-Headers", allowHeaders) + header.Set("Access-Control-Expose-Headers", corsExposeHeaders) + header.Set("Access-Control-Max-Age", "86400") + header.Add("Vary", "Origin") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return true + } + return false +} + func (s *Server) Close() error { if s.stopLogs != nil { close(s.stopLogs) From 9230eb24e640ea3ed072bcd28311626626e98231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Mon, 14 Sep 2026 10:44:46 +0800 Subject: [PATCH 2/2] test(workbuddy): align catalog expectation with model exposure --- internal/providers/workbuddy/client_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go index 3a5253c..30840ea 100644 --- a/internal/providers/workbuddy/client_test.go +++ b/internal/providers/workbuddy/client_test.go @@ -571,7 +571,7 @@ func TestChatRequestFindsStoredReasoningByCanonicalKey(t *testing.T) { } } -func TestModelsAcceptsGlobalCLIAgentNamesAndUsesAccountRegion(t *testing.T) { +func TestModelsAcceptsGlobalAgentNamesAndUsesAccountRegion(t *testing.T) { payload, _ := Credential{AccessToken: "at", UID: "u1", Domain: "codebuddy.cn", ExpiresAt: 4102444800}.Encode() store := &memStore{items: map[string][]byte{"acc1": payload}, region: "global"} var origin, requestHost, ideType string @@ -602,7 +602,7 @@ func TestModelsAcceptsGlobalCLIAgentNamesAndUsesAccountRegion(t *testing.T) { if err != nil { t.Fatal(err) } - if len(models) != 1 || models[0].NativeModel != "glm-5.2" { + if len(models) != 2 || models[0].NativeModel != "glm-5.2" || models[1].NativeModel != "web-model" { t.Fatalf("models=%+v", models) } if origin != "https://www.workbuddy.ai" {