From 1b21bd65280db949a37bb07ee08a403470ca6957 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 04:05:40 +0900 Subject: [PATCH] fix(server): allow POST /v1/alpha/search on the loopback listener The unauthenticated loopback listener admits routes through an allowlist in loopbackRouteAllowed(). /v1/alpha/search - the native Codex web-search relay - was never on it, so a directly-spawned codex app-server got 404 for every web search (#3192). Admit POST on that path. The handler runs its own admission (resolveApiAuth + validateForwardAdmissionCredential), so a loopback caller without a ChatGPT credential is still refused inside the relay; only the listener's 404 goes away. The public listener is unchanged. Clean reimplementation of #3193, whose branch was byte-corrupted by an encoding round-trip (em-dashes and emoji in ~30 unrelated comment lines). Supersedes #3193. Fixes #3192. Co-authored-by: alan7629 --- .../docs/fr/reference/configuration/server.md | 3 +- .../docs/reference/configuration/server.md | 4 +- .../docs/tr/reference/configuration/server.md | 5 ++- .../zh-tw/reference/configuration/server.md | 5 ++- src/server/index.ts | 10 ++++- tests/loopback-listener-integration.test.ts | 39 ++++++++++++++++++- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index bad04dd6dea..207f8a4c085 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -105,7 +105,8 @@ Le port est obligatoire et doit différer du port proxy. Il n'est jamais attribu changerait au fil des redémarrages tandis que les serveurs d'applications déjà en cours d'exécution conservaient le `base_url` précédent. L'écouteur ne sert que `POST /v1/responses`, sa mise à niveau WebSocket, `POST /v1/responses/compact`, -et `GET /v1/models`. Tout le reste, y compris `/api/*` et le tableau de bord, renvoie `404`. +`POST /v1/alpha/search` (le relais de recherche web natif de Codex), `GET /v1/models` et les mises à +niveau WebSocket vocales autonomes. Tout le reste, y compris `/api/*` et le tableau de bord, renvoie `404`. :::danger[Surface non authentifiée] Chaque processus de la machine peut utiliser cet écouteur. Il consomme le quota du compte et utilise les identifiants de diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 6976504b42a..7bc15c11912 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -113,7 +113,9 @@ The port is required and must differ from the proxy port. It is never OS-assigne would change across restarts while already-running app-servers kept the previous `base_url`. The listener serves only `POST /v1/responses`, its WebSocket upgrade, `POST /v1/responses/compact`, -and `GET /v1/models`. Everything else, including `/api/*` and the dashboard, returns `404`. +`POST /v1/alpha/search` (the native Codex web-search relay), `GET /v1/models`, and the standalone +realtime voice WebSocket upgrades. Everything else, including `/api/*` and the dashboard, returns +`404`. :::danger[This is an unauthenticated surface] Every process on the machine can use this listener. It spends account quota and paid provider diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index edbd593eeea..47c61479045 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -111,8 +111,9 @@ tarafından atanmaz: geçici bir port yeniden başlatmalar arasında değişirke zaten çalışan app-server'lar önceki `base_url`'i tutardı. Dinleyici yalnızca `POST /v1/responses`, onun WebSocket yükseltmesi, `POST -/v1/responses/compact` ve `GET /v1/models` sunar. `/api/*` ve kontrol paneli -dahil diğer her şey `404` döndürür. +/v1/responses/compact`, `POST /v1/alpha/search` (yerel Codex web arama aktarımı), +`GET /v1/models` ve bağımsız sesli WebSocket yükseltmelerini sunar. `/api/*` ve +kontrol paneli dahil diğer her şey `404` döndürür. :::danger[Bu kimliği doğrulanmamış bir yüzeydir] Makinedeki her süreç bu dinleyiciyi kullanabilir. Hesap kotasını ve ücretli diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 1d80f5911c0..e85594740dd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -89,8 +89,9 @@ stream 開啟前以 `401` 失敗。 該 port 是必填的,且必須與 proxy port 不同。它絕不會由 OS 指派:臨時 port 會在重啟時改變,而 已執行的 app-server 仍保留先前的 `base_url`。 -該 listener 只服務 `POST /v1/responses`、其 WebSocket upgrade、`POST /v1/responses/compact` 與 -`GET /v1/models`。其他一切,包括 `/api/*` 與儀表板,都會回傳 `404`。 +該 listener 只服務 `POST /v1/responses`、其 WebSocket upgrade、`POST /v1/responses/compact`、 +`POST /v1/alpha/search`(Codex 原生網頁搜尋中繼)、`GET /v1/models`,以及獨立語音 WebSocket upgrade。 +其他一切,包括 `/api/*` 與儀表板,都會回傳 `404`。 :::danger[這是一個未認證的介面] 機器上的每個 process 都可以使用此 listener。它會耗用帳號配額與付費 provider 憑證,也可能耗盡 diff --git a/src/server/index.ts b/src/server/index.ts index 0fe675463cd..1c4f72bb09d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -780,8 +780,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { // 5s default on a loaded Windows box, where the test measured 5.04s. }, SERVER_BUDGET_MS); - test("serves only the four allowlisted routes, using each route's real method", async () => { + test("serves only the allowlisted routes, using each route's real method", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); const server = startServer(0); @@ -285,13 +285,13 @@ describe("unauthenticated loopback listener", () => { { method: "POST", path: "/v1/chat/completions", body: '{"model":"x","messages":[]}' }, { method: "POST", path: "/v1/messages", body: '{"model":"x","messages":[]}' }, { method: "POST", path: "/v1/images/generations", body: '{"prompt":"x"}' }, - { method: "POST", path: "/v1/alpha/search", body: '{"query":"x"}' }, { method: "GET", path: "/v1/opencodex/artifacts/x" }, { method: "POST", path: "/v1/live", body: "{}" }, { method: "POST", path: "/v1/realtime/calls", body: "{}" }, // Allowlisted paths still reject the methods they do not serve. { method: "DELETE", path: "/v1/responses" }, { method: "POST", path: "/v1/models" }, + { method: "GET", path: "/v1/alpha/search" }, ]; for (const { method, path, body } of denied) { const res = await fetch(`${base}${path}`, { @@ -309,6 +309,41 @@ describe("unauthenticated loopback listener", () => { } }); + test("admits POST /v1/alpha/search so native web search reaches the relay (#3192)", async () => { + const loopbackPort = await freePort(); + saveConfig(baseConfig(loopbackPort)); + const server = startServer(0); + const body = '{"query":"x"}'; + const headers = { "content-type": "application/json" }; + try { + // Codex sends its native web-search call to the same base URL as /v1/responses. Before + // the allowlist admitted it, the direct-spawn host answered 404 for every search. What + // proves the gate is open is that the answer comes from BEHIND it: the admitted-turn + // path (503 while native-main maintenance holds, otherwise the relay's own 401 for a + // missing ChatGPT credential), never the listener's 404 and never the public + // listener's "opencodex API key required". + const viaLoopback = await fetch(`http://127.0.0.1:${loopbackPort}/v1/alpha/search`, { + method: "POST", body, headers, + }); + const loopbackBody = await viaLoopback.json() as { error?: { message?: string } }; + expect(viaLoopback.status).not.toBe(404); + expect([401, 503]).toContain(viaLoopback.status); + expect(loopbackBody.error?.message).toBeDefined(); + expect(loopbackBody.error?.message).not.toBe("opencodex API key required"); + + // The public listener is unchanged: the same request without a key is still refused + // at admission, so widening the loopback allowlist did not widen the public surface. + const viaPublic = await fetch(`http://127.0.0.1:${server.port}/v1/alpha/search`, { + method: "POST", body, headers, + }); + expect(viaPublic.status).toBe(401); + const publicBody = await viaPublic.json() as { error?: { message?: string } }; + expect(publicBody.error?.message).toBe("opencodex API key required"); + } finally { + await server.stop(true); + } + }); + test("admits standalone realtime voice WebSocket upgrades, HTTP stays rejected", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort));