From 58fef363684785242d481b05f11ee8d69c919294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Sat, 1 Aug 2026 17:22:29 +0800 Subject: [PATCH 1/2] fix(server): match browser extension CORS origins exactly --- .../content/docs/reference/configuration.md | 2 +- .../docs/zh-cn/reference/configuration.md | 2 +- src/server/auth-cors.ts | 23 +++++++++--- src/types.ts | 2 +- tests/server-auth.test.ts | 37 +++++++++++++++++++ tests/server-loopback-host-gate.test.ts | 35 ++++++++++++++++++ 6 files changed, 93 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 799630ff38..a814609f18 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -70,7 +70,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `visionSidecar?` | `OcxVisionSidecarConfig` | on | Vision sidecar options (see below). | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex's built-in `image_gen` tool (see below). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | -| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. | `codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 4f4f935ad2..4e8f5dc2b3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -62,7 +62,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 开启 | 网络搜索 sidecar 选项(见下文)。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | 开启 | 视觉 sidecar 选项(见下文)。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +| `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许;支持 `chrome-extension://<扩展 ID>` 等基于 authority 的浏览器扩展 origin,`*` 不是通配符。 | `codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, 中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4505510425..aedf4a932e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -80,15 +80,28 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { if (!cfg.corsAllowOrigins?.length) return false; + const parsedOrigin = comparableOrigin(origin); return cfg.corsAllowOrigins.some(allowed => { - try { - return new URL(allowed).origin === new URL(origin).origin; - } catch { - return allowed === origin; - } + const parsedAllowed = comparableOrigin(allowed); + return parsedOrigin !== null && parsedAllowed !== null + ? parsedAllowed === parsedOrigin + : allowed === origin; }); } +function comparableOrigin(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.origin !== "null") return parsed.origin; + // WHATWG URL exposes authority-based custom schemes (for example browser + // extensions) as opaque `null` origins. Compare their scheme + authority so + // one allowlisted extension cannot admit every other opaque origin. + return parsed.host ? `${parsed.protocol}//${parsed.host}` : null; + } catch { + return null; + } +} + export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); diff --git a/src/types.ts b/src/types.ts index 8ada4a536d..e063837dc7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -750,7 +750,7 @@ export interface OcxConfig { combos?: Record; /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ tokenGuardian?: OcxTokenGuardianConfig; - /** Additional origins allowed for CORS (e.g. ["https://clisu-oracle.tail19a2d7.ts.net"]). Loopback origins are always allowed. */ + /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://). Loopback origins are always allowed. */ corsAllowOrigins?: string[]; } diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 41ccefff9e..c938ef14dd 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -632,6 +632,43 @@ describe("server local API auth", () => { } }); + test("extension allowlist gates preflight and data-plane requests by authority", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const extensionOrigin = "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"; + saveConfig({ + ...config("127.0.0.1"), + corsAllowOrigins: [extensionOrigin], + }); + stubModelDiscoveryFor("https://api.example.test"); + + const server = startServer(0); + const modelsUrl = new URL("/v1/models", server.url); + try { + const preflight = await fetch(modelsUrl, { + method: "OPTIONS", + headers: { + origin: extensionOrigin, + "access-control-request-method": "GET", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe(extensionOrigin); + + const accepted = await fetch(modelsUrl, { headers: { origin: extensionOrigin } }); + expect(accepted.status).toBe(200); + expect(accepted.headers.get("access-control-allow-origin")).toBe(extensionOrigin); + + const rejected = await fetch(modelsUrl, { + headers: { origin: "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + }); + expect(rejected.status).toBe(403); + } finally { + await server.stop(true); + } + }); + test("loopback management API rejects host-header same-origin rebinding", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/server-loopback-host-gate.test.ts b/tests/server-loopback-host-gate.test.ts index 8c8a617fe6..e5937eeb16 100644 --- a/tests/server-loopback-host-gate.test.ts +++ b/tests/server-loopback-host-gate.test.ts @@ -96,3 +96,38 @@ describe("isAllowedRequestOrigin over a forwarded port", () => { ).toBe(false); }); }); + +describe("isAllowedRequestOrigin with extension origins", () => { + test("admits only the configured browser extension authority", () => { + const config = { + ...loopbackConfig, + corsAllowOrigins: ["chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"], + } as OcxConfig; + + expect( + isAllowedRequestOrigin( + request("localhost:10100", "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"), + config, + ), + ).toBe(true); + expect( + isAllowedRequestOrigin( + request("localhost:10100", "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + config, + ), + ).toBe(false); + expect( + isAllowedRequestOrigin( + request("localhost:10100", "moz-extension://modkelfkcfjpgbfmnbnllalkiogfofh"), + config, + ), + ).toBe(false); + + expect( + isAllowedRequestOrigin( + request("localhost:10100", "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"), + { ...loopbackConfig, corsAllowOrigins: ["*"] } as OcxConfig, + ), + ).toBe(false); + }); +}); From a346ad607cc8c6d30dd7708e33f05ad96629e79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Sun, 2 Aug 2026 09:19:06 +0800 Subject: [PATCH 2/2] docs: sync extension CORS origin guidance --- docs-site/src/content/docs/ja/reference/configuration.md | 2 +- docs-site/src/content/docs/ko/reference/configuration.md | 2 +- docs-site/src/content/docs/ru/reference/configuration.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 80d0dbf7bc..927d64dbc3 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -63,7 +63,7 @@ namespaced selected id を bare id に変えます。 | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | ウェブ検索サイドカーオプション(下記参照)。 | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | ビジョンサイドカーオプション(下記参照)。 | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +| `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。`chrome-extension://` など、authority ベースのブラウザー拡張機能の origin に対応しています。`*` はワイルドカードではありません。 | `codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII 英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index 3259182c3f..b0f22b0dc3 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -64,7 +64,7 @@ namespaced selected id를 bare id로 바꿉니다. | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | 웹 검색 사이드카 옵션(아래 참조). | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | 비전 사이드카 옵션(아래 참조). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | -| `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +| `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. `chrome-extension://` 같은 authority 기반 브라우저 확장 프로그램 origin을 지원하며, `*`는 와일드카드가 아닙니다. | `codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index f950e8570a..84238610ac 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -68,7 +68,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | вкл. | Параметры сайдкара веб-поиска (см. ниже). | | `visionSidecar?` | `OcxVisionSidecarConfig` | вкл. | Параметры vision-сайдкара (см. ниже). | | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | -| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. Поддерживаются origin расширений браузера на основе authority, например `chrome-extension://`; `*` не является подстановочным знаком. | Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные