Skip to content

Commit ab5a20c

Browse files
committed
fix(server): match browser extension CORS origins by scheme+authority
Applies the maintainer-reviewed implementation from PR #850 (eachann1024) onto current dev. URL.origin serializes every extension scheme as "null" per WHATWG URL 4.7, so the old origin-equality check in isExtraAllowedOrigin admitted ANY browser extension whenever one was allowlisted. comparableOrigin now keeps WHATWG origins for normal schemes and compares protocol//host for authority-based opaque origins; hostless opaque origins keep exact-string fallback. Covers both planes through the shared predicate: data-plane /v1/* and management /api/* (preflight + GUI session issuance). Beyond the PR: management-plane preflight assertions (/api/settings accept + cross-extension reject) folded from the wt4 audit; locale docs re-based onto the configuration/server.md subpages (docs split 7fdb2cb) with the Firefox/Safari UUID-rotation caveat (per-install / per-launch regeneration — MDN, Mozilla bug 1717671, WebKit bug 244330). Tests: 69/69 server-auth + server-loopback-host-gate; typecheck green.
1 parent 19d002b commit ab5a20c

10 files changed

Lines changed: 144 additions & 11 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# wp1 execution notes (P-phase stale check, 2026-08-02)
2+
3+
## Stale check results
4+
5+
- `010_implementation.md` line refs: auth-cors.ts `isExtraAllowedOrigin` confirmed live at :81-89 in the pre-fix form; `setCorsOrigin` :21. `origin_rejected` sites in `src/server/index.ts` = 10 (grep-confirmed), all funneled through `isAllowedRequestOrigin`/`isAllowedManagementOrigin` → both call `isExtraAllowedOrigin`, so ONE predicate fix covers every data-plane + management-plane rejection site.
6+
- Bun URL behavior verified locally (primary proof): `chrome-extension://abc123/page.html`, `moz-extension://u-u-i-d/`, `safari-web-extension://UUID/` all serialize `.origin` as `"null"` with `host` populated.
7+
- Docs drift: `docs-site` configuration reference was split into domain subpages (commit 7fdb2cb8e). The `corsAllowOrigins` row now lives at `docs-site/src/content/docs/<locale>/reference/configuration/server.md` (:19-20) for all five locales (EN, zh-cn, ko, ja, ru). The PR #850 doc hunks DO NOT APPLY to current dev — docs must be re-based onto the subpage rows.
8+
- PR #850 state: OPEN, CONFLICTING vs dev. Maintainer review blocker (locale doc drift) was already fixed by the contributor in a346ad60, so the fetched diff head is review-clean. Code+test hunks APPLY CLEAN to dev@478354ee8 (`git apply --check --include='src/**' --include='tests/**'`).
9+
10+
## wp1 execution decision
11+
12+
Apply the maintainer-reviewed PR #850 diff for `src/server/auth-cors.ts`, `src/types.ts`, `tests/server-auth.test.ts`, `tests/server-loopback-host-gate.test.ts` verbatim (credit eachann1024), then re-write the five `server.md` rows by hand in the new subpage location with the same content the PR used (authority-based extension origins supported; `*` is not a wildcard).
13+
14+
Rationale: the implementation is small, already maintainer-reviewed, and its test shape (live server preflight + data-plane + unit-level cross-scheme and `*` rejection) matches the acceptance criteria in `010_implementation.md` exactly. Hand-rewriting an equivalent fix would add risk, not value.
15+
16+
## Acceptance (carried from 010, mapped to PR tests)
17+
18+
1. Configured extension ID passes preflight + `/v1/models` — covered by the new `server-auth.test.ts` case (204 + allow-origin echo; 200 data-plane).
19+
2. Different extension ID rejected — covered (403 live; false unit).
20+
3. Cross-scheme isolation (`moz-extension://<same-id>` rejected) — covered by unit test.
21+
4. `*` rejected — covered by unit test.
22+
5. Non-extension behavior unchanged — existing `server-auth` / `server-loopback-host-gate` suites must stay green.
23+
6. Docs: five locale `server.md` rows updated; locales must not contradict EN.

docs-site/src/content/docs/ja/reference/configuration/server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: リスナー、リモート アクセス、アドミッション
1616
| `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 |
1717
| `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 |
1818
| `websockets?` | `boolean` | `false` |応答 WebSocket パスとして `supports_websockets` をアドバタイズします。 False は HTTP/SSE を維持します。 |
19-
| `corsAllowOrigins?` | `string[]` | `[]` |追加の正確な CORS 起点。ループバック起点は常に許可されます|
19+
| `corsAllowOrigins?` | `string[]` | `[]` | 追加の正確な CORS origin。ループバック origin は常に許可します。`chrome-extension://<extension-id>` など authority ベースのブラウザー拡張 origin に対応し、`*` はワイルドカードではありません。Firefox と Safari は拡張 UUID を(インストール/ブラウザー起動ごとに)再生成するため、origin が変わったらエントリを更新してください|
2020
| `apiKeys?` | `OcxApiKey[]` | `[]` |生成された `ocx_…` 資格情報は、非ループバック バインドでの管理およびデータ プレーン認証によって受け入れられました。ダッシュボードで管理。 |
2121
| `storageCleanupPolicy?` | `StorageCleanupPolicy` |無効 |アーカイブされたセッションのクリーンアップ ポリシーをオプトインします。暗黙的に有効になることはありません。 |
2222
| `appOwnedMemoryBudgetMb?` | `number` | `256` |排除可能なアプリ所有のログ、キャッシュ、BLOB、および継続ペイロードの MiB の上限。範囲は 64 ~ 4096。 RSSキャップではありません。 |

docs-site/src/content/docs/ko/reference/configuration/server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소,
1616
| `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. |
1717
| `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. |
1818
| `websockets?` | `boolean` | `false` | Responses WebSocket 경로에 `supports_websockets`를 광고합니다. `false`이면 HTTP/SSE를 유지합니다. |
19-
| `corsAllowOrigins?` | `string[]` | `[]` | 추가로 허용할 정확한 CORS origin입니다. 루프백 origin은 항상 허용됩니다. |
19+
| `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin입니다. 루프백 origin은 항상 허용됩니다. `chrome-extension://<extension-id>` 같은 authority 기반 브라우저 확장 origin을 지원하며, `*`는 와일드카드가 아닙니다. Firefox와 Safari는 확장 UUID를 (설치/브라우저 실행 때마다) 새로 만드므로 origin이 바뀌면 항목을 갱신하세요. |
2020
| `apiKeys?` | `OcxApiKey[]` | `[]` | 비루프백 바인드에서 관리 API와 데이터 플레인 인증이 허용하는 생성된 `ocx_…` 자격 증명입니다. 대시보드에서 관리합니다. |
2121
| `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | 선택적으로 활성화하는 보관 세션 정리 정책입니다. 절대 암묵적으로 활성화되지 않습니다. |
2222
| `appOwnedMemoryBudgetMb?` | `number` | `256` | 제거 가능한 앱 소유 로그, 캐시, blob, continuation payload에 대한 MiB 단위 상한입니다. 범위는 64–4096이며 RSS 상한은 아닙니다. |

docs-site/src/content/docs/reference/configuration/server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ runs helper features around provider requests.
1717
| `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. |
1818
| `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. |
1919
| `websockets?` | `boolean` | `false` | Advertise `supports_websockets` for the Responses WebSocket path. False keeps HTTP/SSE. |
20-
| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact CORS origins. Loopback origins are always allowed. |
20+
| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://<extension-id>` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. |
2121
| `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Dashboard-managed. |
2222
| `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. |
2323
| `appOwnedMemoryBudgetMb?` | `number` | `256` | Cap in MiB for evictable app-owned logs, caches, blobs, and continuation payloads. Range 64–4096; not an RSS cap. |

docs-site/src/content/docs/ru/reference/configuration/server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ description: Listener, удалённый доступ, admission key, тайм
1717
| `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. |
1818
| `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. |
1919
| `websockets?` | `boolean` | `false` | Объявлять `supports_websockets` для WebSocket-пути Responses. Значение false удерживает HTTP/SSE. |
20-
| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные CORS-origin. Loopback-origin разрешены всегда. |
20+
| `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. Поддерживаются authority-based origin браузерных расширений, например `chrome-extension://<extension-id>`; `*` не является маской. Firefox и Safari пересоздают UUID расширения (при каждой установке/запуске браузера), поэтому обновляйте запись при смене origin. |
2121
| `apiKeys?` | `OcxApiKey[]` | `[]` | Сгенерированные credentials `ocx_…`, принимаемые для management и data-plane auth на не-loopback bind'ах. Управляются через дашборд. |
2222
| `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in policy очистки архивированных сессий. Никогда не включается неявно. |
2323
| `appOwnedMemoryBudgetMb?` | `number` | `256` | Лимит в MiB для eviction-friendly app-owned log'ов, cache'ей, blob'ов и continuation payload'ов. Это не RSS-cap. Диапазон 64–4096. |

docs-site/src/content/docs/zh-cn/reference/configuration/server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、
1717
| `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 |
1818
| `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 |
1919
| `websockets?` | `boolean` | `false` | 为 Responses WebSocket 路径声明 `supports_websockets`。设为 false 会保留 HTTP/SSE。 |
20-
| `corsAllowOrigins?` | `string[]` | `[]` | 额外的精确 CORS 来源。回环来源始终允许|
20+
| `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许;支持 `chrome-extension://<扩展 ID>` 等基于 authority 的浏览器扩展 origin,`*` 不是通配符。Firefox 和 Safari 会(每次安装/启动浏览器时)重新生成扩展 UUID,origin 变化后请更新该条目|
2121
| `apiKeys?` | `OcxApiKey[]` | `[]` | 管理平面和非回环绑定上的数据平面身份验证可接受的已生成 `ocx_…` 凭据。由仪表板管理。 |
2222
| `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | 可选启用的归档会话清理策略。不会被隐式启用。 |
2323
| `appOwnedMemoryBudgetMb?` | `number` | `256` | 可逐出应用自有日志、缓存、blob 和续传载荷的内存上限,单位 MiB。范围 64–4096;不是 RSS 上限。 |

src/server/auth-cors.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,28 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
8080

8181
function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
8282
if (!cfg.corsAllowOrigins?.length) return false;
83+
const parsedOrigin = comparableOrigin(origin);
8384
return cfg.corsAllowOrigins.some(allowed => {
84-
try {
85-
return new URL(allowed).origin === new URL(origin).origin;
86-
} catch {
87-
return allowed === origin;
88-
}
85+
const parsedAllowed = comparableOrigin(allowed);
86+
return parsedOrigin !== null && parsedAllowed !== null
87+
? parsedAllowed === parsedOrigin
88+
: allowed === origin;
8989
});
9090
}
9191

92+
function comparableOrigin(value: string): string | null {
93+
try {
94+
const parsed = new URL(value);
95+
if (parsed.origin !== "null") return parsed.origin;
96+
// WHATWG URL exposes authority-based custom schemes (for example browser
97+
// extensions) as opaque `null` origins. Compare their scheme + authority so
98+
// one allowlisted extension cannot admit every other opaque origin.
99+
return parsed.host ? `${parsed.protocol}//${parsed.host}` : null;
100+
} catch {
101+
return null;
102+
}
103+
}
104+
92105
export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
93106
const host = req.headers.get("Host");
94107
const parsedHost = parseHttpHost(host);

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,7 @@ export interface OcxConfig {
756756
combos?: Record<string, OcxComboConfig>;
757757
/** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */
758758
tokenGuardian?: OcxTokenGuardianConfig;
759-
/** Additional origins allowed for CORS (e.g. ["https://clisu-oracle.tail19a2d7.ts.net"]). Loopback origins are always allowed. */
759+
/** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://<id>). Loopback origins are always allowed. */
760760
corsAllowOrigins?: string[];
761761
}
762762

