diff --git a/README.md b/README.md index cd5b7f7..9bfc948 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`、静态前端资源和 OpenAI 兼容 `/v1/*` 的 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..2fa511f 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` for the OpenAI-compatible `/v1/*` endpoints. 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..bed96ae 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` for the OpenAI-compatible `/v1/*` endpoints require the API key stored in SQLite. ## 7. Managed next-version update @@ -205,4 +205,4 @@ accepted for staged-update hosts; unknown versions fail closed. - Published host updater assets cover Linux, macOS, and Windows on `amd64` and `arm64`. - Run the Windows updater installer as the same signed-in user that runs Docker Desktop; do not install it as `LocalSystem`. - Keep `deploy/.env` private because Docker Desktop mode stores the updater token there. -- Do not remove `qoder-data` unless you intentionally want to delete SQLite accounts and credentials. \ No newline at end of file +- Do not remove `qoder-data` unless you intentionally want to delete SQLite accounts and credentials. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index aff41db..10e65fa 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -30,6 +30,31 @@ func TestClassifyCanceledErrorDoesNotBecomeAuth(t *testing.T) { } } +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 != "*" { + 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/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" {