tests/server-auth.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,68 @@ describe("server local API auth", () => {
632632
}
633633
});
634634

635+
test("extension allowlist gates preflight and data-plane requests by authority", async () => {
636+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
637+
mkdirSync(TEST_DIR, { recursive: true });
638+
process.env.OPENCODEX_HOME = TEST_DIR;
639+
const extensionOrigin = "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh";
640+
saveConfig({
641+
...config("127.0.0.1"),
642+
corsAllowOrigins: [extensionOrigin],
643+
});
644+
stubModelDiscoveryFor("https://api.example.test");
645+
646+
const server = startServer(0);
647+
const modelsUrl = new URL("/v1/models", server.url);
648+
try {
649+
const preflight = await fetch(modelsUrl, {
650+
method: "OPTIONS",
651+
headers: {
652+
origin: extensionOrigin,
653+
"access-control-request-method": "GET",
654+
},
655+
});
656+
expect(preflight.status).toBe(204);
657+
expect(preflight.headers.get("access-control-allow-origin")).toBe(extensionOrigin);
658+
659+
const accepted = await fetch(modelsUrl, { headers: { origin: extensionOrigin } });
660+
expect(accepted.status).toBe(200);
661+
expect(accepted.headers.get("access-control-allow-origin")).toBe(extensionOrigin);
662+
663+
const rejected = await fetch(modelsUrl, {
664+
headers: { origin: "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
665+
});
666+
expect(rejected.status).toBe(403);
667+
668+
// The management plane shares isExtraAllowedOrigin: the configured extension gets
669+
// its preflight echoed, any other extension is refused before reaching /api/*.
670+
const managementUrl = new URL("/api/settings", server.url);
671+
const managementPreflight = await fetch(managementUrl, {
672+
method: "OPTIONS",
673+
headers: {
674+
origin: extensionOrigin,
675+
"access-control-request-method": "GET",
676+
},
677+
});
678+
expect(managementPreflight.status).toBe(204);
679+
expect(managementPreflight.headers.get("access-control-allow-origin")).toBe(extensionOrigin);
680+
681+
const managementRejected = await fetch(managementUrl, {
682+
method: "OPTIONS",
683+
headers: {
684+
origin: "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
685+
"access-control-request-method": "GET",
686+
},
687+
});
688+
expect(managementRejected.status).toBe(403);
689+
expect(managementRejected.headers.get("access-control-allow-origin")).not.toBe(
690+
"chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
691+
);
692+
} finally {
693+
await server.stop(true);
694+
}
695+
});
696+
635697
test("loopback management API rejects host-header same-origin rebinding", async () => {
636698
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
637699
mkdirSync(TEST_DIR, { recursive: true });

tests/server-loopback-host-gate.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,38 @@ describe("isAllowedRequestOrigin over a forwarded port", () => {
9696
).toBe(false);
9797
});
9898
});
99+
100+
describe("isAllowedRequestOrigin with extension origins", () => {
101+
test("admits only the configured browser extension authority", () => {
102+
const config = {
103+
...loopbackConfig,
104+
corsAllowOrigins: ["chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"],
105+
} as OcxConfig;
106+
107+
expect(
108+
isAllowedRequestOrigin(
109+
request("localhost:10100", "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"),
110+
config,
111+
),
112+
).toBe(true);
113+
expect(
114+
isAllowedRequestOrigin(
115+
request("localhost:10100", "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
116+
config,
117+
),
118+
).toBe(false);
119+
expect(
120+
isAllowedRequestOrigin(
121+
request("localhost:10100", "moz-extension://modkelfkcfjpgbfmnbnllalkiogfofh"),
122+
config,
123+
),
124+
).toBe(false);
125+
126+
expect(
127+
isAllowedRequestOrigin(
128+
request("localhost:10100", "chrome-extension://modkelfkcfjpgbfmnbnllalkiogfofh"),
129+
{ ...loopbackConfig, corsAllowOrigins: ["*"] } as OcxConfig,
130+
),
131+
).toBe(false);
132+
});
133+
});

0 commit comments

Comments
 (0)